querysub 0.528.0 → 0.529.0
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "querysub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.529.0",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
|
|
@@ -70,8 +70,8 @@
|
|
|
70
70
|
"node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
|
|
71
71
|
"pako": "^2.1.0",
|
|
72
72
|
"peggy": "^5.0.6",
|
|
73
|
-
"sliftutils": "^1.7.
|
|
74
|
-
"socket-function": "^1.2.
|
|
73
|
+
"sliftutils": "^1.7.13",
|
|
74
|
+
"socket-function": "^1.2.21",
|
|
75
75
|
"terser": "^5.31.0",
|
|
76
76
|
"typenode": "^6.6.1",
|
|
77
77
|
"typesafecss": "^0.32.0",
|
|
@@ -1,17 +1,12 @@
|
|
|
1
|
-
import { cache
|
|
1
|
+
import { cache } from "socket-function/src/caching";
|
|
2
2
|
import { getStorageDir } from "../fs";
|
|
3
3
|
import { Archives } from "./archives";
|
|
4
4
|
import fs from "fs";
|
|
5
5
|
import os from "os";
|
|
6
|
-
import { isNode,
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
11
|
-
import { blue, green, magenta } from "socket-function/src/formatting/logColors";
|
|
12
|
-
import debugbreak from "debugbreak";
|
|
13
|
-
import { onTimeProfile } from "../-0-hooks/hooks";
|
|
14
|
-
import dns from "dns";
|
|
6
|
+
import { isNode, timeInMinute } from "socket-function/src/misc";
|
|
7
|
+
import { isLogBackblaze } from "../config";
|
|
8
|
+
import { ArchivesBackblaze as ArchivesBackblazeBase } from "sliftutils/storage/backblaze";
|
|
9
|
+
import type { IArchives, ArchivesConfig } from "sliftutils/storage/IArchives";
|
|
15
10
|
|
|
16
11
|
export function hasBackblazePermissions() {
|
|
17
12
|
return isNode() && fs.existsSync(getBackblazePath());
|
|
@@ -29,913 +24,64 @@ export function getBackblazePath() {
|
|
|
29
24
|
return testPaths[0];
|
|
30
25
|
}
|
|
31
26
|
|
|
32
|
-
type
|
|
33
|
-
applicationKeyId: string;
|
|
34
|
-
applicationKey: string;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
let backblazeCreds = lazy((): BackblazeCreds => (
|
|
38
|
-
JSON.parse(fs.readFileSync(getBackblazePath(), "utf8")) as {
|
|
39
|
-
applicationKeyId: string;
|
|
40
|
-
applicationKey: string;
|
|
41
|
-
}
|
|
42
|
-
));
|
|
43
|
-
const getAPI = lazy(async () => {
|
|
44
|
-
let creds = backblazeCreds();
|
|
45
|
-
|
|
46
|
-
// NOTE: On errors, our retry code resets this lazy, so we DO get new authorize when needed.
|
|
47
|
-
// TODO: Maybe we should get new authorization periodically at well?
|
|
48
|
-
let authorizeRaw = await httpsRequest("https://api.backblazeb2.com/b2api/v2/b2_authorize_account", undefined, "GET", undefined, {
|
|
49
|
-
headers: {
|
|
50
|
-
Authorization: "Basic " + Buffer.from(creds.applicationKeyId + ":" + creds.applicationKey).toString("base64"),
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
let auth = JSON.parse(authorizeRaw.toString()) as {
|
|
55
|
-
accountId: string;
|
|
56
|
-
authorizationToken: string;
|
|
57
|
-
apiUrl: string;
|
|
58
|
-
downloadUrl: string;
|
|
59
|
-
allowed: {
|
|
60
|
-
bucketId: string;
|
|
61
|
-
bucketName: string;
|
|
62
|
-
capabilities: string[];
|
|
63
|
-
namePrefix: string;
|
|
64
|
-
}[];
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
function createB2Function<Arg, Result>(name: string, type: "POST" | "GET", noAccountId?: "noAccountId"): (arg: Arg) => Promise<Result> {
|
|
68
|
-
return async (arg: Arg) => {
|
|
69
|
-
if (!noAccountId) {
|
|
70
|
-
arg = { accountId: auth.accountId, ...arg };
|
|
71
|
-
}
|
|
72
|
-
try {
|
|
73
|
-
let url = auth.apiUrl + "/b2api/v2/" + name;
|
|
74
|
-
let time = Date.now();
|
|
75
|
-
let result = await httpsRequest(url, Buffer.from(JSON.stringify(arg)), type, undefined, {
|
|
76
|
-
headers: {
|
|
77
|
-
Authorization: auth.authorizationToken,
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
onTimeProfile("Backblaze API", time);
|
|
81
|
-
return JSON.parse(result.toString());
|
|
82
|
-
} catch (e: any) {
|
|
83
|
-
throw new Error(`Error in ${name}, arg ${JSON.stringify(arg).slice(0, 1000)}: ${e.stack}`);
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const createBucket = createB2Function<{
|
|
89
|
-
bucketName: string;
|
|
90
|
-
bucketType: "allPrivate" | "allPublic";
|
|
91
|
-
lifecycleRules?: any[];
|
|
92
|
-
corsRules?: unknown[];
|
|
93
|
-
bucketInfo?: {
|
|
94
|
-
[key: string]: unknown;
|
|
95
|
-
};
|
|
96
|
-
}, {
|
|
97
|
-
accountId: string;
|
|
98
|
-
bucketId: string;
|
|
99
|
-
bucketName: string;
|
|
100
|
-
bucketType: "allPrivate" | "allPublic";
|
|
101
|
-
bucketInfo: {
|
|
102
|
-
lifecycleRules: any[];
|
|
103
|
-
};
|
|
104
|
-
corsRules: any[];
|
|
105
|
-
lifecycleRules: any[];
|
|
106
|
-
revision: number;
|
|
107
|
-
}>("b2_create_bucket", "POST");
|
|
108
|
-
|
|
109
|
-
const updateBucket = createB2Function<{
|
|
110
|
-
accountId: string;
|
|
111
|
-
bucketId: string;
|
|
112
|
-
bucketType?: "allPrivate" | "allPublic";
|
|
113
|
-
lifecycleRules?: any[];
|
|
114
|
-
bucketInfo?: {
|
|
115
|
-
[key: string]: unknown;
|
|
116
|
-
};
|
|
117
|
-
corsRules?: unknown[];
|
|
118
|
-
}, {
|
|
119
|
-
accountId: string;
|
|
120
|
-
bucketId: string;
|
|
121
|
-
bucketName: string;
|
|
122
|
-
bucketType: "allPrivate" | "allPublic";
|
|
123
|
-
bucketInfo: {
|
|
124
|
-
lifecycleRules: any[];
|
|
125
|
-
};
|
|
126
|
-
corsRules: any[];
|
|
127
|
-
lifecycleRules: any[];
|
|
128
|
-
revision: number;
|
|
129
|
-
}>("b2_update_bucket", "POST");
|
|
130
|
-
|
|
131
|
-
// https://www.backblaze.com/apidocs/b2-update-bucket
|
|
132
|
-
// TODO: b2_update_bucket, so we can update CORS, etc
|
|
133
|
-
|
|
134
|
-
const listBuckets = createB2Function<{
|
|
135
|
-
bucketName?: string;
|
|
136
|
-
}, {
|
|
137
|
-
buckets: {
|
|
138
|
-
accountId: string;
|
|
139
|
-
bucketId: string;
|
|
140
|
-
bucketName: string;
|
|
141
|
-
bucketType: "allPrivate" | "allPublic";
|
|
142
|
-
bucketInfo: {
|
|
143
|
-
lifecycleRules: any[];
|
|
144
|
-
};
|
|
145
|
-
corsRules: any[];
|
|
146
|
-
lifecycleRules: any[];
|
|
147
|
-
revision: number;
|
|
148
|
-
}[];
|
|
149
|
-
}>("b2_list_buckets", "POST");
|
|
150
|
-
|
|
151
|
-
function encodePath(path: string) {
|
|
152
|
-
// Preserve slashes, but encode everything else
|
|
153
|
-
path = path.split("/").map(encodeURIComponent).join("/");
|
|
154
|
-
if (path.startsWith("/")) path = "%2F" + path.slice(1);
|
|
155
|
-
if (path.endsWith("/")) path = path.slice(0, -1) + "%2F";
|
|
156
|
-
// NOTE: For some reason, this won't render in the web UI correctly. BUT, it'll
|
|
157
|
-
// work get get/set and find
|
|
158
|
-
// - ALSO, it seems to add duplicate files? This might also be a web UI thing. It
|
|
159
|
-
// seems to work though.
|
|
160
|
-
while (path.includes("//")) {
|
|
161
|
-
path = path.replaceAll("//", "/%2F");
|
|
162
|
-
}
|
|
163
|
-
return path;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async function downloadFileByName(config: {
|
|
167
|
-
bucketName: string;
|
|
168
|
-
fileName: string;
|
|
169
|
-
range?: { start: number; end: number; };
|
|
170
|
-
}) {
|
|
171
|
-
let fileName = encodePath(config.fileName);
|
|
172
|
-
|
|
173
|
-
let result = await httpsRequest(auth.apiUrl + "/file/" + config.bucketName + "/" + fileName, Buffer.from(JSON.stringify({
|
|
174
|
-
accountId: auth.accountId,
|
|
175
|
-
responseType: "arraybuffer",
|
|
176
|
-
})), "GET", undefined, {
|
|
177
|
-
headers: Object.fromEntries(Object.entries({
|
|
178
|
-
Authorization: auth.authorizationToken,
|
|
179
|
-
"Content-Type": "application/json",
|
|
180
|
-
Range: config.range ? `bytes=${config.range.start}-${config.range.end - 1}` : undefined,
|
|
181
|
-
}).filter(x => x[1] !== undefined)),
|
|
182
|
-
});
|
|
183
|
-
return result;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Oh... apparently, we can't reuse these? Huh...
|
|
187
|
-
const getUploadURL = (async (bucketId: string) => {
|
|
188
|
-
//setTimeout(() => getUploadURL.clear(bucketId), timeInHour * 1);
|
|
189
|
-
let getUploadUrlRaw = await httpsRequest(auth.apiUrl + "/b2api/v2/b2_get_upload_url?bucketId=" + bucketId, undefined, "GET", undefined, {
|
|
190
|
-
headers: {
|
|
191
|
-
Authorization: auth.authorizationToken,
|
|
192
|
-
}
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
return JSON.parse(getUploadUrlRaw.toString()) as {
|
|
196
|
-
bucketId: string;
|
|
197
|
-
uploadUrl: string;
|
|
198
|
-
authorizationToken: string;
|
|
199
|
-
};
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
async function uploadFile(config: {
|
|
203
|
-
bucketId: string;
|
|
204
|
-
fileName: string;
|
|
205
|
-
data: Buffer;
|
|
206
|
-
}) {
|
|
207
|
-
let getUploadUrl = await getUploadURL(config.bucketId);
|
|
208
|
-
|
|
209
|
-
await httpsRequest(getUploadUrl.uploadUrl, config.data, "POST", undefined, {
|
|
210
|
-
headers: {
|
|
211
|
-
Authorization: getUploadUrl.authorizationToken,
|
|
212
|
-
"X-Bz-File-Name": encodePath(config.fileName),
|
|
213
|
-
"Content-Type": "b2/x-auto",
|
|
214
|
-
"X-Bz-Content-Sha1": "do_not_verify",
|
|
215
|
-
"Content-Length": config.data.length + "",
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const hideFile = createB2Function<{
|
|
221
|
-
bucketId: string;
|
|
222
|
-
fileName: string;
|
|
223
|
-
}, {}>("b2_hide_file", "POST", "noAccountId");
|
|
224
|
-
|
|
225
|
-
const getFileInfo = createB2Function<{
|
|
226
|
-
bucketName: string;
|
|
227
|
-
fileId: string;
|
|
228
|
-
}, {
|
|
229
|
-
fileId: string;
|
|
230
|
-
fileName: string;
|
|
231
|
-
accountId: string;
|
|
232
|
-
bucketId: string;
|
|
233
|
-
contentLength: number;
|
|
234
|
-
contentSha1: string;
|
|
235
|
-
contentType: string;
|
|
236
|
-
fileInfo: {
|
|
237
|
-
src_last_modified_millis: number;
|
|
238
|
-
};
|
|
239
|
-
action: string;
|
|
240
|
-
uploadTimestamp: number;
|
|
241
|
-
}>("b2_get_file_info", "POST", "noAccountId");
|
|
242
|
-
|
|
243
|
-
const listFileNames = createB2Function<{
|
|
244
|
-
bucketId: string;
|
|
245
|
-
prefix: string;
|
|
246
|
-
startFileName?: string;
|
|
247
|
-
maxFileCount?: number;
|
|
248
|
-
delimiter?: string;
|
|
249
|
-
}, {
|
|
250
|
-
files: {
|
|
251
|
-
fileId: string;
|
|
252
|
-
fileName: string;
|
|
253
|
-
accountId: string;
|
|
254
|
-
bucketId: string;
|
|
255
|
-
contentLength: number;
|
|
256
|
-
contentSha1: string;
|
|
257
|
-
contentType: string;
|
|
258
|
-
fileInfo: {
|
|
259
|
-
src_last_modified_millis: number;
|
|
260
|
-
};
|
|
261
|
-
action: string;
|
|
262
|
-
uploadTimestamp: number;
|
|
263
|
-
}[];
|
|
264
|
-
nextFileName: string;
|
|
265
|
-
}>("b2_list_file_names", "POST", "noAccountId");
|
|
266
|
-
|
|
267
|
-
const copyFile = createB2Function<{
|
|
268
|
-
sourceFileId: string;
|
|
269
|
-
fileName: string;
|
|
270
|
-
destinationBucketId: string;
|
|
271
|
-
}, {}>("b2_copy_file", "POST", "noAccountId");
|
|
272
|
-
|
|
273
|
-
const startLargeFile = createB2Function<{
|
|
274
|
-
bucketId: string;
|
|
275
|
-
fileName: string;
|
|
276
|
-
contentType: string;
|
|
277
|
-
fileInfo: { [key: string]: string };
|
|
278
|
-
}, {
|
|
279
|
-
fileId: string;
|
|
280
|
-
fileName: string;
|
|
281
|
-
accountId: string;
|
|
282
|
-
bucketId: string;
|
|
283
|
-
contentType: string;
|
|
284
|
-
fileInfo: any;
|
|
285
|
-
uploadTimestamp: number;
|
|
286
|
-
}>("b2_start_large_file", "POST", "noAccountId");
|
|
287
|
-
|
|
288
|
-
// Apparently we can't reuse these?
|
|
289
|
-
const getUploadPartURL = (async (fileId: string) => {
|
|
290
|
-
let uploadPartRaw = await httpsRequest(auth.apiUrl + "/b2api/v2/b2_get_upload_part_url?fileId=" + fileId, undefined, "GET", undefined, {
|
|
291
|
-
headers: {
|
|
292
|
-
Authorization: auth.authorizationToken,
|
|
293
|
-
}
|
|
294
|
-
});
|
|
295
|
-
return JSON.parse(uploadPartRaw.toString()) as {
|
|
296
|
-
fileId: string;
|
|
297
|
-
partNumber: number;
|
|
298
|
-
uploadUrl: string;
|
|
299
|
-
authorizationToken: string;
|
|
300
|
-
};
|
|
301
|
-
});
|
|
302
|
-
async function uploadPart(config: {
|
|
303
|
-
fileId: string;
|
|
304
|
-
partNumber: number;
|
|
305
|
-
data: Buffer;
|
|
306
|
-
sha1: string;
|
|
307
|
-
}): Promise<{
|
|
308
|
-
fileId: string;
|
|
309
|
-
partNumber: number;
|
|
310
|
-
contentLength: number;
|
|
311
|
-
contentSha1: string;
|
|
312
|
-
}> {
|
|
313
|
-
let uploadPart = await getUploadPartURL(config.fileId);
|
|
314
|
-
|
|
315
|
-
let result = await httpsRequest(uploadPart.uploadUrl, config.data, "POST", undefined, {
|
|
316
|
-
headers: {
|
|
317
|
-
Authorization: uploadPart.authorizationToken,
|
|
318
|
-
"X-Bz-Part-Number": config.partNumber + "",
|
|
319
|
-
"X-Bz-Content-Sha1": config.sha1,
|
|
320
|
-
"Content-Length": config.data.length + "",
|
|
321
|
-
|
|
322
|
-
}
|
|
323
|
-
});
|
|
324
|
-
return JSON.parse(result.toString());
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const finishLargeFile = createB2Function<{
|
|
328
|
-
fileId: string;
|
|
329
|
-
partSha1Array: string[];
|
|
330
|
-
}, {
|
|
331
|
-
fileId: string;
|
|
332
|
-
fileName: string;
|
|
333
|
-
accountId: string;
|
|
334
|
-
bucketId: string;
|
|
335
|
-
contentLength: number;
|
|
336
|
-
contentSha1: string;
|
|
337
|
-
contentType: string;
|
|
338
|
-
fileInfo: any;
|
|
339
|
-
uploadTimestamp: number;
|
|
340
|
-
}>("b2_finish_large_file", "POST", "noAccountId");
|
|
341
|
-
|
|
342
|
-
const cancelLargeFile = createB2Function<{
|
|
343
|
-
fileId: string;
|
|
344
|
-
}, {}>("b2_cancel_large_file", "POST", "noAccountId");
|
|
345
|
-
|
|
346
|
-
const getDownloadAuthorization = createB2Function<{
|
|
347
|
-
bucketId: string;
|
|
348
|
-
fileNamePrefix: string;
|
|
349
|
-
validDurationInSeconds: number;
|
|
350
|
-
b2ContentDisposition?: string;
|
|
351
|
-
b2ContentLanguage?: string;
|
|
352
|
-
b2Expires?: string;
|
|
353
|
-
b2CacheControl?: string;
|
|
354
|
-
b2ContentEncoding?: string;
|
|
355
|
-
b2ContentType?: string;
|
|
356
|
-
}, {
|
|
357
|
-
bucketId: string;
|
|
358
|
-
fileNamePrefix: string;
|
|
359
|
-
authorizationToken: string;
|
|
360
|
-
}>("b2_get_download_authorization", "POST", "noAccountId");
|
|
361
|
-
|
|
362
|
-
async function getDownloadURL(path: string) {
|
|
363
|
-
if (!path.startsWith("/")) {
|
|
364
|
-
path = "/" + path;
|
|
365
|
-
}
|
|
366
|
-
return auth.downloadUrl + path;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
return {
|
|
371
|
-
createBucket,
|
|
372
|
-
updateBucket,
|
|
373
|
-
listBuckets,
|
|
374
|
-
downloadFileByName,
|
|
375
|
-
uploadFile,
|
|
376
|
-
hideFile,
|
|
377
|
-
getFileInfo,
|
|
378
|
-
listFileNames,
|
|
379
|
-
copyFile,
|
|
380
|
-
startLargeFile,
|
|
381
|
-
uploadPart,
|
|
382
|
-
finishLargeFile,
|
|
383
|
-
cancelLargeFile,
|
|
384
|
-
getDownloadAuthorization,
|
|
385
|
-
getDownloadURL,
|
|
386
|
-
apiUrl: auth.apiUrl,
|
|
387
|
-
};
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
type B2Api = (typeof getAPI) extends () => Promise<infer T> ? T : never;
|
|
391
|
-
|
|
392
|
-
|
|
27
|
+
// The implementation lives in sliftutils (it reads credentials via getSecret, which our appSecrets.ts serves from getBackblazePath). We forward to an inner instance instead of subclassing, because our Archives interface's move/copy take a querysub Archives target (with getBaseArchives unwrapping) which is type-incompatible with the base class's IArchives signatures.
|
|
393
28
|
export class ArchivesBackblaze {
|
|
394
|
-
|
|
29
|
+
private archives: ArchivesBackblazeBase;
|
|
30
|
+
|
|
31
|
+
public constructor(config: {
|
|
395
32
|
bucketName: string;
|
|
396
33
|
public?: boolean;
|
|
397
34
|
immutable?: boolean;
|
|
398
35
|
cacheTime?: number;
|
|
399
36
|
allowedOrigins?: string[];
|
|
400
37
|
}) {
|
|
401
|
-
|
|
402
|
-
|
|
38
|
+
this.archives = new ArchivesBackblazeBase(config);
|
|
39
|
+
if (isLogBackblaze()) {
|
|
40
|
+
this.archives.enableLogging();
|
|
41
|
+
}
|
|
403
42
|
}
|
|
404
43
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
private logging = isLogBackblaze();
|
|
409
|
-
public enableLogging() {
|
|
410
|
-
this.logging = true;
|
|
44
|
+
public enableLogging(): void {
|
|
45
|
+
this.archives.enableLogging();
|
|
411
46
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
console.log(text);
|
|
47
|
+
public getDebugName(): string {
|
|
48
|
+
return this.archives.getDebugName();
|
|
415
49
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
return "backblaze/" + this.config.bucketName;
|
|
50
|
+
public async hasWriteAccess(): Promise<boolean> {
|
|
51
|
+
return this.archives.hasWriteAccess();
|
|
419
52
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
let api = await getAPI();
|
|
423
|
-
|
|
424
|
-
let cacheTime = this.config.cacheTime ?? 0;
|
|
425
|
-
if (this.config.immutable) {
|
|
426
|
-
cacheTime = 86400 * 1000;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// ALWAYS set access control, as we can make urls for private buckets with getDownloadAuthorization
|
|
430
|
-
let desiredCorsRules = [{
|
|
431
|
-
corsRuleName: "allowAll",
|
|
432
|
-
allowedOrigins: this.config.allowedOrigins ?? ["https"],
|
|
433
|
-
allowedOperations: ["b2_download_file_by_id", "b2_download_file_by_name"],
|
|
434
|
-
allowedHeaders: ["range"],
|
|
435
|
-
exposeHeaders: ["x-bz-content-sha1"],
|
|
436
|
-
maxAgeSeconds: cacheTime / 1000,
|
|
437
|
-
}];
|
|
438
|
-
let bucketInfo: Record<string, unknown> = {};
|
|
439
|
-
if (cacheTime) {
|
|
440
|
-
bucketInfo["cache-control"] = `max-age=${cacheTime / 1000}`;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
let exists = false;
|
|
445
|
-
let retries = 0;
|
|
446
|
-
while (true) {
|
|
447
|
-
try {
|
|
448
|
-
await api.createBucket({
|
|
449
|
-
bucketName: this.bucketName,
|
|
450
|
-
bucketType: this.config.public ? "allPublic" : "allPrivate",
|
|
451
|
-
lifecycleRules: [{
|
|
452
|
-
"daysFromUploadingToHiding": null,
|
|
453
|
-
// Keep files for 7 days, which should be enough time to recover accidental hiding.
|
|
454
|
-
"daysFromHidingToDeleting": 7,
|
|
455
|
-
"fileNamePrefix": ""
|
|
456
|
-
}],
|
|
457
|
-
corsRules: desiredCorsRules,
|
|
458
|
-
bucketInfo
|
|
459
|
-
});
|
|
460
|
-
} catch (e: any) {
|
|
461
|
-
if (!e.stack.includes(`"duplicate_bucket_name"`)) {
|
|
462
|
-
if (retries < 3) {
|
|
463
|
-
console.error(`Backblaze create bucket failed, retrying in 5s: ${e.stack}`);
|
|
464
|
-
await delay(5000);
|
|
465
|
-
retries++;
|
|
466
|
-
continue;
|
|
467
|
-
}
|
|
468
|
-
throw e;
|
|
469
|
-
}
|
|
470
|
-
exists = true;
|
|
471
|
-
}
|
|
472
|
-
break;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
let bucketList = await api.listBuckets({
|
|
476
|
-
bucketName: this.bucketName,
|
|
477
|
-
});
|
|
478
|
-
if (bucketList.buckets.length === 0) {
|
|
479
|
-
throw new Error(`Bucket name "${this.bucketName}" is being used by someone else. Bucket names have to be globally unique. Try a different name until you find a free one.`);
|
|
480
|
-
}
|
|
481
|
-
this.bucketId = bucketList.buckets[0].bucketId;
|
|
482
|
-
|
|
483
|
-
if (exists) {
|
|
484
|
-
let bucket = bucketList.buckets[0];
|
|
485
|
-
function normalize(obj: Record<string, unknown>) {
|
|
486
|
-
let kvps = Object.entries(obj);
|
|
487
|
-
sort(kvps, x => x[0]);
|
|
488
|
-
return Object.fromEntries(kvps);
|
|
489
|
-
}
|
|
490
|
-
function orderIndependentEqual(lhs: Record<string, unknown>, rhs: Record<string, unknown>) {
|
|
491
|
-
return JSON.stringify(normalize(lhs)) === JSON.stringify(normalize(rhs));
|
|
492
|
-
}
|
|
493
|
-
function orderIndependentEqualArray(lhs: unknown[], rhs: unknown[]) {
|
|
494
|
-
if (lhs.length !== rhs.length) return false;
|
|
495
|
-
for (let i = 0; i < lhs.length; i++) {
|
|
496
|
-
if (!orderIndependentEqual(lhs[i] as Record<string, unknown>, rhs[i] as Record<string, unknown>)) return false;
|
|
497
|
-
}
|
|
498
|
-
return true;
|
|
499
|
-
}
|
|
500
|
-
if (
|
|
501
|
-
!orderIndependentEqualArray(bucket.corsRules, desiredCorsRules)
|
|
502
|
-
|| !orderIndependentEqual(bucket.bucketInfo, bucketInfo)
|
|
503
|
-
) {
|
|
504
|
-
console.log(magenta(`Updating CORS rules for ${this.bucketName}`), bucket.corsRules, desiredCorsRules);
|
|
505
|
-
await api.updateBucket({
|
|
506
|
-
accountId: bucket.accountId,
|
|
507
|
-
bucketId: bucket.bucketId,
|
|
508
|
-
bucketType: bucket.bucketType,
|
|
509
|
-
lifecycleRules: bucket.lifecycleRules,
|
|
510
|
-
corsRules: desiredCorsRules,
|
|
511
|
-
bucketInfo: bucketInfo,
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
return api;
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
private currentReset: Promise<void> | undefined;
|
|
519
|
-
|
|
520
|
-
// Keep track of when we last reset because of a 503
|
|
521
|
-
private last503Reset = 0;
|
|
522
|
-
// IMPORTANT! We must always CATCH AROUND the apiRetryLogic, NEVER inside of fnc. Otherwise we won't
|
|
523
|
-
// be able to recreate the auth token.
|
|
524
|
-
// `context` is a short label (verb + file path) included in every retry/error log so a stuck
|
|
525
|
-
// silent-retry loop is identifiable from the logs.
|
|
526
|
-
private async apiRetryLogic<T>(
|
|
527
|
-
context: string,
|
|
528
|
-
fnc: (api: B2Api) => Promise<T>,
|
|
529
|
-
retries = 3
|
|
530
|
-
): Promise<T> {
|
|
531
|
-
let api: B2Api | undefined;
|
|
532
|
-
try {
|
|
533
|
-
api = await this.getBucketAPI();
|
|
534
|
-
return await fnc(api);
|
|
535
|
-
} catch (err: any) {
|
|
536
|
-
if (retries <= 0) throw err;
|
|
537
|
-
|
|
538
|
-
// If it's a 503 and it's been a minute since we last reset, then Wait and reset.
|
|
539
|
-
if (
|
|
540
|
-
(err.stack.includes(`"status": 503`)
|
|
541
|
-
|| err.stack.includes(`"service_unavailable"`)
|
|
542
|
-
|| err.stack.includes(`"internal_error"`)
|
|
543
|
-
|| err.stack.includes(`ENOBUFS`)
|
|
544
|
-
) && Date.now() - this.last503Reset > 60 * 1000) {
|
|
545
|
-
// Backblades is so flaky that we're just going to warn here.
|
|
546
|
-
console.warn(`[${context}] Backblaze error, waiting and resetting: ${err.message}`);
|
|
547
|
-
this.log(`[${context}] Backblaze error, waiting and resetting: ${err.message}`);
|
|
548
|
-
this.currentReset = this.currentReset || (async () => {
|
|
549
|
-
await delay(10 * 1000);
|
|
550
|
-
// We check again in case, and in the very likely case that this is being run in parallel, we only want to reset once.
|
|
551
|
-
if (Date.now() - this.last503Reset > 60 * 1000) {
|
|
552
|
-
this.log(`[${context}] Resetting getAPI and getBucketAPI: ${err.message}`);
|
|
553
|
-
this.last503Reset = Date.now();
|
|
554
|
-
getAPI.reset();
|
|
555
|
-
this.getBucketAPI.reset();
|
|
556
|
-
}
|
|
557
|
-
})().finally(() => this.currentReset = undefined);
|
|
558
|
-
await this.currentReset;
|
|
559
|
-
return this.apiRetryLogic(context, fnc, retries - 1);
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// If the error is that the authorization token is invalid, reset getBucketAPI and getAPI
|
|
563
|
-
// If the error is that the bucket isn't found, reset getBucketAPI
|
|
564
|
-
if (err.stack.includes(`"expired_auth_token"`)) {
|
|
565
|
-
this.log(`[${context}] Authorization token expired`);
|
|
566
|
-
getAPI.reset();
|
|
567
|
-
this.getBucketAPI.reset();
|
|
568
|
-
return this.apiRetryLogic(context, fnc, retries - 1);
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
if (
|
|
572
|
-
err.stack.includes(`no tomes available`)
|
|
573
|
-
|| err.stack.includes(`ETIMEDOUT`)
|
|
574
|
-
|| err.stack.includes(`socket hang up`)
|
|
575
|
-
// Eh... this might be bad, but... I think we just get random 400 errors. If this spams errors,
|
|
576
|
-
// we can remove this line.
|
|
577
|
-
|| err.stack.includes(`400 Bad Request`)
|
|
578
|
-
|| err.stack.includes(`getaddrinfo ENOTFOUND`)
|
|
579
|
-
|| err.stack.includes(`ECONNRESET`)
|
|
580
|
-
|| err.stack.includes(`ECONNREFUSED`)
|
|
581
|
-
|| err.stack.includes(`ENOBUFS`)
|
|
582
|
-
) {
|
|
583
|
-
console.warn(`[${context}] Retrying in 5s: ${err.message}`);
|
|
584
|
-
this.log(`[${context}] ${err.message} retrying in 5s`);
|
|
585
|
-
await delay(5000);
|
|
586
|
-
return this.apiRetryLogic(context, fnc, retries - 1);
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
if (err.stack.includes(`getaddrinfo ENOTFOUND`)) {
|
|
590
|
-
if (api) {
|
|
591
|
-
let urlObj = new URL(api.apiUrl);
|
|
592
|
-
let hostname = urlObj.hostname;
|
|
593
|
-
let lookupAddresses = await new Promise(resolve => {
|
|
594
|
-
dns.lookup(hostname, (err, addresses) => {
|
|
595
|
-
resolve(addresses);
|
|
596
|
-
});
|
|
597
|
-
});
|
|
598
|
-
let resolveAddresses = await new Promise(resolve => {
|
|
599
|
-
dns.resolve4(hostname, (err, addresses) => {
|
|
600
|
-
resolve(addresses);
|
|
601
|
-
});
|
|
602
|
-
});
|
|
603
|
-
console.error(`[${context}] getaddrinfo ENOTFOUND ${hostname}`, { lookupAddresses, resolveAddresses, apiUrl: api.apiUrl, fullError: err.stack });
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// NOTE: The AI thought case that happens when we run out of retries, that's stupid. This obviously isn't the case. This is the case when it's a normal error, as in the file doesn't exist, we need to throw. We absolutely should not warn here. Warning here wouldn't be anything. It would just be saying, oh, we checked if a file and it didn't, which is normal, which is why we check if a file exists.
|
|
608
|
-
|
|
609
|
-
throw err;
|
|
610
|
-
}
|
|
53
|
+
public async getConfig(): Promise<ArchivesConfig> {
|
|
54
|
+
return this.archives.getConfig();
|
|
611
55
|
}
|
|
612
|
-
|
|
613
56
|
public async get(fileName: string, config?: { range?: { start: number; end: number; }; retryCount?: number }): Promise<Buffer | undefined> {
|
|
614
|
-
|
|
615
|
-
try {
|
|
616
|
-
let time = Date.now();
|
|
617
|
-
const downloadPoll = () => {
|
|
618
|
-
if (!downloading) return;
|
|
619
|
-
this.log(`Backblaze download in progress ${fileName}`);
|
|
620
|
-
setTimeout(downloadPoll, 5000);
|
|
621
|
-
};
|
|
622
|
-
setTimeout(downloadPoll, 5000);
|
|
623
|
-
let result = await this.apiRetryLogic(`get ${fileName}`, async (api) => {
|
|
624
|
-
let range = config?.range;
|
|
625
|
-
if (range) {
|
|
626
|
-
let fileInfo = await this.getInfo(fileName);
|
|
627
|
-
if (!fileInfo) throw new Error(`File ${fileName} not found`);
|
|
628
|
-
let rangeStart = range.start;
|
|
629
|
-
let rangeEnd = Math.min(range.end, fileInfo.size);
|
|
630
|
-
// NOTE: I think if we request nothing, it confuses Backblaze and ends up giving us the entire file.
|
|
631
|
-
if (rangeEnd <= rangeStart) return Buffer.alloc(0);
|
|
632
|
-
let result = await api.downloadFileByName({
|
|
633
|
-
bucketName: this.bucketName,
|
|
634
|
-
fileName,
|
|
635
|
-
range: { start: rangeStart, end: rangeEnd },
|
|
636
|
-
});
|
|
637
|
-
if (result.length !== rangeEnd - rangeStart) {
|
|
638
|
-
let afterLength = await this.getInfo(fileName);
|
|
639
|
-
if (afterLength && afterLength.size >= fileInfo.size) {
|
|
640
|
-
console.error(`Backblaze range download return the correct number of bytes. Tried to get ${rangeStart}-${rangeEnd}, but received ${rangeStart}-${rangeStart + result.length}. For file: ${fileName}`);
|
|
641
|
-
// I'm not sure if it's a bug that where we get extra data if we try to read beyond the end of the file, or if the bug is due to some kind of lag that will resolve itself if we wait a little bit.
|
|
642
|
-
setTimeout(async () => {
|
|
643
|
-
let resultAgain = await api.downloadFileByName({
|
|
644
|
-
bucketName: this.bucketName,
|
|
645
|
-
fileName,
|
|
646
|
-
range: { start: rangeStart, end: rangeEnd },
|
|
647
|
-
});
|
|
648
|
-
devDebugbreak();
|
|
649
|
-
let didResultFixItSelf = resultAgain.length === rangeEnd - rangeStart;
|
|
650
|
-
|
|
651
|
-
console.log({ didResultFixItSelf }, resultAgain);
|
|
652
|
-
}, timeInMinute * 2);
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
return await api.downloadFileByName({
|
|
657
|
-
bucketName: this.bucketName,
|
|
658
|
-
fileName,
|
|
659
|
-
});
|
|
660
|
-
});
|
|
661
|
-
let timeStr = formatTime(Date.now() - time);
|
|
662
|
-
let rateStr = formatNumber(result.length / (Date.now() - time) * 1000) + "B/s";
|
|
663
|
-
this.log(`backblaze download (${formatNumber(result.length)}B${config?.range && `, ${formatNumber(config.range.start)} - ${formatNumber(config.range.end)}` || ""}) in ${timeStr} (${rateStr}, ${fileName})`);
|
|
664
|
-
return result;
|
|
665
|
-
} catch (e) {
|
|
666
|
-
this.log(`backblaze file does not exist ${fileName}`);
|
|
667
|
-
return undefined;
|
|
668
|
-
} finally {
|
|
669
|
-
downloading = false;
|
|
670
|
-
}
|
|
57
|
+
return this.archives.get(fileName, config);
|
|
671
58
|
}
|
|
672
|
-
public async
|
|
673
|
-
this.
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
});
|
|
678
|
-
let existsChecks = 30;
|
|
679
|
-
while (existsChecks > 0) {
|
|
680
|
-
let exists = await this.getInfo(fileName);
|
|
681
|
-
if (exists) break;
|
|
682
|
-
await delay(1000);
|
|
683
|
-
existsChecks--;
|
|
684
|
-
}
|
|
685
|
-
if (existsChecks === 0) {
|
|
686
|
-
let exists = await this.getInfo(fileName);
|
|
687
|
-
devDebugbreak();
|
|
688
|
-
console.warn(`File ${fileName}/${f} was uploaded, but could not be found afterwards. Hopefully it was just deleted, very quickly? If backblaze is taking too long for files to propagate, then we might run into issues with the database atomicity.`);
|
|
689
|
-
}
|
|
690
|
-
|
|
59
|
+
public async get2(fileName: string, config?: { range?: { start: number; end: number; } }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
|
|
60
|
+
return this.archives.get2(fileName, config);
|
|
61
|
+
}
|
|
62
|
+
public async set(fileName: string, data: Buffer, config?: { lastModified?: number }): Promise<void> {
|
|
63
|
+
return this.archives.set(fileName, data, config);
|
|
691
64
|
}
|
|
692
65
|
public async append(fileName: string, data: Buffer): Promise<void> {
|
|
693
66
|
throw new Error(`ArchivesBackblaze does not support append. Use set instead.`);
|
|
694
|
-
// this.log(`backblaze append (${formatNumber(data.length)}B) ${fileName}`);
|
|
695
|
-
// // Backblaze doesn't have native append, so we need to get, concatenate, and set
|
|
696
|
-
// let existing = await this.get(fileName);
|
|
697
|
-
// let newData = existing ? Buffer.concat([existing, data]) : data;
|
|
698
|
-
// await this.set(fileName, newData);
|
|
699
67
|
}
|
|
700
68
|
public async del(fileName: string): Promise<void> {
|
|
701
|
-
this.
|
|
702
|
-
try {
|
|
703
|
-
await this.apiRetryLogic(`hideFile ${fileName}`, async (api) => {
|
|
704
|
-
await api.hideFile({ bucketId: this.bucketId, fileName: fileName });
|
|
705
|
-
});
|
|
706
|
-
} catch (e: any) {
|
|
707
|
-
this.log(`backblaze error in hide, possibly already hidden ${fileName}\n${e.stack}`);
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
// NOTE: Deletion SEEMS to work. This DOES break if we delete a file which keeps being recreated,
|
|
711
|
-
// ex, the heartbeat.
|
|
712
|
-
// let existsChecks = 10;
|
|
713
|
-
// while (existsChecks > 0) {
|
|
714
|
-
// let exists = await this.getInfo(fileName);
|
|
715
|
-
// if (!exists) break;
|
|
716
|
-
// await delay(1000);
|
|
717
|
-
// existsChecks--;
|
|
718
|
-
// }
|
|
719
|
-
// if (existsChecks === 0) {
|
|
720
|
-
// let exists = await this.getInfo(fileName);
|
|
721
|
-
// devDebugbreak();
|
|
722
|
-
// console.warn(`File ${fileName} was deleted, but was still found afterwards`);
|
|
723
|
-
// exists = await this.getInfo(fileName);
|
|
724
|
-
// }
|
|
69
|
+
return this.archives.del(fileName);
|
|
725
70
|
}
|
|
726
|
-
|
|
727
71
|
public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined>; }): Promise<void> {
|
|
728
|
-
|
|
729
|
-
let onError: (() => Promise<void>)[] = [];
|
|
730
|
-
let time = Date.now();
|
|
731
|
-
try {
|
|
732
|
-
let { path } = config;
|
|
733
|
-
// Backblaze requires 5MB chunks. But, larger is more efficient for us.
|
|
734
|
-
const MIN_CHUNK_SIZE = 32 * 1024 * 1024;
|
|
735
|
-
let dataQueue: Buffer[] = [];
|
|
736
|
-
async function getNextData(): Promise<Buffer | undefined> {
|
|
737
|
-
if (dataQueue.length) return dataQueue.shift();
|
|
738
|
-
// Get buffers until we get 5MB, OR, end. Backblaze requires this for large files.
|
|
739
|
-
let totalBytes = 0;
|
|
740
|
-
let buffers: Buffer[] = [];
|
|
741
|
-
while (totalBytes < MIN_CHUNK_SIZE) {
|
|
742
|
-
let data = await config.getNextData();
|
|
743
|
-
if (!data) break;
|
|
744
|
-
totalBytes += data.length;
|
|
745
|
-
buffers.push(data);
|
|
746
|
-
}
|
|
747
|
-
if (!buffers.length) return undefined;
|
|
748
|
-
return Buffer.concat(buffers);
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
let fileName = path;
|
|
752
|
-
let data = await getNextData();
|
|
753
|
-
if (!data?.length) return;
|
|
754
|
-
// Backblaze disallows overly small files
|
|
755
|
-
if (data.length < MIN_CHUNK_SIZE) {
|
|
756
|
-
return await this.set(fileName, data);
|
|
757
|
-
}
|
|
758
|
-
// Backblaze disallows less than 2 chunks
|
|
759
|
-
let secondData = await getNextData();
|
|
760
|
-
if (!secondData?.length) {
|
|
761
|
-
return await this.set(fileName, data);
|
|
762
|
-
}
|
|
763
|
-
// ALSO, if there are two chunks, but one is too small, combine it. This helps allow us never
|
|
764
|
-
// send small chunks.
|
|
765
|
-
if (secondData.length < MIN_CHUNK_SIZE) {
|
|
766
|
-
return await this.set(fileName, Buffer.concat([data, secondData]));
|
|
767
|
-
}
|
|
768
|
-
this.log(`Uploading large file ${config.path}`);
|
|
769
|
-
dataQueue.unshift(data, secondData);
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
let uploadInfo = await this.apiRetryLogic(`startLargeFile ${fileName}`, async (api) => {
|
|
773
|
-
return await api.startLargeFile({
|
|
774
|
-
bucketId: this.bucketId,
|
|
775
|
-
fileName: fileName,
|
|
776
|
-
contentType: "b2/x-auto",
|
|
777
|
-
fileInfo: {},
|
|
778
|
-
});
|
|
779
|
-
});
|
|
780
|
-
onError.push(async () => {
|
|
781
|
-
await this.apiRetryLogic(`cancelLargeFile ${fileName}`, async (api) => {
|
|
782
|
-
await api.cancelLargeFile({ fileId: uploadInfo.fileId });
|
|
783
|
-
});
|
|
784
|
-
});
|
|
785
|
-
|
|
786
|
-
const LOG_INTERVAL = timeInMinute;
|
|
787
|
-
let nextLogTime = Date.now() + LOG_INTERVAL;
|
|
788
|
-
|
|
789
|
-
let partNumber = 1;
|
|
790
|
-
let partSha1Array: string[] = [];
|
|
791
|
-
let totalBytes = 0;
|
|
792
|
-
while (true) {
|
|
793
|
-
data = await getNextData();
|
|
794
|
-
if (!data) break;
|
|
795
|
-
// So... if the next chunk is the last one, combine it with the current one. This
|
|
796
|
-
// prevents ANY uploads from being < the threshold, as apparently the "last part"
|
|
797
|
-
// check in backblaze fails when we have to retry an upload (due to "no tomes available").
|
|
798
|
-
// Well it can't fail if even the last part is > 5MB, now can it!
|
|
799
|
-
// BUT, only if this isn't the first chunk, otherwise we might try to send
|
|
800
|
-
// a single chunk, which we can't do.
|
|
801
|
-
if (partSha1Array.length > 0) {
|
|
802
|
-
let maybeLastData = await getNextData();
|
|
803
|
-
if (maybeLastData) {
|
|
804
|
-
if (maybeLastData.length < MIN_CHUNK_SIZE) {
|
|
805
|
-
// It's the last one, so consume it now
|
|
806
|
-
data = Buffer.concat([data, maybeLastData]);
|
|
807
|
-
} else {
|
|
808
|
-
// It's not the last one. Put it back, in case the one AFTER is the last
|
|
809
|
-
// one, in which case we need to merge maybeLastData with the next next data.
|
|
810
|
-
dataQueue.unshift(maybeLastData);
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
let sha1 = require("crypto").createHash("sha1");
|
|
815
|
-
sha1.update(data);
|
|
816
|
-
let sha1Hex = sha1.digest("hex");
|
|
817
|
-
partSha1Array.push(sha1Hex);
|
|
818
|
-
await this.apiRetryLogic(`uploadPart#${partNumber} ${fileName}`, async (api) => {
|
|
819
|
-
if (!data) throw new Error("Impossible, data is undefined");
|
|
820
|
-
|
|
821
|
-
let timeStr = formatTime(Date.now() - time);
|
|
822
|
-
let rateStr = formatNumber(totalBytes / (Date.now() - time) * 1000) + "B/s";
|
|
823
|
-
this.log(`Uploading large file part ${partNumber}, uploaded ${blue(formatNumber(totalBytes) + "B")} in ${blue(timeStr)} (${blue(rateStr)}). ${config.path}`);
|
|
824
|
-
totalBytes += data.length;
|
|
825
|
-
|
|
826
|
-
await api.uploadPart({
|
|
827
|
-
fileId: uploadInfo.fileId,
|
|
828
|
-
partNumber: partNumber,
|
|
829
|
-
data: data,
|
|
830
|
-
sha1: sha1Hex,
|
|
831
|
-
});
|
|
832
|
-
});
|
|
833
|
-
partNumber++;
|
|
834
|
-
|
|
835
|
-
if (Date.now() > nextLogTime) {
|
|
836
|
-
nextLogTime = Date.now() + LOG_INTERVAL;
|
|
837
|
-
let timeStr = formatTime(Date.now() - time);
|
|
838
|
-
let rateStr = formatNumber(totalBytes / (Date.now() - time) * 1000) + "B/s";
|
|
839
|
-
console.log(`Still uploading large file at ${Date.now()}. Uploaded ${formatNumber(totalBytes)}B in ${timeStr} (${rateStr}). ${config.path}`);
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
this.log(`Finished uploading large file uploaded ${green(formatNumber(totalBytes))}B`);
|
|
843
|
-
|
|
844
|
-
await this.apiRetryLogic(`finishLargeFile ${fileName}`, async (api) => {
|
|
845
|
-
await api.finishLargeFile({
|
|
846
|
-
fileId: uploadInfo.fileId,
|
|
847
|
-
partSha1Array: partSha1Array,
|
|
848
|
-
});
|
|
849
|
-
});
|
|
850
|
-
} catch (e: any) {
|
|
851
|
-
for (let c of onError) {
|
|
852
|
-
try {
|
|
853
|
-
await c();
|
|
854
|
-
} catch (e) {
|
|
855
|
-
console.error(`Error during error clean. Ignoring, we will rethrow the original error, path ${config.path}`, e);
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
throw new Error(`Error in setLargeFile for ${config.path}: ${e.stack}`);
|
|
860
|
-
}
|
|
72
|
+
return this.archives.setLargeFile(config);
|
|
861
73
|
}
|
|
862
|
-
|
|
863
74
|
public async getInfo(fileName: string): Promise<{ writeTime: number; size: number; } | undefined> {
|
|
864
|
-
return
|
|
865
|
-
try {
|
|
866
|
-
// NOTE: Apparently, there's no other way to do this, as the file name does not equal the file ID, and git file info requires the file ID.
|
|
867
|
-
let info = await api.listFileNames({ bucketId: this.bucketId, prefix: fileName, maxFileCount: 10 });
|
|
868
|
-
let file = info.files.find(x => x.fileName === fileName && x.action === "upload");
|
|
869
|
-
if (!file) {
|
|
870
|
-
this.log(`Backblaze file not exists ${fileName}`);
|
|
871
|
-
return undefined;
|
|
872
|
-
}
|
|
873
|
-
this.log(`Backblaze file exists ${fileName}`);
|
|
874
|
-
return {
|
|
875
|
-
writeTime: file.uploadTimestamp,
|
|
876
|
-
size: file.contentLength,
|
|
877
|
-
};
|
|
878
|
-
} catch (e: any) {
|
|
879
|
-
if (e.stack.includes(`file_not_found`)) {
|
|
880
|
-
this.log(`Backblaze file not exists ${fileName}`);
|
|
881
|
-
return undefined;
|
|
882
|
-
}
|
|
883
|
-
throw e;
|
|
884
|
-
}
|
|
885
|
-
});
|
|
75
|
+
return this.archives.getInfo(fileName);
|
|
886
76
|
}
|
|
887
|
-
|
|
888
|
-
// For example findFileNames("ips/")
|
|
889
77
|
public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
|
|
890
|
-
|
|
891
|
-
return result.map(x => x.path);
|
|
78
|
+
return this.archives.find(prefix, config);
|
|
892
79
|
}
|
|
893
80
|
public async findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<{ path: string; createTime: number; size: number; }[]> {
|
|
894
|
-
return
|
|
895
|
-
if (!config?.shallow && config?.type === "folders") {
|
|
896
|
-
let allFiles = await this.findInfo(prefix);
|
|
897
|
-
let allFolders = new Map<string, { path: string; createTime: number; size: number }>();
|
|
898
|
-
for (let { path, createTime, size } of allFiles) {
|
|
899
|
-
let folder = path.split("/").slice(0, -1).join("/");
|
|
900
|
-
if (!folder) continue;
|
|
901
|
-
allFolders.set(folder, { path: folder, createTime, size });
|
|
902
|
-
}
|
|
903
|
-
return Array.from(allFolders.values());
|
|
904
|
-
}
|
|
905
|
-
let files = new Map<string, { path: string; createTime: number; size: number; }>();
|
|
906
|
-
let startFileName = "";
|
|
907
|
-
while (true) {
|
|
908
|
-
let result = await api.listFileNames({
|
|
909
|
-
bucketId: this.bucketId,
|
|
910
|
-
prefix: prefix,
|
|
911
|
-
startFileName,
|
|
912
|
-
maxFileCount: 1000,
|
|
913
|
-
delimiter: config?.shallow ? "/" : undefined,
|
|
914
|
-
});
|
|
915
|
-
for (let file of result.files) {
|
|
916
|
-
if (file.action === "upload" && config?.type !== "folders") {
|
|
917
|
-
files.set(file.fileName, { path: file.fileName, createTime: file.uploadTimestamp, size: file.contentLength });
|
|
918
|
-
} else if (file.action === "folder" && config?.type === "folders") {
|
|
919
|
-
let folder = file.fileName;
|
|
920
|
-
if (folder.endsWith("/")) {
|
|
921
|
-
folder = folder.slice(0, -1);
|
|
922
|
-
}
|
|
923
|
-
files.set(folder, { path: folder, createTime: file.uploadTimestamp, size: file.contentLength });
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
}
|
|
927
|
-
startFileName = result.nextFileName;
|
|
928
|
-
if (!startFileName) break;
|
|
929
|
-
}
|
|
930
|
-
return Array.from(files.values());
|
|
931
|
-
});
|
|
81
|
+
return this.archives.findInfo(prefix, config);
|
|
932
82
|
}
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
let bytes = Buffer.from(path, "utf8");
|
|
936
|
-
if (bytes.length > 1000) {
|
|
937
|
-
throw new Error(`Path too long: ${path.length} characters > 1000 characters. Path: ${path}`);
|
|
938
|
-
}
|
|
83
|
+
public async assertPathValid(path: string): Promise<void> {
|
|
84
|
+
return this.archives.assertPathValid(path);
|
|
939
85
|
}
|
|
940
86
|
|
|
941
87
|
public async move(config: {
|
|
@@ -943,49 +89,23 @@ export class ArchivesBackblaze {
|
|
|
943
89
|
target: Archives;
|
|
944
90
|
targetPath: string;
|
|
945
91
|
copyInstead?: boolean;
|
|
946
|
-
}) {
|
|
947
|
-
let {
|
|
92
|
+
}): Promise<void> {
|
|
93
|
+
let { target, targetPath } = config;
|
|
948
94
|
let base = target.getBaseArchives?.();
|
|
949
95
|
if (base) {
|
|
950
96
|
target = base.archives;
|
|
951
97
|
targetPath = base.parentPath + targetPath;
|
|
952
98
|
}
|
|
953
|
-
//
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
// downloading and re-uploading the entire file.
|
|
964
|
-
let info = await api.listFileNames({ bucketId: this.bucketId, prefix: path, maxFileCount: 10 });
|
|
965
|
-
let file = info.files.find(x => x.fileName === path);
|
|
966
|
-
if (!file) throw new Error(`File not found to move: ${path}`);
|
|
967
|
-
await api.copyFile({
|
|
968
|
-
sourceFileId: file.fileId,
|
|
969
|
-
fileName: targetPath,
|
|
970
|
-
destinationBucketId: targetBucketId,
|
|
971
|
-
});
|
|
972
|
-
});
|
|
973
|
-
} else {
|
|
974
|
-
let data = await this.get(path);
|
|
975
|
-
if (!data) throw new Error(`File not found to move: ${path}`);
|
|
976
|
-
await target.set(targetPath, data);
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
if (!config.copyInstead) {
|
|
980
|
-
let exists = await this.getInfo(targetPath);
|
|
981
|
-
if (!exists) {
|
|
982
|
-
debugbreak(2);
|
|
983
|
-
debugger;
|
|
984
|
-
console.error(`File not found after move. Leaving BOTH files. ${targetPath} was not found. Being moved from ${path}`);
|
|
985
|
-
} else {
|
|
986
|
-
await this.del(path);
|
|
987
|
-
}
|
|
988
|
-
}
|
|
99
|
+
// Hand the inner instance across so the base implementation's bucket-to-bucket copyFile fast path (an instanceof check on its own class) still triggers.
|
|
100
|
+
let innerTarget: IArchives = target instanceof ArchivesBackblaze
|
|
101
|
+
? target.archives
|
|
102
|
+
: target as unknown as IArchives;
|
|
103
|
+
return this.archives.move({
|
|
104
|
+
path: config.path,
|
|
105
|
+
target: innerTarget,
|
|
106
|
+
targetPath,
|
|
107
|
+
copyInstead: config.copyInstead,
|
|
108
|
+
});
|
|
989
109
|
}
|
|
990
110
|
|
|
991
111
|
public async copy(config: {
|
|
@@ -996,13 +116,8 @@ export class ArchivesBackblaze {
|
|
|
996
116
|
return this.move({ ...config, copyInstead: true });
|
|
997
117
|
}
|
|
998
118
|
|
|
999
|
-
public async getURL(path: string) {
|
|
1000
|
-
return
|
|
1001
|
-
if (path.startsWith("/")) {
|
|
1002
|
-
path = path.slice(1);
|
|
1003
|
-
}
|
|
1004
|
-
return await api.getDownloadURL("file/" + this.bucketName + "/" + path);
|
|
1005
|
-
});
|
|
119
|
+
public async getURL(path: string): Promise<string> {
|
|
120
|
+
return this.archives.getURL(path);
|
|
1006
121
|
}
|
|
1007
122
|
|
|
1008
123
|
public async getDownloadAuthorization(config: {
|
|
@@ -1019,25 +134,10 @@ export class ArchivesBackblaze {
|
|
|
1019
134
|
fileNamePrefix: string;
|
|
1020
135
|
authorizationToken: string;
|
|
1021
136
|
}> {
|
|
1022
|
-
return
|
|
1023
|
-
return await api.getDownloadAuthorization({
|
|
1024
|
-
bucketId: this.bucketId,
|
|
1025
|
-
fileNamePrefix: config.fileNamePrefix ?? "",
|
|
1026
|
-
...config,
|
|
1027
|
-
});
|
|
1028
|
-
});
|
|
137
|
+
return this.archives.getDownloadAuthorization(config);
|
|
1029
138
|
}
|
|
1030
139
|
}
|
|
1031
140
|
|
|
1032
|
-
/*
|
|
1033
|
-
Names should be a UTF-8 string up to 1024 bytes with the following exceptions:
|
|
1034
|
-
Character codes below 32 are not allowed.
|
|
1035
|
-
DEL characters (127) are not allowed.
|
|
1036
|
-
Backslashes are not allowed.
|
|
1037
|
-
File names cannot start with /, end with /, or contain //.
|
|
1038
|
-
*/
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
141
|
export const getArchivesBackblaze = cache((domain: string) => {
|
|
1042
142
|
return new ArchivesBackblaze({ bucketName: domain });
|
|
1043
143
|
});
|
|
@@ -1066,4 +166,4 @@ export const getArchivesBackblazePublic = cache((domain: string) => {
|
|
|
1066
166
|
cacheTime: timeInMinute,
|
|
1067
167
|
allowedOrigins: [`https://${domain}`, `https://127-0-0-1.${domain}:7007`],
|
|
1068
168
|
});
|
|
1069
|
-
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { qreact as qreactType } from "./qreact";
|
|
2
|
+
|
|
3
|
+
// qreact.tsx assigns itself onto globalThis at runtime, but only files that import { qreact } see it in the type system. Sliftutils' .tsx sources get pulled into our program (its .d.ts files' relative imports resolve to the neighboring sources) and compile under our qreact jsxFactory without importing it — this ambient declaration gives them the global qreact value and the global JSX fallback namespace.
|
|
4
|
+
declare global {
|
|
5
|
+
const qreact: typeof qreactType;
|
|
6
|
+
namespace JSX {
|
|
7
|
+
type IntrinsicElements = qreactType.JSX.IntrinsicElements;
|
|
8
|
+
type Element = qreactType.JSX.Element;
|
|
9
|
+
type IntrinsicClassAttributes<T = unknown> = qreactType.JSX.IntrinsicClassAttributes<T>;
|
|
10
|
+
interface ElementClass {
|
|
11
|
+
render: any;
|
|
12
|
+
}
|
|
13
|
+
interface ElementAttributesProperty {
|
|
14
|
+
props: {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -264,6 +264,33 @@ async function main() {
|
|
|
264
264
|
console.log(`✅ Swap configured: ${swapCheck.split(/\s+/)[1]}MB total vs ${ramMB}MB REAL MEMORY`);
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
// Enable TCP BBR congestion control (with fq qdisc, which BBR is designed to pair with)
|
|
268
|
+
console.log("Checking TCP congestion control...");
|
|
269
|
+
const currentCongestion = (await runPromise(`ssh ${sshRemote} "sysctl -n net.ipv4.tcp_congestion_control"`)).trim();
|
|
270
|
+
if (currentCongestion === "bbr") {
|
|
271
|
+
console.log("✅ TCP BBR already enabled");
|
|
272
|
+
} else {
|
|
273
|
+
await runPromise(`ssh ${sshRemote} "sudo modprobe tcp_bbr"`, { nothrow: true });
|
|
274
|
+
const availableCongestion = await runPromise(`ssh ${sshRemote} "sysctl -n net.ipv4.tcp_available_congestion_control"`);
|
|
275
|
+
if (!availableCongestion.split(/\s+/).includes("bbr")) {
|
|
276
|
+
console.warn(`⚠️ TCP BBR not available on this kernel (current: ${currentCongestion}, available: ${availableCongestion.trim()}). Skipping.`);
|
|
277
|
+
} else {
|
|
278
|
+
console.log(`Enabling TCP BBR (current: ${currentCongestion})...`);
|
|
279
|
+
// Persist across reboots (modules-load.d so tcp_bbr is loaded before sysctl.d is applied)
|
|
280
|
+
await runPromise(`ssh ${sshRemote} "echo 'tcp_bbr' | sudo tee /etc/modules-load.d/bbr.conf"`);
|
|
281
|
+
await runPromise(`ssh ${sshRemote} "printf 'net.core.default_qdisc=fq\\nnet.ipv4.tcp_congestion_control=bbr\\n' | sudo tee /etc/sysctl.d/99-bbr.conf"`);
|
|
282
|
+
// Enable immediately
|
|
283
|
+
await runPromise(`ssh ${sshRemote} "sudo sysctl -w net.core.default_qdisc=fq"`);
|
|
284
|
+
await runPromise(`ssh ${sshRemote} "sudo sysctl -w net.ipv4.tcp_congestion_control=bbr"`);
|
|
285
|
+
const newCongestion = (await runPromise(`ssh ${sshRemote} "sysctl -n net.ipv4.tcp_congestion_control"`)).trim();
|
|
286
|
+
if (newCongestion === "bbr") {
|
|
287
|
+
console.log("✅ TCP BBR enabled");
|
|
288
|
+
} else {
|
|
289
|
+
console.warn(`⚠️ Tried to enable TCP BBR, but congestion control is still: ${newCongestion}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
267
294
|
let backblazePath = getBackblazePath();
|
|
268
295
|
|
|
269
296
|
console.log("Setting up machine:", sshRemote);
|
package/tsconfig.json
CHANGED
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
],
|
|
25
25
|
"socket-function/*": [
|
|
26
26
|
"./node_modules/socket-function/*"
|
|
27
|
+
],
|
|
28
|
+
// Resolve to the emitted declarations instead of the linked sources: sliftutils compiles its JSX with preact.createElement, so its .tsx files cannot compile under our qreact jsxFactory.
|
|
29
|
+
"sliftutils": [
|
|
30
|
+
"./node_modules/sliftutils/index.d.ts"
|
|
31
|
+
],
|
|
32
|
+
"sliftutils/*": [
|
|
33
|
+
"./node_modules/sliftutils/*.d.ts"
|
|
27
34
|
]
|
|
28
35
|
},
|
|
29
36
|
"experimentalDecorators": true,
|