sliftutils 1.7.8 → 1.7.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/storageserver.js +4 -0
- package/bundler/bundleEntry.ts +14 -10
- package/bundler/bundler.ts +6 -1
- package/{teststorage → examplestorage}/browser.tsx +18 -22
- package/{teststorage/server.ts → examplestorage/exampleserver.ts} +5 -5
- package/index.d.ts +528 -78
- package/misc/https/dns.d.ts +7 -3
- package/misc/https/dns.ts +4 -9
- package/misc/https/hostServer.d.ts +6 -3
- package/misc/https/hostServer.ts +6 -6
- package/package.json +2 -1
- package/spec.txt +77 -42
- package/storage/ArchivesDisk.d.ts +65 -0
- package/storage/ArchivesDisk.ts +365 -0
- package/storage/IArchives.d.ts +95 -1
- package/storage/IArchives.ts +146 -1
- package/storage/backblaze.d.ts +14 -2
- package/storage/backblaze.ts +18 -2
- package/storage/remoteStorage/ArchivesRemote.d.ts +29 -17
- package/storage/remoteStorage/ArchivesRemote.ts +63 -63
- package/storage/remoteStorage/ArchivesUrl.d.ts +46 -0
- package/storage/remoteStorage/ArchivesUrl.ts +74 -0
- package/storage/remoteStorage/accessPage.tsx +111 -28
- package/storage/remoteStorage/blobStore.d.ts +79 -25
- package/storage/remoteStorage/blobStore.ts +441 -287
- package/storage/remoteStorage/cliArgs.d.ts +1 -0
- package/storage/remoteStorage/cliArgs.ts +9 -0
- package/storage/remoteStorage/createArchives.d.ts +64 -0
- package/storage/remoteStorage/createArchives.ts +395 -0
- package/storage/remoteStorage/grantAccess.js +4 -0
- package/storage/remoteStorage/grantAccessCli.d.ts +1 -0
- package/storage/remoteStorage/grantAccessCli.ts +27 -0
- package/storage/remoteStorage/remoteConfig.d.ts +21 -0
- package/storage/remoteStorage/remoteConfig.ts +94 -0
- package/storage/remoteStorage/storageController.d.ts +19 -27
- package/storage/remoteStorage/storageController.ts +205 -136
- package/storage/remoteStorage/storageServer.d.ts +11 -0
- package/storage/remoteStorage/storageServer.ts +80 -93
- package/storage/remoteStorage/storageServerCli.d.ts +1 -0
- package/storage/remoteStorage/storageServerCli.ts +41 -0
- package/storage/remoteStorage/storageServerState.d.ts +37 -0
- package/storage/remoteStorage/storageServerState.ts +358 -0
- package/testsite/server.ts +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getArg(name: string): string | undefined;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function getArg(name: string): string | undefined {
|
|
2
|
+
let index = process.argv.indexOf(`--${name}`);
|
|
3
|
+
if (index < 0) return undefined;
|
|
4
|
+
let value = process.argv[index + 1];
|
|
5
|
+
if (!value || value.startsWith("--")) {
|
|
6
|
+
throw new Error(`Missing value for --${name}`);
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/// <reference types="node" />
|
|
3
|
+
import { IArchives, RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig, ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus } from "../IArchives";
|
|
4
|
+
export declare function createApiArchives(source: HostedConfig | BackblazeConfig): IArchives;
|
|
5
|
+
export declare class ArchivesChain implements IArchives {
|
|
6
|
+
private normalized;
|
|
7
|
+
private adopted;
|
|
8
|
+
private sourcesPromise;
|
|
9
|
+
constructor(config: RemoteConfig | RemoteConfigBase);
|
|
10
|
+
getDebugName(): string;
|
|
11
|
+
private getSourceConfigs;
|
|
12
|
+
private getSources;
|
|
13
|
+
private init;
|
|
14
|
+
private ensureRouting;
|
|
15
|
+
private readFromSource;
|
|
16
|
+
private read;
|
|
17
|
+
private getAccessHelp;
|
|
18
|
+
private write;
|
|
19
|
+
waitingForAccess(): Promise<{
|
|
20
|
+
link: string;
|
|
21
|
+
machineId: string;
|
|
22
|
+
ip: string;
|
|
23
|
+
} | undefined>;
|
|
24
|
+
get(fileName: string, config?: {
|
|
25
|
+
range?: {
|
|
26
|
+
start: number;
|
|
27
|
+
end: number;
|
|
28
|
+
};
|
|
29
|
+
}): Promise<Buffer | undefined>;
|
|
30
|
+
get2(fileName: string, config?: {
|
|
31
|
+
range?: {
|
|
32
|
+
start: number;
|
|
33
|
+
end: number;
|
|
34
|
+
};
|
|
35
|
+
}): Promise<{
|
|
36
|
+
data: Buffer;
|
|
37
|
+
writeTime: number;
|
|
38
|
+
} | undefined>;
|
|
39
|
+
getInfo(fileName: string): Promise<{
|
|
40
|
+
writeTime: number;
|
|
41
|
+
size: number;
|
|
42
|
+
} | undefined>;
|
|
43
|
+
find(prefix: string, config?: {
|
|
44
|
+
shallow?: boolean;
|
|
45
|
+
type: "files" | "folders";
|
|
46
|
+
}): Promise<string[]>;
|
|
47
|
+
findInfo(prefix: string, config?: {
|
|
48
|
+
shallow?: boolean;
|
|
49
|
+
type: "files" | "folders";
|
|
50
|
+
}): Promise<ArchiveFileInfo[]>;
|
|
51
|
+
getChangesAfter(time: number): Promise<ArchiveFileInfo[]>;
|
|
52
|
+
getSyncStatus(): Promise<ArchivesSyncStatus>;
|
|
53
|
+
getConfig(): Promise<ArchivesConfig>;
|
|
54
|
+
set(fileName: string, data: Buffer, config?: {
|
|
55
|
+
lastModified?: number;
|
|
56
|
+
}): Promise<void>;
|
|
57
|
+
del(fileName: string): Promise<void>;
|
|
58
|
+
setLargeFile(config: {
|
|
59
|
+
path: string;
|
|
60
|
+
getNextData(): Promise<Buffer | undefined>;
|
|
61
|
+
}): Promise<void>;
|
|
62
|
+
getURL(path: string): Promise<string>;
|
|
63
|
+
}
|
|
64
|
+
export declare function createArchives(config: RemoteConfig | RemoteConfigBase): ArchivesChain;
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
module.allowclient = true;
|
|
2
|
+
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import { isNode } from "socket-function/src/misc";
|
|
6
|
+
import {
|
|
7
|
+
IArchives, RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig,
|
|
8
|
+
ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus,
|
|
9
|
+
} from "../IArchives";
|
|
10
|
+
import {
|
|
11
|
+
ROUTING_FILE, getConfigVersion, getBucketBaseUrl, parseHostedUrl,
|
|
12
|
+
normalizeRemoteConfig, normalizeSource, parseRoutingData, serializeRemoteConfig,
|
|
13
|
+
} from "./remoteConfig";
|
|
14
|
+
import { ArchivesRemote } from "./ArchivesRemote";
|
|
15
|
+
import { ArchivesUrl } from "./ArchivesUrl";
|
|
16
|
+
import { ArchivesBackblaze } from "../backblaze";
|
|
17
|
+
import { getStorageServerConfigOptional, getLocalArchives } from "./storageServerState";
|
|
18
|
+
import { STORAGE_ACCESS_DENIED } from "./storageController";
|
|
19
|
+
|
|
20
|
+
// Turns a RemoteConfig into a usable IArchives (createArchives). Setup is fully lazy: on the first
|
|
21
|
+
// call we read the routing file (ROUTING_FILE) from every source, adopt the newest version found
|
|
22
|
+
// (exactly ONE level of indirection - we never fetch routing configs from newly adopted sources),
|
|
23
|
+
// and write the adopted config to every source that is missing it (which CREATES not-yet-existing
|
|
24
|
+
// buckets) or holds an older version.
|
|
25
|
+
|
|
26
|
+
const DOWN_RETRY_DELAY = 30 * 1000;
|
|
27
|
+
const ENSURE_RETRY_DELAY = 30 * 1000;
|
|
28
|
+
|
|
29
|
+
// The direct API IArchives for one source, with no URL-form fallback and no chaining. Used by the
|
|
30
|
+
// storage server for its synchronization sources: hosted sources block waiting for account access
|
|
31
|
+
// (logging instructions) when access hasn't been granted yet.
|
|
32
|
+
export function createApiArchives(source: HostedConfig | BackblazeConfig): IArchives {
|
|
33
|
+
if (source.type === "backblaze") {
|
|
34
|
+
return new ArchivesBackblaze({ bucketName: source.bucketName, public: source.public, immutable: source.immutable });
|
|
35
|
+
}
|
|
36
|
+
let parsed = parseHostedUrl(source.url);
|
|
37
|
+
let server = isNode() && getStorageServerConfigOptional() || undefined;
|
|
38
|
+
if (server && parsed.address === server.domain && parsed.port === server.port) {
|
|
39
|
+
// This source is a bucket hosted by our own process - use it directly instead of calling
|
|
40
|
+
// ourselves over HTTPS
|
|
41
|
+
return getLocalArchives(parsed.account, parsed.bucketName);
|
|
42
|
+
}
|
|
43
|
+
return new ArchivesRemote({ url: source.url, accountName: source.accountName });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function hasBackblazeCreds(): boolean {
|
|
47
|
+
return isNode() && fs.existsSync(os.homedir() + "/backblaze.json");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type ChainSource = {
|
|
51
|
+
config: HostedConfig | BackblazeConfig;
|
|
52
|
+
// Direct API access. Missing when we can only use the public-URL form (backblaze without
|
|
53
|
+
// credentials, or backblaze in the browser).
|
|
54
|
+
api?: IArchives;
|
|
55
|
+
// Public-URL fallback, used when the API denies us access. Never created when the config
|
|
56
|
+
// explicitly says public: false (then we don't even hit the URL).
|
|
57
|
+
url?: ArchivesUrl;
|
|
58
|
+
// Why writes to this source can never work (collected into the error when a write finds no
|
|
59
|
+
// source that accepts it)
|
|
60
|
+
writeBlocked?: string;
|
|
61
|
+
// After a source errors we stop trying it until this time, so one down source doesn't stall
|
|
62
|
+
// every call
|
|
63
|
+
downUntil: number;
|
|
64
|
+
// Whether we confirmed this source holds an up-to-date routing file. Writing that file creates
|
|
65
|
+
// the bucket, so this also tracks which buckets we've initialized.
|
|
66
|
+
routingEnsured: boolean;
|
|
67
|
+
lastEnsureAttempt: number;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function buildChainSource(config: HostedConfig | BackblazeConfig): ChainSource {
|
|
71
|
+
let source: ChainSource = { config, downUntil: 0, routingEnsured: false, lastEnsureAttempt: 0 };
|
|
72
|
+
if (config.type === "backblaze") {
|
|
73
|
+
if (hasBackblazeCreds()) {
|
|
74
|
+
source.api = new ArchivesBackblaze({ bucketName: config.bucketName, public: config.public, immutable: config.immutable });
|
|
75
|
+
} else if (isNode()) {
|
|
76
|
+
source.writeBlocked = `No backblaze credentials for ${config.url}. Create ~/backblaze.json with { "applicationKeyId": ..., "applicationKey": ... } to enable API access.`;
|
|
77
|
+
} else {
|
|
78
|
+
source.writeBlocked = `Browsers cannot write to backblaze (bucket ${config.url})`;
|
|
79
|
+
}
|
|
80
|
+
if (!source.api && config.public !== false) {
|
|
81
|
+
source.url = new ArchivesUrl(getBucketBaseUrl(config.url));
|
|
82
|
+
}
|
|
83
|
+
return source;
|
|
84
|
+
}
|
|
85
|
+
let parsed = parseHostedUrl(config.url);
|
|
86
|
+
let server = isNode() && getStorageServerConfigOptional() || undefined;
|
|
87
|
+
if (server && parsed.address === server.domain && parsed.port === server.port) {
|
|
88
|
+
// A bucket hosted by our own process - use it directly instead of calling ourselves
|
|
89
|
+
source.api = getLocalArchives(parsed.account, parsed.bucketName);
|
|
90
|
+
return source;
|
|
91
|
+
}
|
|
92
|
+
source.api = new ArchivesRemote({ url: config.url, accountName: config.accountName, waitForAccess: false });
|
|
93
|
+
if (config.public !== false) {
|
|
94
|
+
source.url = new ArchivesUrl(getBucketBaseUrl(config.url));
|
|
95
|
+
}
|
|
96
|
+
return source;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class ArchivesChain implements IArchives {
|
|
100
|
+
private normalized: RemoteConfig;
|
|
101
|
+
// The config we operate on: ours, or a newer-versioned one adopted from a source's routing file
|
|
102
|
+
private adopted: RemoteConfig;
|
|
103
|
+
private sourcesPromise: Promise<ChainSource[]> | undefined;
|
|
104
|
+
|
|
105
|
+
constructor(config: RemoteConfig | RemoteConfigBase) {
|
|
106
|
+
this.normalized = normalizeRemoteConfig(config);
|
|
107
|
+
this.adopted = this.normalized;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
public getDebugName() {
|
|
111
|
+
let urls = this.adopted.sources.map(x => typeof x === "string" && x || (x as HostedConfig | BackblazeConfig).url);
|
|
112
|
+
return `chain/${urls.join(",")}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private getSourceConfigs(config: RemoteConfig): (HostedConfig | BackblazeConfig)[] {
|
|
116
|
+
return config.sources.map(normalizeSource);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private getSources(): Promise<ChainSource[]> {
|
|
120
|
+
if (!this.sourcesPromise) {
|
|
121
|
+
let promise = this.init();
|
|
122
|
+
this.sourcesPromise = promise;
|
|
123
|
+
// A failed init isn't cached, so a source coming up later fixes us
|
|
124
|
+
promise.catch(() => {
|
|
125
|
+
if (this.sourcesPromise === promise) {
|
|
126
|
+
this.sourcesPromise = undefined;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return this.sourcesPromise;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async init(): Promise<ChainSource[]> {
|
|
134
|
+
let sources = this.getSourceConfigs(this.normalized).map(buildChainSource);
|
|
135
|
+
let fetched = await Promise.all(sources.map(async source => {
|
|
136
|
+
try {
|
|
137
|
+
let data = await this.readFromSource(source, archives => archives.get(ROUTING_FILE));
|
|
138
|
+
return data && parseRoutingData(data) || undefined;
|
|
139
|
+
} catch {
|
|
140
|
+
// Down or access denied - ensureRouting retries reconciliation for this source later
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}));
|
|
144
|
+
let best = this.normalized;
|
|
145
|
+
for (let config of fetched) {
|
|
146
|
+
if (config && getConfigVersion(config) > getConfigVersion(best)) {
|
|
147
|
+
best = config;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
this.adopted = best;
|
|
151
|
+
if (best !== this.normalized) {
|
|
152
|
+
// Exactly one level of indirection: the adopted config's sources are used directly,
|
|
153
|
+
// and we never fetch routing configs from THEM to adopt something newer again
|
|
154
|
+
sources = this.getSourceConfigs(best).map(buildChainSource);
|
|
155
|
+
await Promise.all(sources.map(source => this.ensureRouting(source)));
|
|
156
|
+
return sources;
|
|
157
|
+
}
|
|
158
|
+
for (let i = 0; i < sources.length; i++) {
|
|
159
|
+
let config = fetched[i];
|
|
160
|
+
if (config && getConfigVersion(config) >= getConfigVersion(best)) {
|
|
161
|
+
sources[i].routingEnsured = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
await Promise.all(sources.map((source, i) => this.ensureRouting(source, { known: { existing: fetched[i] } })));
|
|
165
|
+
return sources;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Makes sure a source holds our adopted routing config, creating the bucket when it doesn't
|
|
169
|
+
// exist yet (a bucket exists iff its routing file does) and upgrading older versions. Failures
|
|
170
|
+
// (no access, source down) are swallowed - we retry periodically, and actual writes surface
|
|
171
|
+
// the real error.
|
|
172
|
+
private async ensureRouting(source: ChainSource, config?: { known?: { existing: RemoteConfig | undefined }; force?: boolean }): Promise<void> {
|
|
173
|
+
if (source.routingEnsured) return;
|
|
174
|
+
if (!config?.force && source.lastEnsureAttempt !== 0 && Date.now() - source.lastEnsureAttempt < ENSURE_RETRY_DELAY) return;
|
|
175
|
+
source.lastEnsureAttempt = Date.now();
|
|
176
|
+
let known = config?.known;
|
|
177
|
+
try {
|
|
178
|
+
let existing = known && known.existing;
|
|
179
|
+
if (!known) {
|
|
180
|
+
let data = await this.readFromSource(source, archives => archives.get(ROUTING_FILE));
|
|
181
|
+
existing = data && parseRoutingData(data) || undefined;
|
|
182
|
+
}
|
|
183
|
+
if (existing && getConfigVersion(existing) >= getConfigVersion(this.adopted)) {
|
|
184
|
+
source.routingEnsured = true;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (!source.api) return;
|
|
188
|
+
await source.api.set(ROUTING_FILE, serializeRemoteConfig(this.adopted));
|
|
189
|
+
source.routingEnsured = true;
|
|
190
|
+
} catch { }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Reads from one source, API first, falling back to the public-URL form when the API denies us
|
|
194
|
+
// access. Access-denied does NOT mark the source down (access can be granted at any moment);
|
|
195
|
+
// genuine failures do.
|
|
196
|
+
private async readFromSource<T>(source: ChainSource, run: (archives: IArchives) => Promise<T>): Promise<T> {
|
|
197
|
+
if (source.api) {
|
|
198
|
+
try {
|
|
199
|
+
return await run(source.api);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
let denied = String((e as Error).stack || e).includes(STORAGE_ACCESS_DENIED);
|
|
202
|
+
if (!denied) {
|
|
203
|
+
source.downUntil = Date.now() + DOWN_RETRY_DELAY;
|
|
204
|
+
throw e;
|
|
205
|
+
}
|
|
206
|
+
if (!source.url) throw e;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (!source.url) {
|
|
210
|
+
throw new Error(`Source ${source.config.url} has no API access and no public URL form (public: false)`);
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
return await run(source.url);
|
|
214
|
+
} catch (e) {
|
|
215
|
+
source.downUntil = Date.now() + DOWN_RETRY_DELAY;
|
|
216
|
+
throw e;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Tries each source in turn, skipping recently-down ones. A source that answers - even with
|
|
221
|
+
// undefined / empty - is authoritative: sources are synchronized copies of the same bucket, so
|
|
222
|
+
// a miss on one is a miss everywhere (we don't scan the rest).
|
|
223
|
+
private async read<T>(config: { apiOnly?: boolean }, run: (archives: IArchives) => Promise<T>): Promise<T> {
|
|
224
|
+
let sources = await this.getSources();
|
|
225
|
+
let errors: string[] = [];
|
|
226
|
+
for (let source of sources) {
|
|
227
|
+
if (source.downUntil > Date.now()) {
|
|
228
|
+
errors.push(`${source.config.url} is down, retrying after ${new Date(source.downUntil).toISOString()}`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
// Opportunistic (even on reads): create the bucket / upgrade its routing where needed
|
|
232
|
+
void this.ensureRouting(source);
|
|
233
|
+
if (config.apiOnly) {
|
|
234
|
+
let api = source.api;
|
|
235
|
+
if (!api) {
|
|
236
|
+
errors.push(`${source.config.url} has URL-only access, which cannot serve this operation`);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
return await run(api);
|
|
241
|
+
} catch (e) {
|
|
242
|
+
if (!String((e as Error).stack || e).includes(STORAGE_ACCESS_DENIED)) {
|
|
243
|
+
source.downUntil = Date.now() + DOWN_RETRY_DELAY;
|
|
244
|
+
}
|
|
245
|
+
errors.push(String(e));
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
return await this.readFromSource(source, run);
|
|
251
|
+
} catch (e) {
|
|
252
|
+
errors.push(String(e));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
throw new Error(`All sources failed for ${this.getDebugName()}: ${errors.join(" | ")}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private getAccessHelp(source: ChainSource): string {
|
|
259
|
+
if (source.config.type === "remote") {
|
|
260
|
+
let parsed = parseHostedUrl(source.config.url);
|
|
261
|
+
let account = source.config.accountName || parsed.account;
|
|
262
|
+
return `No write access to account ${JSON.stringify(account)} on ${parsed.address}:${parsed.port}. Visit https://${parsed.address}:${parsed.port}/${account} to grant this machine access.`;
|
|
263
|
+
}
|
|
264
|
+
return `Write to ${source.config.url} was denied (check the ~/backblaze.json credentials)`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Writes go to the first source that accepts them - the storage servers synchronize the
|
|
268
|
+
// sources between themselves. If no source accepts the write, the error explains how to get
|
|
269
|
+
// write access (the access page link, or the backblaze secret to create).
|
|
270
|
+
private async write(run: (archives: IArchives) => Promise<void>): Promise<void> {
|
|
271
|
+
let sources = await this.getSources();
|
|
272
|
+
let errors: string[] = [];
|
|
273
|
+
for (let source of sources) {
|
|
274
|
+
if (source.config.syncOptions?.noWriteBack) continue;
|
|
275
|
+
if (source.writeBlocked) {
|
|
276
|
+
errors.push(source.writeBlocked);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
let api = source.api;
|
|
280
|
+
if (!api) continue;
|
|
281
|
+
if (source.downUntil > Date.now()) {
|
|
282
|
+
errors.push(`${source.config.url} is down, retrying after ${new Date(source.downUntil).toISOString()}`);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
// The write needs the bucket to exist, so this one is awaited (and never throttled)
|
|
286
|
+
await this.ensureRouting(source, { force: true });
|
|
287
|
+
try {
|
|
288
|
+
return await run(api);
|
|
289
|
+
} catch (e) {
|
|
290
|
+
if (String((e as Error).stack || e).includes(STORAGE_ACCESS_DENIED)) {
|
|
291
|
+
errors.push(this.getAccessHelp(source));
|
|
292
|
+
} else {
|
|
293
|
+
source.downUntil = Date.now() + DOWN_RETRY_DELAY;
|
|
294
|
+
errors.push(String(e));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
throw new Error(`Write failed on every source of ${this.getDebugName()}: ${errors.join(" | ") || "no sources accept writes"}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// The access-page link (plus our machineId/ip, for the approver to match the request) for the
|
|
302
|
+
// first hosted source that hasn't granted us access. undefined when we have access everywhere
|
|
303
|
+
// we can have it. Also registers the access request server-side.
|
|
304
|
+
public async waitingForAccess(): Promise<{ link: string; machineId: string; ip: string } | undefined> {
|
|
305
|
+
let sources = await this.getSources();
|
|
306
|
+
for (let source of sources) {
|
|
307
|
+
if (source.api instanceof ArchivesRemote) {
|
|
308
|
+
let waiting = await source.api.waitingForAccess();
|
|
309
|
+
if (waiting) return waiting;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
public async get(fileName: string, config?: { range?: { start: number; end: number } }): Promise<Buffer | undefined> {
|
|
316
|
+
let result = await this.get2(fileName, config);
|
|
317
|
+
return result && result.data || undefined;
|
|
318
|
+
}
|
|
319
|
+
public async get2(fileName: string, config?: { range?: { start: number; end: number } }): Promise<{ data: Buffer; writeTime: number } | undefined> {
|
|
320
|
+
return await this.read({}, archives => archives.get2(fileName, config));
|
|
321
|
+
}
|
|
322
|
+
public async getInfo(fileName: string): Promise<{ writeTime: number; size: number } | undefined> {
|
|
323
|
+
return await this.read({}, archives => archives.getInfo(fileName));
|
|
324
|
+
}
|
|
325
|
+
public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
|
|
326
|
+
return await this.read({ apiOnly: true }, archives => archives.find(prefix, config));
|
|
327
|
+
}
|
|
328
|
+
public async findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<ArchiveFileInfo[]> {
|
|
329
|
+
return await this.read({ apiOnly: true }, archives => archives.findInfo(prefix, config));
|
|
330
|
+
}
|
|
331
|
+
public async getChangesAfter(time: number): Promise<ArchiveFileInfo[]> {
|
|
332
|
+
return await this.read({ apiOnly: true }, async archives => {
|
|
333
|
+
if (!archives.getChangesAfter) {
|
|
334
|
+
throw new Error(`${archives.getDebugName()} does not support getChangesAfter`);
|
|
335
|
+
}
|
|
336
|
+
return await archives.getChangesAfter(time);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
public async getSyncStatus(): Promise<ArchivesSyncStatus> {
|
|
340
|
+
return await this.read({ apiOnly: true }, async archives => {
|
|
341
|
+
if (!archives.getSyncStatus) {
|
|
342
|
+
throw new Error(`${archives.getDebugName()} does not support getSyncStatus`);
|
|
343
|
+
}
|
|
344
|
+
return await archives.getSyncStatus();
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
public async getConfig(): Promise<ArchivesConfig> {
|
|
348
|
+
let sources = await this.getSources();
|
|
349
|
+
if (!sources.some(x => x.api)) return {};
|
|
350
|
+
return await this.read({ apiOnly: true }, archives => archives.getConfig());
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
public async set(fileName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
|
|
354
|
+
await this.write(archives => archives.set(fileName, data, config));
|
|
355
|
+
}
|
|
356
|
+
public async del(fileName: string): Promise<void> {
|
|
357
|
+
await this.write(archives => archives.del(fileName));
|
|
358
|
+
}
|
|
359
|
+
public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined> }): Promise<void> {
|
|
360
|
+
let sources = await this.getSources();
|
|
361
|
+
let errors: string[] = [];
|
|
362
|
+
for (let source of sources) {
|
|
363
|
+
if (source.config.syncOptions?.noWriteBack) continue;
|
|
364
|
+
if (source.writeBlocked) {
|
|
365
|
+
errors.push(source.writeBlocked);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (!source.api) continue;
|
|
369
|
+
if (source.downUntil > Date.now()) {
|
|
370
|
+
errors.push(`${source.config.url} is down, retrying after ${new Date(source.downUntil).toISOString()}`);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
await this.ensureRouting(source, { force: true });
|
|
374
|
+
// The data stream cannot be rewound, so there is no failover to later sources here
|
|
375
|
+
return await source.api.setLargeFile(config);
|
|
376
|
+
}
|
|
377
|
+
throw new Error(`No writable source for setLargeFile on ${this.getDebugName()}: ${errors.join(" | ") || "no sources accept writes"}`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
public async getURL(path: string): Promise<string> {
|
|
381
|
+
let sources = await this.getSources();
|
|
382
|
+
for (let source of sources) {
|
|
383
|
+
if (source.config.public === false) continue;
|
|
384
|
+
if (source.url) return await source.url.getURL(path);
|
|
385
|
+
if (source.api) return await source.api.getURL(path);
|
|
386
|
+
}
|
|
387
|
+
throw new Error(`No public source to build a URL from for ${this.getDebugName()} (every source has public: false)`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// The IArchives for a RemoteConfig (or a single source - a routing URL string works). Fully lazy:
|
|
392
|
+
// nothing is contacted until the first call.
|
|
393
|
+
export function createArchives(config: RemoteConfig | RemoteConfigBase): ArchivesChain {
|
|
394
|
+
return new ArchivesChain(config);
|
|
395
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { SocketFunction } from "socket-function/SocketFunction";
|
|
2
|
+
import { RemoteStorageController } from "./storageController";
|
|
3
|
+
import { authenticateStorage, parseStorageUrl } from "./ArchivesRemote";
|
|
4
|
+
import { getArg } from "./cliArgs";
|
|
5
|
+
|
|
6
|
+
// The grantAccess CLI. Invoked via the sibling grantAccess.js bootstrap (which loads typenode and
|
|
7
|
+
// then requires this file). Grants a specific access request by its requestId; must be run on the
|
|
8
|
+
// storage machine itself (its certs.ts identity is what the server trusts as admin).
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
let url = getArg("url");
|
|
12
|
+
if (!url) throw new Error(`--url is required (ex: --url https://storage.example.com:4444)`);
|
|
13
|
+
let requestId = getArg("requestId");
|
|
14
|
+
if (!requestId) throw new Error(`--requestId is required (the request id shown on the access page)`);
|
|
15
|
+
|
|
16
|
+
let { address, port } = parseStorageUrl(url);
|
|
17
|
+
let nodeId = SocketFunction.connect({ address, port });
|
|
18
|
+
await authenticateStorage({ address, port, nodeId });
|
|
19
|
+
let record = await RemoteStorageController.nodes[nodeId].adminGrantAccess(requestId);
|
|
20
|
+
console.log(`Granted machine ${record.machineId} access to account ${JSON.stringify(record.account)}`);
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
main().catch(e => {
|
|
25
|
+
console.error(e);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/// <reference types="node" />
|
|
3
|
+
import { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig } from "../IArchives";
|
|
4
|
+
export declare const ROUTING_FILE = "storage/storagerouting.json";
|
|
5
|
+
export declare function getConfigVersion(config: RemoteConfig): number;
|
|
6
|
+
/** Strips the routing-file suffix, leaving the bucket's public base URL (file paths append to it). */
|
|
7
|
+
export declare function getBucketBaseUrl(url: string): string;
|
|
8
|
+
export declare function buildFileUrl(baseUrl: string, filePath: string): string;
|
|
9
|
+
export declare function parseHostedUrl(url: string): {
|
|
10
|
+
address: string;
|
|
11
|
+
port: number;
|
|
12
|
+
account: string;
|
|
13
|
+
bucketName: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function parseBackblazeUrl(url: string): {
|
|
16
|
+
bucketName: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function normalizeSource(source: RemoteConfigBase): HostedConfig | BackblazeConfig;
|
|
19
|
+
export declare function normalizeRemoteConfig(config: RemoteConfig | RemoteConfigBase): RemoteConfig;
|
|
20
|
+
export declare function parseRoutingData(data: Buffer): RemoteConfig;
|
|
21
|
+
export declare function serializeRemoteConfig(config: RemoteConfig): Buffer;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module.allowclient = true;
|
|
2
|
+
|
|
3
|
+
import { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig } from "../IArchives";
|
|
4
|
+
|
|
5
|
+
// Parsing / normalization of RemoteConfig (see IArchives.ts). Every bucket stores its own
|
|
6
|
+
// configuration (a RemoteConfig) inside itself, at ROUTING_FILE. Writing that file creates the
|
|
7
|
+
// bucket / reconfigures it (see storageServerState.ts); clients reconcile it by version (see
|
|
8
|
+
// createArchives.ts).
|
|
9
|
+
|
|
10
|
+
export const ROUTING_FILE = "storage/storagerouting.json";
|
|
11
|
+
const ROUTING_SUFFIX = "/" + ROUTING_FILE;
|
|
12
|
+
|
|
13
|
+
// A missing version counts as -1, so any explicitly versioned config beats an unversioned one
|
|
14
|
+
export function getConfigVersion(config: RemoteConfig): number {
|
|
15
|
+
return config.version ?? -1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Strips the routing-file suffix, leaving the bucket's public base URL (file paths append to it). */
|
|
19
|
+
export function getBucketBaseUrl(url: string): string {
|
|
20
|
+
if (!url.endsWith(ROUTING_SUFFIX)) {
|
|
21
|
+
throw new Error(`Expected a bucket routing URL ending with ${JSON.stringify(ROUTING_SUFFIX)}, was ${JSON.stringify(url)}`);
|
|
22
|
+
}
|
|
23
|
+
return url.slice(0, -ROUTING_SUFFIX.length);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildFileUrl(baseUrl: string, filePath: string): string {
|
|
27
|
+
return baseUrl + "/" + filePath.split("/").map(encodeURIComponent).join("/");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Ex: https://storage2.vidgridweb.com:4445/file/exampleaccount/examplebucket/storage/storagerouting.json
|
|
31
|
+
export function parseHostedUrl(url: string): { address: string; port: number; account: string; bucketName: string } {
|
|
32
|
+
let base = getBucketBaseUrl(url);
|
|
33
|
+
let u = new URL(base);
|
|
34
|
+
if (u.protocol !== "https:") {
|
|
35
|
+
throw new Error(`Storage URL must use https, got ${JSON.stringify(u.protocol)} in ${JSON.stringify(url)}`);
|
|
36
|
+
}
|
|
37
|
+
let parts = u.pathname.split("/").filter(x => x);
|
|
38
|
+
if (parts.length !== 3 || parts[0] !== "file") {
|
|
39
|
+
throw new Error(`Expected a hosted bucket URL like https://host:port/file/<account>/<bucketName>${ROUTING_SUFFIX}, was ${JSON.stringify(url)}`);
|
|
40
|
+
}
|
|
41
|
+
return { address: u.hostname, port: +u.port || 443, account: decodeURIComponent(parts[1]), bucketName: decodeURIComponent(parts[2]) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Ex: https://f002.backblazeb2.com/file/querysubtest-com-public-immutable/storage/storagerouting.json
|
|
45
|
+
export function parseBackblazeUrl(url: string): { bucketName: string } {
|
|
46
|
+
let base = getBucketBaseUrl(url);
|
|
47
|
+
let u = new URL(base);
|
|
48
|
+
let parts = u.pathname.split("/").filter(x => x);
|
|
49
|
+
if (parts.length !== 2 || parts[0] !== "file") {
|
|
50
|
+
throw new Error(`Expected a backblaze bucket URL like https://f002.backblazeb2.com/file/<bucketName>${ROUTING_SUFFIX}, was ${JSON.stringify(url)}`);
|
|
51
|
+
}
|
|
52
|
+
return { bucketName: decodeURIComponent(parts[1]) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalizeSource(source: RemoteConfigBase): HostedConfig | BackblazeConfig {
|
|
56
|
+
if (typeof source !== "string") {
|
|
57
|
+
if (source.type === "remote") {
|
|
58
|
+
// Throws if the URL is malformed, so bad configs are rejected before they're stored
|
|
59
|
+
parseHostedUrl(source.url);
|
|
60
|
+
}
|
|
61
|
+
return source;
|
|
62
|
+
}
|
|
63
|
+
let hostname = new URL(source).hostname;
|
|
64
|
+
if (hostname.endsWith(".backblazeb2.com")) {
|
|
65
|
+
return { type: "backblaze", url: source, bucketName: parseBackblazeUrl(source).bucketName };
|
|
66
|
+
}
|
|
67
|
+
parseHostedUrl(source);
|
|
68
|
+
return { type: "remote", url: source };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function normalizeRemoteConfig(config: RemoteConfig | RemoteConfigBase): RemoteConfig {
|
|
72
|
+
if (typeof config !== "string" && "sources" in config) {
|
|
73
|
+
return { version: config.version, sources: config.sources.map(normalizeSource) };
|
|
74
|
+
}
|
|
75
|
+
return { sources: [normalizeSource(config)] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseRoutingData(data: Buffer): RemoteConfig {
|
|
79
|
+
let text = data.toString();
|
|
80
|
+
let parsed: RemoteConfig;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(text) as RemoteConfig;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
throw new Error(`Routing config is not valid JSON (${String(e)}). Data: ${text.slice(0, 500)}`);
|
|
85
|
+
}
|
|
86
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.sources)) {
|
|
87
|
+
throw new Error(`Routing config must be { version?, sources: [...] }, was ${text.slice(0, 500)}`);
|
|
88
|
+
}
|
|
89
|
+
return normalizeRemoteConfig(parsed);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function serializeRemoteConfig(config: RemoteConfig): Buffer {
|
|
93
|
+
return Buffer.from(JSON.stringify(config, undefined, 4));
|
|
94
|
+
}
|