querysub 0.403.0 → 0.405.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.
Files changed (108) hide show
  1. package/.cursorrules +2 -0
  2. package/bin/audit-imports.js +4 -0
  3. package/bin/join.js +1 -1
  4. package/package.json +7 -4
  5. package/spec.txt +77 -0
  6. package/src/-a-archives/archiveCache.ts +9 -4
  7. package/src/-a-archives/archivesBackBlaze.ts +1039 -1039
  8. package/src/-a-auth/certs.ts +0 -12
  9. package/src/-c-identity/IdentityController.ts +12 -3
  10. package/src/-f-node-discovery/NodeDiscovery.ts +32 -26
  11. package/src/-g-core-values/NodeCapabilities.ts +12 -2
  12. package/src/0-path-value-core/AuthorityLookup.ts +239 -0
  13. package/src/0-path-value-core/LockWatcher2.ts +150 -0
  14. package/src/0-path-value-core/PathRouter.ts +543 -0
  15. package/src/0-path-value-core/PathRouterRouteOverride.ts +72 -0
  16. package/src/0-path-value-core/PathRouterServerAuthoritySpec.tsx +73 -0
  17. package/src/0-path-value-core/PathValueCommitter.ts +222 -488
  18. package/src/0-path-value-core/PathValueController.ts +277 -239
  19. package/src/0-path-value-core/PathWatcher.ts +534 -0
  20. package/src/0-path-value-core/ShardPrefixes.ts +31 -0
  21. package/src/0-path-value-core/ValidStateComputer.ts +303 -0
  22. package/src/0-path-value-core/archiveLocks/ArchiveLocks.ts +1 -1
  23. package/src/0-path-value-core/archiveLocks/ArchiveLocks2.ts +80 -44
  24. package/src/0-path-value-core/archiveLocks/archiveSnapshots.ts +13 -16
  25. package/src/0-path-value-core/auditLogs.ts +2 -0
  26. package/src/0-path-value-core/hackedPackedPathParentFiltering.ts +97 -0
  27. package/src/0-path-value-core/pathValueArchives.ts +491 -492
  28. package/src/0-path-value-core/pathValueCore.ts +195 -1496
  29. package/src/0-path-value-core/startupAuthority.ts +74 -0
  30. package/src/1-path-client/RemoteWatcher.ts +90 -82
  31. package/src/1-path-client/pathValueClientWatcher.ts +808 -815
  32. package/src/2-proxy/PathValueProxyWatcher.ts +10 -8
  33. package/src/2-proxy/archiveMoveHarness.ts +182 -214
  34. package/src/2-proxy/garbageCollection.ts +9 -8
  35. package/src/2-proxy/schema2.ts +21 -1
  36. package/src/3-path-functions/PathFunctionHelpers.ts +206 -180
  37. package/src/3-path-functions/PathFunctionRunner.ts +943 -766
  38. package/src/3-path-functions/PathFunctionRunnerMain.ts +5 -3
  39. package/src/3-path-functions/pathFunctionLoader.ts +2 -2
  40. package/src/3-path-functions/syncSchema.ts +596 -521
  41. package/src/4-deploy/deployFunctions.ts +19 -4
  42. package/src/4-deploy/deployGetFunctionsInner.ts +8 -2
  43. package/src/4-deploy/deployMain.ts +51 -68
  44. package/src/4-deploy/edgeClientWatcher.tsx +6 -1
  45. package/src/4-deploy/edgeNodes.ts +2 -2
  46. package/src/4-dom/qreact.tsx +2 -4
  47. package/src/4-dom/qreactTest.tsx +7 -13
  48. package/src/4-querysub/Querysub.ts +21 -8
  49. package/src/4-querysub/QuerysubController.ts +45 -29
  50. package/src/4-querysub/permissions.ts +2 -2
  51. package/src/4-querysub/querysubPrediction.ts +80 -70
  52. package/src/4-querysub/schemaHelpers.ts +5 -1
  53. package/src/5-diagnostics/GenericFormat.tsx +14 -9
  54. package/src/archiveapps/archiveGCEntry.tsx +9 -2
  55. package/src/archiveapps/archiveJoinEntry.ts +96 -84
  56. package/src/bits.ts +19 -0
  57. package/src/config.ts +21 -3
  58. package/src/config2.ts +23 -48
  59. package/src/deployManager/components/DeployPage.tsx +7 -3
  60. package/src/deployManager/machineSchema.ts +4 -1
  61. package/src/diagnostics/ActionsHistory.ts +3 -8
  62. package/src/diagnostics/AuditLogPage.tsx +2 -3
  63. package/src/diagnostics/FunctionCallInfo.tsx +141 -0
  64. package/src/diagnostics/FunctionCallInfoState.ts +162 -0
  65. package/src/diagnostics/MachineThreadInfo.tsx +1 -1
  66. package/src/diagnostics/NodeViewer.tsx +37 -48
  67. package/src/diagnostics/SyncTestPage.tsx +241 -0
  68. package/src/diagnostics/auditImportViolations.ts +185 -0
  69. package/src/diagnostics/listenOnDebugger.ts +3 -3
  70. package/src/diagnostics/logs/IndexedLogs/BufferUnitSet.ts +10 -4
  71. package/src/diagnostics/logs/IndexedLogs/IndexedLogs.ts +2 -2
  72. package/src/diagnostics/logs/IndexedLogs/LogViewer3.tsx +24 -22
  73. package/src/diagnostics/logs/IndexedLogs/moveIndexLogsToPublic.ts +1 -1
  74. package/src/diagnostics/logs/diskLogGlobalContext.ts +1 -0
  75. package/src/diagnostics/logs/errorNotifications2/logWatcher.ts +1 -3
  76. package/src/diagnostics/logs/lifeCycleAnalysis/LifeCycleEntryEditor.tsx +34 -16
  77. package/src/diagnostics/logs/lifeCycleAnalysis/LifeCycleEntryReadMode.tsx +4 -6
  78. package/src/diagnostics/logs/lifeCycleAnalysis/LifeCycleInstanceTableView.tsx +36 -5
  79. package/src/diagnostics/logs/lifeCycleAnalysis/LifeCyclePage.tsx +19 -5
  80. package/src/diagnostics/logs/lifeCycleAnalysis/LifeCycleRenderer.tsx +15 -7
  81. package/src/diagnostics/logs/lifeCycleAnalysis/NestedLifeCycleInfo.tsx +28 -106
  82. package/src/diagnostics/logs/lifeCycleAnalysis/lifeCycleMatching.ts +2 -0
  83. package/src/diagnostics/logs/lifeCycleAnalysis/lifeCycleMisc.ts +0 -0
  84. package/src/diagnostics/logs/lifeCycleAnalysis/lifeCycleSearch.tsx +18 -7
  85. package/src/diagnostics/logs/lifeCycleAnalysis/lifeCycles.tsx +3 -0
  86. package/src/diagnostics/managementPages.tsx +10 -3
  87. package/src/diagnostics/misc-pages/ArchiveViewer.tsx +20 -26
  88. package/src/diagnostics/misc-pages/ArchiveViewerTree.tsx +6 -4
  89. package/src/diagnostics/misc-pages/ComponentSyncStats.tsx +2 -2
  90. package/src/diagnostics/misc-pages/LocalWatchViewer.tsx +7 -9
  91. package/src/diagnostics/misc-pages/SnapshotViewer.tsx +23 -12
  92. package/src/diagnostics/misc-pages/archiveViewerShared.tsx +1 -1
  93. package/src/diagnostics/pathAuditer.ts +486 -0
  94. package/src/diagnostics/pathAuditerCallback.ts +20 -0
  95. package/src/diagnostics/watchdog.ts +8 -1
  96. package/src/library-components/URLParam.ts +1 -1
  97. package/src/misc/hash.ts +1 -0
  98. package/src/path.ts +21 -7
  99. package/src/server.ts +54 -47
  100. package/src/user-implementation/loginEmail.tsx +1 -1
  101. package/tempnotes.txt +65 -0
  102. package/test.ts +298 -97
  103. package/src/0-path-value-core/NodePathAuthorities.ts +0 -1057
  104. package/src/0-path-value-core/PathController.ts +0 -1
  105. package/src/5-diagnostics/diskValueAudit.ts +0 -218
  106. package/src/5-diagnostics/memoryValueAudit.ts +0 -438
  107. package/src/archiveapps/archiveMergeEntry.tsx +0 -48
  108. package/src/archiveapps/lockTest.ts +0 -127
@@ -1,1040 +1,1040 @@
1
- import { cache, lazy } from "socket-function/src/caching";
2
- import { getStorageDir } from "../fs";
3
- import { Archives } from "./archives";
4
- import fs from "fs";
5
- import os from "os";
6
- import { isNode, sort, timeInHour, timeInMinute } from "socket-function/src/misc";
7
- import { httpsRequest } from "../https";
8
- import { delay } from "socket-function/src/batching";
9
- import { devDebugbreak } from "../config";
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";
15
-
16
- export function hasBackblazePermissions() {
17
- return isNode() && fs.existsSync(getBackblazePath());
18
- }
19
- export function getBackblazePath() {
20
- let testPaths = [
21
- getStorageDir() + "backblaze.json",
22
- os.homedir() + "/backblaze.json",
23
- ];
24
- for (let path of testPaths) {
25
- if (fs.existsSync(path)) {
26
- return path;
27
- }
28
- }
29
- return testPaths[0];
30
- }
31
-
32
- type BackblazeCreds = {
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
-
393
- export class ArchivesBackblaze {
394
- public constructor(private config: {
395
- bucketName: string;
396
- public?: boolean;
397
- immutable?: boolean;
398
- cacheTime?: number;
399
- }) { }
400
-
401
- private bucketName = this.config.bucketName.replaceAll(/[^\w\d]/g, "-");
402
- private bucketId = "";
403
-
404
- private logging = false;
405
- public enableLogging() {
406
- this.logging = true;
407
- }
408
- private log(text: string) {
409
- if (!this.logging) return;
410
- console.log(text);
411
- }
412
-
413
- public getDebugName() {
414
- return "backblaze/" + this.config.bucketName;
415
- }
416
-
417
- private getBucketAPI = lazy(async () => {
418
- let api = await getAPI();
419
-
420
- let cacheTime = this.config.cacheTime ?? 0;
421
- if (this.config.immutable) {
422
- cacheTime = 86400 * 1000;
423
- }
424
-
425
- // ALWAYS set access control, as we can make urls for private buckets with getDownloadAuthorization
426
- let desiredCorsRules = [{
427
- corsRuleName: "allowAll",
428
- allowedOrigins: ["https"],
429
- allowedOperations: ["b2_download_file_by_id", "b2_download_file_by_name"],
430
- allowedHeaders: ["range"],
431
- exposeHeaders: ["x-bz-content-sha1"],
432
- maxAgeSeconds: cacheTime / 1000,
433
- }];
434
- let bucketInfo: Record<string, unknown> = {};
435
- if (cacheTime) {
436
- bucketInfo["cache-control"] = `max-age=${cacheTime / 1000}`;
437
- }
438
-
439
-
440
- let exists = false;
441
- try {
442
- await api.createBucket({
443
- bucketName: this.bucketName,
444
- bucketType: this.config.public ? "allPublic" : "allPrivate",
445
- lifecycleRules: [{
446
- "daysFromUploadingToHiding": null,
447
- // Keep files for 7 days, which should be enough time to recover accidental hiding.
448
- "daysFromHidingToDeleting": 7,
449
- "fileNamePrefix": ""
450
- }],
451
- corsRules: desiredCorsRules,
452
- bucketInfo
453
- });
454
- } catch (e: any) {
455
- if (!e.stack.includes(`"duplicate_bucket_name"`)) {
456
- throw e;
457
- }
458
- exists = true;
459
- }
460
-
461
- let bucketList = await api.listBuckets({
462
- bucketName: this.bucketName,
463
- });
464
- if (bucketList.buckets.length === 0) {
465
- 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.`);
466
- }
467
- this.bucketId = bucketList.buckets[0].bucketId;
468
-
469
- if (exists) {
470
- let bucket = bucketList.buckets[0];
471
- function normalize(obj: Record<string, unknown>) {
472
- let kvps = Object.entries(obj);
473
- sort(kvps, x => x[0]);
474
- return Object.fromEntries(kvps);
475
- }
476
- function orderIndependentEqual(lhs: Record<string, unknown>, rhs: Record<string, unknown>) {
477
- return JSON.stringify(normalize(lhs)) === JSON.stringify(normalize(rhs));
478
- }
479
- function orderIndependentEqualArray(lhs: unknown[], rhs: unknown[]) {
480
- if (lhs.length !== rhs.length) return false;
481
- for (let i = 0; i < lhs.length; i++) {
482
- if (!orderIndependentEqual(lhs[i] as Record<string, unknown>, rhs[i] as Record<string, unknown>)) return false;
483
- }
484
- return true;
485
- }
486
- if (
487
- !orderIndependentEqualArray(bucket.corsRules, desiredCorsRules)
488
- || !orderIndependentEqual(bucket.bucketInfo, bucketInfo)
489
- ) {
490
- console.log(magenta(`Updating CORS rules for ${this.bucketName}`), bucket.corsRules, desiredCorsRules);
491
- await api.updateBucket({
492
- accountId: bucket.accountId,
493
- bucketId: bucket.bucketId,
494
- bucketType: bucket.bucketType,
495
- lifecycleRules: bucket.lifecycleRules,
496
- corsRules: desiredCorsRules,
497
- bucketInfo: bucketInfo,
498
- });
499
- }
500
- }
501
- return api;
502
- });
503
-
504
- // Keep track of when we last reset because of a 503
505
- private last503Reset = 0;
506
- // IMPORTANT! We must always CATCH AROUND the apiRetryLogic, NEVER inside of fnc. Otherwise we won't
507
- // be able to recreate the auth token.
508
- private async apiRetryLogic<T>(
509
- fnc: (api: B2Api) => Promise<T>,
510
- retries = 3
511
- ): Promise<T> {
512
- let api = await this.getBucketAPI();
513
- try {
514
- return await fnc(api);
515
- } catch (err: any) {
516
- if (retries <= 0) throw err;
517
-
518
- // If it's a 503 and it's been a minute since we last reset, then Wait and reset.
519
- if (
520
- (err.stack.includes(`"status": 503`)
521
- || err.stack.includes(`"service_unavailable"`)
522
- || err.stack.includes(`"internal_error"`)
523
- || err.stack.includes(`ENOBUFS`)
524
- ) && Date.now() - this.last503Reset > 60 * 1000) {
525
- console.error("503 error, waiting a minute and resetting: " + err.message);
526
- this.log("503 error, waiting a minute and resetting: " + err.message);
527
- await delay(10 * 1000);
528
- // We check again in case, and in the very likely case that this is being run in parallel, we only want to reset once.
529
- if (Date.now() - this.last503Reset > 60 * 1000) {
530
- this.log("Resetting getAPI and getBucketAPI: " + err.message);
531
- this.last503Reset = Date.now();
532
- getAPI.reset();
533
- this.getBucketAPI.reset();
534
- }
535
- return this.apiRetryLogic(fnc, retries - 1);
536
- }
537
-
538
- // If the error is that the authorization token is invalid, reset getBucketAPI and getAPI
539
- // If the error is that the bucket isn't found, reset getBucketAPI
540
- if (err.stack.includes(`"expired_auth_token"`)) {
541
- this.log("Authorization token expired");
542
- getAPI.reset();
543
- this.getBucketAPI.reset();
544
- return this.apiRetryLogic(fnc, retries - 1);
545
- }
546
-
547
- if (
548
- err.stack.includes(`no tomes available`)
549
- || err.stack.includes(`ETIMEDOUT`)
550
- || err.stack.includes(`socket hang up`)
551
- // Eh... this might be bad, but... I think we just get random 400 errors. If this spams errors,
552
- // we can remove this line.
553
- || err.stack.includes(`400 Bad Request`)
554
- || err.stack.includes(`getaddrinfo ENOTFOUND`)
555
- || err.stack.includes(`ECONNRESET`)
556
- || err.stack.includes(`ECONNREFUSED`)
557
- || err.stack.includes(`ENOBUFS`)
558
- ) {
559
- console.error("Retrying in 5s: " + err.message);
560
- this.log(err.message + " retrying in 5s");
561
- await delay(5000);
562
- return this.apiRetryLogic(fnc, retries - 1);
563
- }
564
-
565
- if (err.stack.includes(`getaddrinfo ENOTFOUND`)) {
566
- let urlObj = new URL(api.apiUrl);
567
- let hostname = urlObj.hostname;
568
- let lookupAddresses = await new Promise(resolve => {
569
- dns.lookup(hostname, (err, addresses) => {
570
- resolve(addresses);
571
- });
572
- });
573
- let resolveAddresses = await new Promise(resolve => {
574
- dns.resolve4(hostname, (err, addresses) => {
575
- resolve(addresses);
576
- });
577
- });
578
- console.error(`getaddrinfo ENOTFOUND ${hostname}`, { lookupAddresses, resolveAddresses, apiUrl: api.apiUrl, fullError: err.stack });
579
- }
580
-
581
- // TODO: Handle if the bucket is deleted?
582
- throw err;
583
- }
584
- }
585
-
586
- public async get(fileName: string, config?: { range?: { start: number; end: number; }; retryCount?: number }): Promise<Buffer | undefined> {
587
- let downloading = true;
588
- try {
589
- let time = Date.now();
590
- const downloadPoll = () => {
591
- if (!downloading) return;
592
- this.log(`Backblaze download in progress ${fileName}`);
593
- setTimeout(downloadPoll, 5000);
594
- };
595
- setTimeout(downloadPoll, 5000);
596
- let result = await this.apiRetryLogic(async (api) => {
597
- let range = config?.range;
598
- if (range) {
599
- let fileInfo = await this.getInfo(fileName);
600
- if (!fileInfo) throw new Error(`File ${fileName} not found`);
601
- let rangeStart = range.start;
602
- let rangeEnd = Math.min(range.end, fileInfo.size);
603
- // NOTE: I think if we request nothing, it confuses Backblaze and ends up giving us the entire file.
604
- if (rangeEnd <= rangeStart) return Buffer.alloc(0);
605
- let result = await api.downloadFileByName({
606
- bucketName: this.bucketName,
607
- fileName,
608
- range: { start: rangeStart, end: rangeEnd },
609
- });
610
- if (result.length !== rangeEnd - rangeStart) {
611
- let afterLength = await this.getInfo(fileName);
612
- if (afterLength && afterLength.size >= fileInfo.size) {
613
- 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}`);
614
- // 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.
615
- setTimeout(async () => {
616
- let resultAgain = await api.downloadFileByName({
617
- bucketName: this.bucketName,
618
- fileName,
619
- range: { start: rangeStart, end: rangeEnd },
620
- });
621
- devDebugbreak();
622
- let didResultFixItSelf = resultAgain.length === rangeEnd - rangeStart;
623
-
624
- console.log({ didResultFixItSelf }, resultAgain);
625
- }, timeInMinute * 2);
626
- }
627
- }
628
- }
629
- return await api.downloadFileByName({
630
- bucketName: this.bucketName,
631
- fileName,
632
- });
633
- });
634
- let timeStr = formatTime(Date.now() - time);
635
- let rateStr = formatNumber(result.length / (Date.now() - time) * 1000) + "B/s";
636
- this.log(`backblaze download (${formatNumber(result.length)}B${config?.range && `, ${formatNumber(config.range.start)} - ${formatNumber(config.range.end)}` || ""}) in ${timeStr} (${rateStr}, ${fileName})`);
637
- return result;
638
- } catch (e) {
639
- this.log(`backblaze file does not exist ${fileName}`);
640
- return undefined;
641
- } finally {
642
- downloading = false;
643
- }
644
- }
645
- public async set(fileName: string, data: Buffer): Promise<void> {
646
- this.log(`backblaze upload (${formatNumber(data.length)}B) ${fileName}`);
647
- let f = fileName;
648
- await this.apiRetryLogic(async (api) => {
649
- await api.uploadFile({ bucketId: this.bucketId, fileName, data: data, });
650
- });
651
- let existsChecks = 30;
652
- while (existsChecks > 0) {
653
- let exists = await this.getInfo(fileName);
654
- if (exists) break;
655
- await delay(1000);
656
- existsChecks--;
657
- }
658
- if (existsChecks === 0) {
659
- let exists = await this.getInfo(fileName);
660
- devDebugbreak();
661
- 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.`);
662
- }
663
-
664
- }
665
- public async append(fileName: string, data: Buffer): Promise<void> {
666
- throw new Error(`ArchivesBackblaze does not support append. Use set instead.`);
667
- // this.log(`backblaze append (${formatNumber(data.length)}B) ${fileName}`);
668
- // // Backblaze doesn't have native append, so we need to get, concatenate, and set
669
- // let existing = await this.get(fileName);
670
- // let newData = existing ? Buffer.concat([existing, data]) : data;
671
- // await this.set(fileName, newData);
672
- }
673
- public async del(fileName: string): Promise<void> {
674
- this.log(`backblaze delete ${fileName}`);
675
- try {
676
- await this.apiRetryLogic(async (api) => {
677
- await api.hideFile({ bucketId: this.bucketId, fileName: fileName });
678
- });
679
- } catch (e: any) {
680
- this.log(`backblaze error in hide, possibly already hidden ${fileName}\n${e.stack}`);
681
- }
682
-
683
- // NOTE: Deletion SEEMS to work. This DOES break if we delete a file which keeps being recreated,
684
- // ex, the heartbeat.
685
- // let existsChecks = 10;
686
- // while (existsChecks > 0) {
687
- // let exists = await this.getInfo(fileName);
688
- // if (!exists) break;
689
- // await delay(1000);
690
- // existsChecks--;
691
- // }
692
- // if (existsChecks === 0) {
693
- // let exists = await this.getInfo(fileName);
694
- // devDebugbreak();
695
- // console.warn(`File ${fileName} was deleted, but was still found afterwards`);
696
- // exists = await this.getInfo(fileName);
697
- // }
698
- }
699
-
700
- public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined>; }): Promise<void> {
701
-
702
- let onError: (() => Promise<void>)[] = [];
703
- let time = Date.now();
704
- try {
705
- let { path } = config;
706
- // Backblaze requires 5MB chunks. But, larger is more efficient for us.
707
- const MIN_CHUNK_SIZE = 32 * 1024 * 1024;
708
- let dataQueue: Buffer[] = [];
709
- async function getNextData(): Promise<Buffer | undefined> {
710
- if (dataQueue.length) return dataQueue.shift();
711
- // Get buffers until we get 5MB, OR, end. Backblaze requires this for large files.
712
- let totalBytes = 0;
713
- let buffers: Buffer[] = [];
714
- while (totalBytes < MIN_CHUNK_SIZE) {
715
- let data = await config.getNextData();
716
- if (!data) break;
717
- totalBytes += data.length;
718
- buffers.push(data);
719
- }
720
- if (!buffers.length) return undefined;
721
- return Buffer.concat(buffers);
722
- }
723
-
724
- let fileName = path;
725
- let data = await getNextData();
726
- if (!data?.length) return;
727
- // Backblaze disallows overly small files
728
- if (data.length < MIN_CHUNK_SIZE) {
729
- return await this.set(fileName, data);
730
- }
731
- // Backblaze disallows less than 2 chunks
732
- let secondData = await getNextData();
733
- if (!secondData?.length) {
734
- return await this.set(fileName, data);
735
- }
736
- // ALSO, if there are two chunks, but one is too small, combine it. This helps allow us never
737
- // send small chunks.
738
- if (secondData.length < MIN_CHUNK_SIZE) {
739
- return await this.set(fileName, Buffer.concat([data, secondData]));
740
- }
741
- this.log(`Uploading large file ${config.path}`);
742
- dataQueue.unshift(data, secondData);
743
-
744
-
745
- let uploadInfo = await this.apiRetryLogic(async (api) => {
746
- return await api.startLargeFile({
747
- bucketId: this.bucketId,
748
- fileName: fileName,
749
- contentType: "b2/x-auto",
750
- fileInfo: {},
751
- });
752
- });
753
- onError.push(async () => {
754
- await this.apiRetryLogic(async (api) => {
755
- await api.cancelLargeFile({ fileId: uploadInfo.fileId });
756
- });
757
- });
758
-
759
- const LOG_INTERVAL = timeInMinute;
760
- let nextLogTime = Date.now() + LOG_INTERVAL;
761
-
762
- let partNumber = 1;
763
- let partSha1Array: string[] = [];
764
- let totalBytes = 0;
765
- while (true) {
766
- data = await getNextData();
767
- if (!data) break;
768
- // So... if the next chunk is the last one, combine it with the current one. This
769
- // prevents ANY uploads from being < the threshold, as apparently the "last part"
770
- // check in backblaze fails when we have to retry an upload (due to "no tomes available").
771
- // Well it can't fail if even the last part is > 5MB, now can it!
772
- // BUT, only if this isn't the first chunk, otherwise we might try to send
773
- // a single chunk, which we can't do.
774
- if (partSha1Array.length > 0) {
775
- let maybeLastData = await getNextData();
776
- if (maybeLastData) {
777
- if (maybeLastData.length < MIN_CHUNK_SIZE) {
778
- // It's the last one, so consume it now
779
- data = Buffer.concat([data, maybeLastData]);
780
- } else {
781
- // It's not the last one. Put it back, in case the one AFTER is the last
782
- // one, in which case we need to merge maybeLastData with the next next data.
783
- dataQueue.unshift(maybeLastData);
784
- }
785
- }
786
- }
787
- let sha1 = require("crypto").createHash("sha1");
788
- sha1.update(data);
789
- let sha1Hex = sha1.digest("hex");
790
- partSha1Array.push(sha1Hex);
791
- await this.apiRetryLogic(async (api) => {
792
- if (!data) throw new Error("Impossible, data is undefined");
793
-
794
- let timeStr = formatTime(Date.now() - time);
795
- let rateStr = formatNumber(totalBytes / (Date.now() - time) * 1000) + "B/s";
796
- this.log(`Uploading large file part ${partNumber}, uploaded ${blue(formatNumber(totalBytes) + "B")} in ${blue(timeStr)} (${blue(rateStr)}). ${config.path}`);
797
- totalBytes += data.length;
798
-
799
- await api.uploadPart({
800
- fileId: uploadInfo.fileId,
801
- partNumber: partNumber,
802
- data: data,
803
- sha1: sha1Hex,
804
- });
805
- });
806
- partNumber++;
807
-
808
- if (Date.now() > nextLogTime) {
809
- nextLogTime = Date.now() + LOG_INTERVAL;
810
- let timeStr = formatTime(Date.now() - time);
811
- let rateStr = formatNumber(totalBytes / (Date.now() - time) * 1000) + "B/s";
812
- console.log(`Still uploading large file at ${Date.now()}. Uploaded ${formatNumber(totalBytes)}B in ${timeStr} (${rateStr}). ${config.path}`);
813
- }
814
- }
815
- this.log(`Finished uploading large file uploaded ${green(formatNumber(totalBytes))}B`);
816
-
817
- await this.apiRetryLogic(async (api) => {
818
- await api.finishLargeFile({
819
- fileId: uploadInfo.fileId,
820
- partSha1Array: partSha1Array,
821
- });
822
- });
823
- } catch (e: any) {
824
- for (let c of onError) {
825
- try {
826
- await c();
827
- } catch (e) {
828
- console.error(`Error during error clean. Ignoring, we will rethrow the original error, path ${config.path}`, e);
829
- }
830
- }
831
-
832
- throw new Error(`Error in setLargeFile for ${config.path}: ${e.stack}`);
833
- }
834
- }
835
-
836
- public async getInfo(fileName: string): Promise<{ writeTime: number; size: number; } | undefined> {
837
- return await this.apiRetryLogic(async (api) => {
838
- try {
839
- // 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.
840
- let info = await api.listFileNames({ bucketId: this.bucketId, prefix: fileName, maxFileCount: 1 });
841
- let file = info.files.find(x => x.fileName === fileName && x.action === "upload");
842
- if (!file) {
843
- this.log(`Backblaze file not exists ${fileName}`);
844
- return undefined;
845
- }
846
- this.log(`Backblaze file exists ${fileName}`);
847
- return {
848
- writeTime: file.uploadTimestamp,
849
- size: file.contentLength,
850
- };
851
- } catch (e: any) {
852
- if (e.stack.includes(`file_not_found`)) {
853
- this.log(`Backblaze file not exists ${fileName}`);
854
- return undefined;
855
- }
856
- throw e;
857
- }
858
- });
859
- }
860
-
861
- // For example findFileNames("ips/")
862
- public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
863
- let result = await this.findInfo(prefix, config);
864
- return result.map(x => x.path);
865
- }
866
- public async findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<{ path: string; createTime: number; size: number; }[]> {
867
- return await this.apiRetryLogic(async (api) => {
868
- if (!config?.shallow && config?.type === "folders") {
869
- let allFiles = await this.findInfo(prefix);
870
- let allFolders = new Map<string, { path: string; createTime: number; size: number }>();
871
- for (let { path, createTime, size } of allFiles) {
872
- let folder = path.split("/").slice(0, -1).join("/");
873
- if (!folder) continue;
874
- allFolders.set(folder, { path: folder, createTime, size });
875
- }
876
- return Array.from(allFolders.values());
877
- }
878
- let files = new Map<string, { path: string; createTime: number; size: number; }>();
879
- let startFileName = "";
880
- while (true) {
881
- let result = await api.listFileNames({
882
- bucketId: this.bucketId,
883
- prefix: prefix,
884
- startFileName,
885
- maxFileCount: 1000,
886
- delimiter: config?.shallow ? "/" : undefined,
887
- });
888
- for (let file of result.files) {
889
- if (file.action === "upload" && config?.type !== "folders") {
890
- files.set(file.fileName, { path: file.fileName, createTime: file.uploadTimestamp, size: file.contentLength });
891
- } else if (file.action === "folder" && config?.type === "folders") {
892
- let folder = file.fileName;
893
- if (folder.endsWith("/")) {
894
- folder = folder.slice(0, -1);
895
- }
896
- files.set(folder, { path: folder, createTime: file.uploadTimestamp, size: file.contentLength });
897
- }
898
-
899
- }
900
- startFileName = result.nextFileName;
901
- if (!startFileName) break;
902
- }
903
- return Array.from(files.values());
904
- });
905
- }
906
-
907
- public async assertPathValid(path: string) {
908
- let bytes = Buffer.from(path, "utf8");
909
- if (bytes.length > 1000) {
910
- throw new Error(`Path too long: ${path.length} characters > 1000 characters. Path: ${path}`);
911
- }
912
- }
913
-
914
- public async move(config: {
915
- path: string;
916
- target: Archives;
917
- targetPath: string;
918
- copyInstead?: boolean;
919
- }) {
920
- let { path, target, targetPath } = config;
921
- let base = target.getBaseArchives?.();
922
- if (base) {
923
- target = base.archives;
924
- targetPath = base.parentPath + targetPath;
925
- }
926
- // A self move should NOOP (and definitely not copy, and then delete itself!)
927
- if (target === this && path === targetPath) {
928
- this.log(`Backblaze move path to itself. Skipping move, as there is no work to do. ${path}`);
929
- return;
930
- }
931
- if (target instanceof ArchivesBackblaze) {
932
- let targetBucketId = target.bucketId;
933
- if (targetBucketId === this.bucketId && path === targetPath) return;
934
- await this.apiRetryLogic(async (api) => {
935
- // Ugh... listing the file name sucks, but... I guess it's still better than
936
- // downloading and re-uploading the entire file.
937
- let info = await api.listFileNames({ bucketId: this.bucketId, prefix: path, });
938
- let file = info.files.find(x => x.fileName === path);
939
- if (!file) throw new Error(`File not found to move: ${path}`);
940
- await api.copyFile({
941
- sourceFileId: file.fileId,
942
- fileName: targetPath,
943
- destinationBucketId: targetBucketId,
944
- });
945
- });
946
- } else {
947
- let data = await this.get(path);
948
- if (!data) throw new Error(`File not found to move: ${path}`);
949
- await target.set(targetPath, data);
950
- }
951
-
952
- if (!config.copyInstead) {
953
- let exists = await this.getInfo(targetPath);
954
- if (!exists) {
955
- debugbreak(2);
956
- debugger;
957
- console.error(`File not found after move. Leaving BOTH files. ${targetPath} was not found. Being moved from ${path}`);
958
- } else {
959
- await this.del(path);
960
- }
961
- }
962
- }
963
-
964
- public async copy(config: {
965
- path: string;
966
- target: Archives;
967
- targetPath: string;
968
- }): Promise<void> {
969
- return this.move({ ...config, copyInstead: true });
970
- }
971
-
972
- public async getURL(path: string) {
973
- return await this.apiRetryLogic(async (api) => {
974
- if (path.startsWith("/")) {
975
- path = path.slice(1);
976
- }
977
- return await api.getDownloadURL("file/" + this.bucketName + "/" + path);
978
- });
979
- }
980
-
981
- public async getDownloadAuthorization(config: {
982
- fileNamePrefix?: string;
983
- validDurationInSeconds: number;
984
- b2ContentDisposition?: string;
985
- b2ContentLanguage?: string;
986
- b2Expires?: string;
987
- b2CacheControl?: string;
988
- b2ContentEncoding?: string;
989
- b2ContentType?: string;
990
- }): Promise<{
991
- bucketId: string;
992
- fileNamePrefix: string;
993
- authorizationToken: string;
994
- }> {
995
- return await this.apiRetryLogic(async (api) => {
996
- return await api.getDownloadAuthorization({
997
- bucketId: this.bucketId,
998
- fileNamePrefix: config.fileNamePrefix ?? "",
999
- ...config,
1000
- });
1001
- });
1002
- }
1003
- }
1004
-
1005
- /*
1006
- Names should be a UTF-8 string up to 1024 bytes with the following exceptions:
1007
- Character codes below 32 are not allowed.
1008
- DEL characters (127) are not allowed.
1009
- Backslashes are not allowed.
1010
- File names cannot start with /, end with /, or contain //.
1011
- */
1012
-
1013
-
1014
- export const getArchivesBackblaze = cache((domain: string) => {
1015
- return new ArchivesBackblaze({ bucketName: domain });
1016
- });
1017
- export const getArchivesBackblazePrivateImmutable = cache((domain: string) => {
1018
- return new ArchivesBackblaze({
1019
- bucketName: domain + "-private-immutable",
1020
- immutable: true
1021
- });
1022
- });
1023
- export const getArchivesBackblazePublicImmutable = cache((domain: string) => {
1024
- return new ArchivesBackblaze({
1025
- bucketName: domain + "-public-immutable",
1026
- public: true,
1027
- immutable: true
1028
- });
1029
- });
1030
-
1031
- // NOTE: Cache by a minute. This might be a bad idea, but... usually whole reason for public is
1032
- // for cloudflare caching (as otherwise we can just access it through a server), or for large files
1033
- // (which should be cached anyways, and probably even use immutable caching).
1034
- export const getArchivesBackblazePublic = cache((domain: string) => {
1035
- return new ArchivesBackblaze({
1036
- bucketName: domain + "-public",
1037
- public: true,
1038
- cacheTime: timeInMinute,
1039
- });
1
+ import { cache, lazy } from "socket-function/src/caching";
2
+ import { getStorageDir } from "../fs";
3
+ import { Archives } from "./archives";
4
+ import fs from "fs";
5
+ import os from "os";
6
+ import { isNode, sort, timeInHour, timeInMinute } from "socket-function/src/misc";
7
+ import { httpsRequest } from "../https";
8
+ import { delay } from "socket-function/src/batching";
9
+ import { devDebugbreak, isLogBackblaze } from "../config";
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";
15
+
16
+ export function hasBackblazePermissions() {
17
+ return isNode() && fs.existsSync(getBackblazePath());
18
+ }
19
+ export function getBackblazePath() {
20
+ let testPaths = [
21
+ getStorageDir() + "backblaze.json",
22
+ os.homedir() + "/backblaze.json",
23
+ ];
24
+ for (let path of testPaths) {
25
+ if (fs.existsSync(path)) {
26
+ return path;
27
+ }
28
+ }
29
+ return testPaths[0];
30
+ }
31
+
32
+ type BackblazeCreds = {
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
+
393
+ export class ArchivesBackblaze {
394
+ public constructor(private config: {
395
+ bucketName: string;
396
+ public?: boolean;
397
+ immutable?: boolean;
398
+ cacheTime?: number;
399
+ }) { }
400
+
401
+ private bucketName = this.config.bucketName.replaceAll(/[^\w\d]/g, "-");
402
+ private bucketId = "";
403
+
404
+ private logging = isLogBackblaze();
405
+ public enableLogging() {
406
+ this.logging = true;
407
+ }
408
+ private log(text: string) {
409
+ if (!this.logging) return;
410
+ console.log(text);
411
+ }
412
+
413
+ public getDebugName() {
414
+ return "backblaze/" + this.config.bucketName;
415
+ }
416
+
417
+ private getBucketAPI = lazy(async () => {
418
+ let api = await getAPI();
419
+
420
+ let cacheTime = this.config.cacheTime ?? 0;
421
+ if (this.config.immutable) {
422
+ cacheTime = 86400 * 1000;
423
+ }
424
+
425
+ // ALWAYS set access control, as we can make urls for private buckets with getDownloadAuthorization
426
+ let desiredCorsRules = [{
427
+ corsRuleName: "allowAll",
428
+ allowedOrigins: ["https"],
429
+ allowedOperations: ["b2_download_file_by_id", "b2_download_file_by_name"],
430
+ allowedHeaders: ["range"],
431
+ exposeHeaders: ["x-bz-content-sha1"],
432
+ maxAgeSeconds: cacheTime / 1000,
433
+ }];
434
+ let bucketInfo: Record<string, unknown> = {};
435
+ if (cacheTime) {
436
+ bucketInfo["cache-control"] = `max-age=${cacheTime / 1000}`;
437
+ }
438
+
439
+
440
+ let exists = false;
441
+ try {
442
+ await api.createBucket({
443
+ bucketName: this.bucketName,
444
+ bucketType: this.config.public ? "allPublic" : "allPrivate",
445
+ lifecycleRules: [{
446
+ "daysFromUploadingToHiding": null,
447
+ // Keep files for 7 days, which should be enough time to recover accidental hiding.
448
+ "daysFromHidingToDeleting": 7,
449
+ "fileNamePrefix": ""
450
+ }],
451
+ corsRules: desiredCorsRules,
452
+ bucketInfo
453
+ });
454
+ } catch (e: any) {
455
+ if (!e.stack.includes(`"duplicate_bucket_name"`)) {
456
+ throw e;
457
+ }
458
+ exists = true;
459
+ }
460
+
461
+ let bucketList = await api.listBuckets({
462
+ bucketName: this.bucketName,
463
+ });
464
+ if (bucketList.buckets.length === 0) {
465
+ 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.`);
466
+ }
467
+ this.bucketId = bucketList.buckets[0].bucketId;
468
+
469
+ if (exists) {
470
+ let bucket = bucketList.buckets[0];
471
+ function normalize(obj: Record<string, unknown>) {
472
+ let kvps = Object.entries(obj);
473
+ sort(kvps, x => x[0]);
474
+ return Object.fromEntries(kvps);
475
+ }
476
+ function orderIndependentEqual(lhs: Record<string, unknown>, rhs: Record<string, unknown>) {
477
+ return JSON.stringify(normalize(lhs)) === JSON.stringify(normalize(rhs));
478
+ }
479
+ function orderIndependentEqualArray(lhs: unknown[], rhs: unknown[]) {
480
+ if (lhs.length !== rhs.length) return false;
481
+ for (let i = 0; i < lhs.length; i++) {
482
+ if (!orderIndependentEqual(lhs[i] as Record<string, unknown>, rhs[i] as Record<string, unknown>)) return false;
483
+ }
484
+ return true;
485
+ }
486
+ if (
487
+ !orderIndependentEqualArray(bucket.corsRules, desiredCorsRules)
488
+ || !orderIndependentEqual(bucket.bucketInfo, bucketInfo)
489
+ ) {
490
+ console.log(magenta(`Updating CORS rules for ${this.bucketName}`), bucket.corsRules, desiredCorsRules);
491
+ await api.updateBucket({
492
+ accountId: bucket.accountId,
493
+ bucketId: bucket.bucketId,
494
+ bucketType: bucket.bucketType,
495
+ lifecycleRules: bucket.lifecycleRules,
496
+ corsRules: desiredCorsRules,
497
+ bucketInfo: bucketInfo,
498
+ });
499
+ }
500
+ }
501
+ return api;
502
+ });
503
+
504
+ // Keep track of when we last reset because of a 503
505
+ private last503Reset = 0;
506
+ // IMPORTANT! We must always CATCH AROUND the apiRetryLogic, NEVER inside of fnc. Otherwise we won't
507
+ // be able to recreate the auth token.
508
+ private async apiRetryLogic<T>(
509
+ fnc: (api: B2Api) => Promise<T>,
510
+ retries = 3
511
+ ): Promise<T> {
512
+ let api = await this.getBucketAPI();
513
+ try {
514
+ return await fnc(api);
515
+ } catch (err: any) {
516
+ if (retries <= 0) throw err;
517
+
518
+ // If it's a 503 and it's been a minute since we last reset, then Wait and reset.
519
+ if (
520
+ (err.stack.includes(`"status": 503`)
521
+ || err.stack.includes(`"service_unavailable"`)
522
+ || err.stack.includes(`"internal_error"`)
523
+ || err.stack.includes(`ENOBUFS`)
524
+ ) && Date.now() - this.last503Reset > 60 * 1000) {
525
+ console.error("503 error, waiting a minute and resetting: " + err.message);
526
+ this.log("503 error, waiting a minute and resetting: " + err.message);
527
+ await delay(10 * 1000);
528
+ // We check again in case, and in the very likely case that this is being run in parallel, we only want to reset once.
529
+ if (Date.now() - this.last503Reset > 60 * 1000) {
530
+ this.log("Resetting getAPI and getBucketAPI: " + err.message);
531
+ this.last503Reset = Date.now();
532
+ getAPI.reset();
533
+ this.getBucketAPI.reset();
534
+ }
535
+ return this.apiRetryLogic(fnc, retries - 1);
536
+ }
537
+
538
+ // If the error is that the authorization token is invalid, reset getBucketAPI and getAPI
539
+ // If the error is that the bucket isn't found, reset getBucketAPI
540
+ if (err.stack.includes(`"expired_auth_token"`)) {
541
+ this.log("Authorization token expired");
542
+ getAPI.reset();
543
+ this.getBucketAPI.reset();
544
+ return this.apiRetryLogic(fnc, retries - 1);
545
+ }
546
+
547
+ if (
548
+ err.stack.includes(`no tomes available`)
549
+ || err.stack.includes(`ETIMEDOUT`)
550
+ || err.stack.includes(`socket hang up`)
551
+ // Eh... this might be bad, but... I think we just get random 400 errors. If this spams errors,
552
+ // we can remove this line.
553
+ || err.stack.includes(`400 Bad Request`)
554
+ || err.stack.includes(`getaddrinfo ENOTFOUND`)
555
+ || err.stack.includes(`ECONNRESET`)
556
+ || err.stack.includes(`ECONNREFUSED`)
557
+ || err.stack.includes(`ENOBUFS`)
558
+ ) {
559
+ console.error("Retrying in 5s: " + err.message);
560
+ this.log(err.message + " retrying in 5s");
561
+ await delay(5000);
562
+ return this.apiRetryLogic(fnc, retries - 1);
563
+ }
564
+
565
+ if (err.stack.includes(`getaddrinfo ENOTFOUND`)) {
566
+ let urlObj = new URL(api.apiUrl);
567
+ let hostname = urlObj.hostname;
568
+ let lookupAddresses = await new Promise(resolve => {
569
+ dns.lookup(hostname, (err, addresses) => {
570
+ resolve(addresses);
571
+ });
572
+ });
573
+ let resolveAddresses = await new Promise(resolve => {
574
+ dns.resolve4(hostname, (err, addresses) => {
575
+ resolve(addresses);
576
+ });
577
+ });
578
+ console.error(`getaddrinfo ENOTFOUND ${hostname}`, { lookupAddresses, resolveAddresses, apiUrl: api.apiUrl, fullError: err.stack });
579
+ }
580
+
581
+ // TODO: Handle if the bucket is deleted?
582
+ throw err;
583
+ }
584
+ }
585
+
586
+ public async get(fileName: string, config?: { range?: { start: number; end: number; }; retryCount?: number }): Promise<Buffer | undefined> {
587
+ let downloading = true;
588
+ try {
589
+ let time = Date.now();
590
+ const downloadPoll = () => {
591
+ if (!downloading) return;
592
+ this.log(`Backblaze download in progress ${fileName}`);
593
+ setTimeout(downloadPoll, 5000);
594
+ };
595
+ setTimeout(downloadPoll, 5000);
596
+ let result = await this.apiRetryLogic(async (api) => {
597
+ let range = config?.range;
598
+ if (range) {
599
+ let fileInfo = await this.getInfo(fileName);
600
+ if (!fileInfo) throw new Error(`File ${fileName} not found`);
601
+ let rangeStart = range.start;
602
+ let rangeEnd = Math.min(range.end, fileInfo.size);
603
+ // NOTE: I think if we request nothing, it confuses Backblaze and ends up giving us the entire file.
604
+ if (rangeEnd <= rangeStart) return Buffer.alloc(0);
605
+ let result = await api.downloadFileByName({
606
+ bucketName: this.bucketName,
607
+ fileName,
608
+ range: { start: rangeStart, end: rangeEnd },
609
+ });
610
+ if (result.length !== rangeEnd - rangeStart) {
611
+ let afterLength = await this.getInfo(fileName);
612
+ if (afterLength && afterLength.size >= fileInfo.size) {
613
+ 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}`);
614
+ // 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.
615
+ setTimeout(async () => {
616
+ let resultAgain = await api.downloadFileByName({
617
+ bucketName: this.bucketName,
618
+ fileName,
619
+ range: { start: rangeStart, end: rangeEnd },
620
+ });
621
+ devDebugbreak();
622
+ let didResultFixItSelf = resultAgain.length === rangeEnd - rangeStart;
623
+
624
+ console.log({ didResultFixItSelf }, resultAgain);
625
+ }, timeInMinute * 2);
626
+ }
627
+ }
628
+ }
629
+ return await api.downloadFileByName({
630
+ bucketName: this.bucketName,
631
+ fileName,
632
+ });
633
+ });
634
+ let timeStr = formatTime(Date.now() - time);
635
+ let rateStr = formatNumber(result.length / (Date.now() - time) * 1000) + "B/s";
636
+ this.log(`backblaze download (${formatNumber(result.length)}B${config?.range && `, ${formatNumber(config.range.start)} - ${formatNumber(config.range.end)}` || ""}) in ${timeStr} (${rateStr}, ${fileName})`);
637
+ return result;
638
+ } catch (e) {
639
+ this.log(`backblaze file does not exist ${fileName}`);
640
+ return undefined;
641
+ } finally {
642
+ downloading = false;
643
+ }
644
+ }
645
+ public async set(fileName: string, data: Buffer): Promise<void> {
646
+ this.log(`backblaze upload (${formatNumber(data.length)}B) ${fileName}`);
647
+ let f = fileName;
648
+ await this.apiRetryLogic(async (api) => {
649
+ await api.uploadFile({ bucketId: this.bucketId, fileName, data: data, });
650
+ });
651
+ let existsChecks = 30;
652
+ while (existsChecks > 0) {
653
+ let exists = await this.getInfo(fileName);
654
+ if (exists) break;
655
+ await delay(1000);
656
+ existsChecks--;
657
+ }
658
+ if (existsChecks === 0) {
659
+ let exists = await this.getInfo(fileName);
660
+ devDebugbreak();
661
+ 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.`);
662
+ }
663
+
664
+ }
665
+ public async append(fileName: string, data: Buffer): Promise<void> {
666
+ throw new Error(`ArchivesBackblaze does not support append. Use set instead.`);
667
+ // this.log(`backblaze append (${formatNumber(data.length)}B) ${fileName}`);
668
+ // // Backblaze doesn't have native append, so we need to get, concatenate, and set
669
+ // let existing = await this.get(fileName);
670
+ // let newData = existing ? Buffer.concat([existing, data]) : data;
671
+ // await this.set(fileName, newData);
672
+ }
673
+ public async del(fileName: string): Promise<void> {
674
+ this.log(`backblaze delete ${fileName}`);
675
+ try {
676
+ await this.apiRetryLogic(async (api) => {
677
+ await api.hideFile({ bucketId: this.bucketId, fileName: fileName });
678
+ });
679
+ } catch (e: any) {
680
+ this.log(`backblaze error in hide, possibly already hidden ${fileName}\n${e.stack}`);
681
+ }
682
+
683
+ // NOTE: Deletion SEEMS to work. This DOES break if we delete a file which keeps being recreated,
684
+ // ex, the heartbeat.
685
+ // let existsChecks = 10;
686
+ // while (existsChecks > 0) {
687
+ // let exists = await this.getInfo(fileName);
688
+ // if (!exists) break;
689
+ // await delay(1000);
690
+ // existsChecks--;
691
+ // }
692
+ // if (existsChecks === 0) {
693
+ // let exists = await this.getInfo(fileName);
694
+ // devDebugbreak();
695
+ // console.warn(`File ${fileName} was deleted, but was still found afterwards`);
696
+ // exists = await this.getInfo(fileName);
697
+ // }
698
+ }
699
+
700
+ public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined>; }): Promise<void> {
701
+
702
+ let onError: (() => Promise<void>)[] = [];
703
+ let time = Date.now();
704
+ try {
705
+ let { path } = config;
706
+ // Backblaze requires 5MB chunks. But, larger is more efficient for us.
707
+ const MIN_CHUNK_SIZE = 32 * 1024 * 1024;
708
+ let dataQueue: Buffer[] = [];
709
+ async function getNextData(): Promise<Buffer | undefined> {
710
+ if (dataQueue.length) return dataQueue.shift();
711
+ // Get buffers until we get 5MB, OR, end. Backblaze requires this for large files.
712
+ let totalBytes = 0;
713
+ let buffers: Buffer[] = [];
714
+ while (totalBytes < MIN_CHUNK_SIZE) {
715
+ let data = await config.getNextData();
716
+ if (!data) break;
717
+ totalBytes += data.length;
718
+ buffers.push(data);
719
+ }
720
+ if (!buffers.length) return undefined;
721
+ return Buffer.concat(buffers);
722
+ }
723
+
724
+ let fileName = path;
725
+ let data = await getNextData();
726
+ if (!data?.length) return;
727
+ // Backblaze disallows overly small files
728
+ if (data.length < MIN_CHUNK_SIZE) {
729
+ return await this.set(fileName, data);
730
+ }
731
+ // Backblaze disallows less than 2 chunks
732
+ let secondData = await getNextData();
733
+ if (!secondData?.length) {
734
+ return await this.set(fileName, data);
735
+ }
736
+ // ALSO, if there are two chunks, but one is too small, combine it. This helps allow us never
737
+ // send small chunks.
738
+ if (secondData.length < MIN_CHUNK_SIZE) {
739
+ return await this.set(fileName, Buffer.concat([data, secondData]));
740
+ }
741
+ this.log(`Uploading large file ${config.path}`);
742
+ dataQueue.unshift(data, secondData);
743
+
744
+
745
+ let uploadInfo = await this.apiRetryLogic(async (api) => {
746
+ return await api.startLargeFile({
747
+ bucketId: this.bucketId,
748
+ fileName: fileName,
749
+ contentType: "b2/x-auto",
750
+ fileInfo: {},
751
+ });
752
+ });
753
+ onError.push(async () => {
754
+ await this.apiRetryLogic(async (api) => {
755
+ await api.cancelLargeFile({ fileId: uploadInfo.fileId });
756
+ });
757
+ });
758
+
759
+ const LOG_INTERVAL = timeInMinute;
760
+ let nextLogTime = Date.now() + LOG_INTERVAL;
761
+
762
+ let partNumber = 1;
763
+ let partSha1Array: string[] = [];
764
+ let totalBytes = 0;
765
+ while (true) {
766
+ data = await getNextData();
767
+ if (!data) break;
768
+ // So... if the next chunk is the last one, combine it with the current one. This
769
+ // prevents ANY uploads from being < the threshold, as apparently the "last part"
770
+ // check in backblaze fails when we have to retry an upload (due to "no tomes available").
771
+ // Well it can't fail if even the last part is > 5MB, now can it!
772
+ // BUT, only if this isn't the first chunk, otherwise we might try to send
773
+ // a single chunk, which we can't do.
774
+ if (partSha1Array.length > 0) {
775
+ let maybeLastData = await getNextData();
776
+ if (maybeLastData) {
777
+ if (maybeLastData.length < MIN_CHUNK_SIZE) {
778
+ // It's the last one, so consume it now
779
+ data = Buffer.concat([data, maybeLastData]);
780
+ } else {
781
+ // It's not the last one. Put it back, in case the one AFTER is the last
782
+ // one, in which case we need to merge maybeLastData with the next next data.
783
+ dataQueue.unshift(maybeLastData);
784
+ }
785
+ }
786
+ }
787
+ let sha1 = require("crypto").createHash("sha1");
788
+ sha1.update(data);
789
+ let sha1Hex = sha1.digest("hex");
790
+ partSha1Array.push(sha1Hex);
791
+ await this.apiRetryLogic(async (api) => {
792
+ if (!data) throw new Error("Impossible, data is undefined");
793
+
794
+ let timeStr = formatTime(Date.now() - time);
795
+ let rateStr = formatNumber(totalBytes / (Date.now() - time) * 1000) + "B/s";
796
+ this.log(`Uploading large file part ${partNumber}, uploaded ${blue(formatNumber(totalBytes) + "B")} in ${blue(timeStr)} (${blue(rateStr)}). ${config.path}`);
797
+ totalBytes += data.length;
798
+
799
+ await api.uploadPart({
800
+ fileId: uploadInfo.fileId,
801
+ partNumber: partNumber,
802
+ data: data,
803
+ sha1: sha1Hex,
804
+ });
805
+ });
806
+ partNumber++;
807
+
808
+ if (Date.now() > nextLogTime) {
809
+ nextLogTime = Date.now() + LOG_INTERVAL;
810
+ let timeStr = formatTime(Date.now() - time);
811
+ let rateStr = formatNumber(totalBytes / (Date.now() - time) * 1000) + "B/s";
812
+ console.log(`Still uploading large file at ${Date.now()}. Uploaded ${formatNumber(totalBytes)}B in ${timeStr} (${rateStr}). ${config.path}`);
813
+ }
814
+ }
815
+ this.log(`Finished uploading large file uploaded ${green(formatNumber(totalBytes))}B`);
816
+
817
+ await this.apiRetryLogic(async (api) => {
818
+ await api.finishLargeFile({
819
+ fileId: uploadInfo.fileId,
820
+ partSha1Array: partSha1Array,
821
+ });
822
+ });
823
+ } catch (e: any) {
824
+ for (let c of onError) {
825
+ try {
826
+ await c();
827
+ } catch (e) {
828
+ console.error(`Error during error clean. Ignoring, we will rethrow the original error, path ${config.path}`, e);
829
+ }
830
+ }
831
+
832
+ throw new Error(`Error in setLargeFile for ${config.path}: ${e.stack}`);
833
+ }
834
+ }
835
+
836
+ public async getInfo(fileName: string): Promise<{ writeTime: number; size: number; } | undefined> {
837
+ return await this.apiRetryLogic(async (api) => {
838
+ try {
839
+ // 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.
840
+ let info = await api.listFileNames({ bucketId: this.bucketId, prefix: fileName, maxFileCount: 10 });
841
+ let file = info.files.find(x => x.fileName === fileName && x.action === "upload");
842
+ if (!file) {
843
+ this.log(`Backblaze file not exists ${fileName}`);
844
+ return undefined;
845
+ }
846
+ this.log(`Backblaze file exists ${fileName}`);
847
+ return {
848
+ writeTime: file.uploadTimestamp,
849
+ size: file.contentLength,
850
+ };
851
+ } catch (e: any) {
852
+ if (e.stack.includes(`file_not_found`)) {
853
+ this.log(`Backblaze file not exists ${fileName}`);
854
+ return undefined;
855
+ }
856
+ throw e;
857
+ }
858
+ });
859
+ }
860
+
861
+ // For example findFileNames("ips/")
862
+ public async find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]> {
863
+ let result = await this.findInfo(prefix, config);
864
+ return result.map(x => x.path);
865
+ }
866
+ public async findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<{ path: string; createTime: number; size: number; }[]> {
867
+ return await this.apiRetryLogic(async (api) => {
868
+ if (!config?.shallow && config?.type === "folders") {
869
+ let allFiles = await this.findInfo(prefix);
870
+ let allFolders = new Map<string, { path: string; createTime: number; size: number }>();
871
+ for (let { path, createTime, size } of allFiles) {
872
+ let folder = path.split("/").slice(0, -1).join("/");
873
+ if (!folder) continue;
874
+ allFolders.set(folder, { path: folder, createTime, size });
875
+ }
876
+ return Array.from(allFolders.values());
877
+ }
878
+ let files = new Map<string, { path: string; createTime: number; size: number; }>();
879
+ let startFileName = "";
880
+ while (true) {
881
+ let result = await api.listFileNames({
882
+ bucketId: this.bucketId,
883
+ prefix: prefix,
884
+ startFileName,
885
+ maxFileCount: 1000,
886
+ delimiter: config?.shallow ? "/" : undefined,
887
+ });
888
+ for (let file of result.files) {
889
+ if (file.action === "upload" && config?.type !== "folders") {
890
+ files.set(file.fileName, { path: file.fileName, createTime: file.uploadTimestamp, size: file.contentLength });
891
+ } else if (file.action === "folder" && config?.type === "folders") {
892
+ let folder = file.fileName;
893
+ if (folder.endsWith("/")) {
894
+ folder = folder.slice(0, -1);
895
+ }
896
+ files.set(folder, { path: folder, createTime: file.uploadTimestamp, size: file.contentLength });
897
+ }
898
+
899
+ }
900
+ startFileName = result.nextFileName;
901
+ if (!startFileName) break;
902
+ }
903
+ return Array.from(files.values());
904
+ });
905
+ }
906
+
907
+ public async assertPathValid(path: string) {
908
+ let bytes = Buffer.from(path, "utf8");
909
+ if (bytes.length > 1000) {
910
+ throw new Error(`Path too long: ${path.length} characters > 1000 characters. Path: ${path}`);
911
+ }
912
+ }
913
+
914
+ public async move(config: {
915
+ path: string;
916
+ target: Archives;
917
+ targetPath: string;
918
+ copyInstead?: boolean;
919
+ }) {
920
+ let { path, target, targetPath } = config;
921
+ let base = target.getBaseArchives?.();
922
+ if (base) {
923
+ target = base.archives;
924
+ targetPath = base.parentPath + targetPath;
925
+ }
926
+ // A self move should NOOP (and definitely not copy, and then delete itself!)
927
+ if (target === this && path === targetPath) {
928
+ this.log(`Backblaze move path to itself. Skipping move, as there is no work to do. ${path}`);
929
+ return;
930
+ }
931
+ if (target instanceof ArchivesBackblaze) {
932
+ let targetBucketId = target.bucketId;
933
+ if (targetBucketId === this.bucketId && path === targetPath) return;
934
+ await this.apiRetryLogic(async (api) => {
935
+ // Ugh... listing the file name sucks, but... I guess it's still better than
936
+ // downloading and re-uploading the entire file.
937
+ let info = await api.listFileNames({ bucketId: this.bucketId, prefix: path, maxFileCount: 10 });
938
+ let file = info.files.find(x => x.fileName === path);
939
+ if (!file) throw new Error(`File not found to move: ${path}`);
940
+ await api.copyFile({
941
+ sourceFileId: file.fileId,
942
+ fileName: targetPath,
943
+ destinationBucketId: targetBucketId,
944
+ });
945
+ });
946
+ } else {
947
+ let data = await this.get(path);
948
+ if (!data) throw new Error(`File not found to move: ${path}`);
949
+ await target.set(targetPath, data);
950
+ }
951
+
952
+ if (!config.copyInstead) {
953
+ let exists = await this.getInfo(targetPath);
954
+ if (!exists) {
955
+ debugbreak(2);
956
+ debugger;
957
+ console.error(`File not found after move. Leaving BOTH files. ${targetPath} was not found. Being moved from ${path}`);
958
+ } else {
959
+ await this.del(path);
960
+ }
961
+ }
962
+ }
963
+
964
+ public async copy(config: {
965
+ path: string;
966
+ target: Archives;
967
+ targetPath: string;
968
+ }): Promise<void> {
969
+ return this.move({ ...config, copyInstead: true });
970
+ }
971
+
972
+ public async getURL(path: string) {
973
+ return await this.apiRetryLogic(async (api) => {
974
+ if (path.startsWith("/")) {
975
+ path = path.slice(1);
976
+ }
977
+ return await api.getDownloadURL("file/" + this.bucketName + "/" + path);
978
+ });
979
+ }
980
+
981
+ public async getDownloadAuthorization(config: {
982
+ fileNamePrefix?: string;
983
+ validDurationInSeconds: number;
984
+ b2ContentDisposition?: string;
985
+ b2ContentLanguage?: string;
986
+ b2Expires?: string;
987
+ b2CacheControl?: string;
988
+ b2ContentEncoding?: string;
989
+ b2ContentType?: string;
990
+ }): Promise<{
991
+ bucketId: string;
992
+ fileNamePrefix: string;
993
+ authorizationToken: string;
994
+ }> {
995
+ return await this.apiRetryLogic(async (api) => {
996
+ return await api.getDownloadAuthorization({
997
+ bucketId: this.bucketId,
998
+ fileNamePrefix: config.fileNamePrefix ?? "",
999
+ ...config,
1000
+ });
1001
+ });
1002
+ }
1003
+ }
1004
+
1005
+ /*
1006
+ Names should be a UTF-8 string up to 1024 bytes with the following exceptions:
1007
+ Character codes below 32 are not allowed.
1008
+ DEL characters (127) are not allowed.
1009
+ Backslashes are not allowed.
1010
+ File names cannot start with /, end with /, or contain //.
1011
+ */
1012
+
1013
+
1014
+ export const getArchivesBackblaze = cache((domain: string) => {
1015
+ return new ArchivesBackblaze({ bucketName: domain });
1016
+ });
1017
+ export const getArchivesBackblazePrivateImmutable = cache((domain: string) => {
1018
+ return new ArchivesBackblaze({
1019
+ bucketName: domain + "-private-immutable",
1020
+ immutable: true
1021
+ });
1022
+ });
1023
+ export const getArchivesBackblazePublicImmutable = cache((domain: string) => {
1024
+ return new ArchivesBackblaze({
1025
+ bucketName: domain + "-public-immutable",
1026
+ public: true,
1027
+ immutable: true
1028
+ });
1029
+ });
1030
+
1031
+ // NOTE: Cache by a minute. This might be a bad idea, but... usually whole reason for public is
1032
+ // for cloudflare caching (as otherwise we can just access it through a server), or for large files
1033
+ // (which should be cached anyways, and probably even use immutable caching).
1034
+ export const getArchivesBackblazePublic = cache((domain: string) => {
1035
+ return new ArchivesBackblaze({
1036
+ bucketName: domain + "-public",
1037
+ public: true,
1038
+ cacheTime: timeInMinute,
1039
+ });
1040
1040
  });