@chidchanun/bcp 0.1.24 → 0.1.26
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/README.md +587 -202
- package/docs/README.md +278 -86
- package/docs/file-upload.md +194 -32
- package/docs/releases/0.1.25.md +132 -0
- package/docs/releases/0.1.26.md +281 -0
- package/docs/s3-storage.md +276 -0
- package/docs/storage.md +401 -0
- package/package.json +5 -2
- package/packages/client/src/database.mjs +236 -0
- package/packages/client/src/server.ts +46 -0
- package/packages/server/src/file-delivery.ts +613 -0
- package/packages/server/src/storage-s3.ts +1159 -0
- package/packages/server/src/storage.ts +1515 -0
- package/packages/server/src/upload-stream.ts +846 -0
|
@@ -0,0 +1,1515 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHash,
|
|
3
|
+
randomUUID,
|
|
4
|
+
} from "node:crypto";
|
|
5
|
+
import {
|
|
6
|
+
mkdir,
|
|
7
|
+
open,
|
|
8
|
+
readFile,
|
|
9
|
+
rm,
|
|
10
|
+
stat,
|
|
11
|
+
unlink,
|
|
12
|
+
writeFile,
|
|
13
|
+
} from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
validateUploadedFile,
|
|
18
|
+
type UploadConstraints,
|
|
19
|
+
} from "./upload.js";
|
|
20
|
+
|
|
21
|
+
const METADATA_DIRECTORY =
|
|
22
|
+
".bcp-storage-meta";
|
|
23
|
+
const STREAM_CHUNK_BYTES =
|
|
24
|
+
64 * 1024;
|
|
25
|
+
|
|
26
|
+
export type StorageErrorCode =
|
|
27
|
+
| "INVALID_KEY"
|
|
28
|
+
| "OBJECT_NOT_FOUND"
|
|
29
|
+
| "OBJECT_EXISTS"
|
|
30
|
+
| "RANGE_NOT_SATISFIABLE"
|
|
31
|
+
| "STREAM_TOO_LARGE";
|
|
32
|
+
|
|
33
|
+
export class StorageError extends Error {
|
|
34
|
+
readonly code:
|
|
35
|
+
StorageErrorCode;
|
|
36
|
+
readonly status:
|
|
37
|
+
number;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
code: StorageErrorCode,
|
|
41
|
+
message: string,
|
|
42
|
+
status: number
|
|
43
|
+
) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name =
|
|
46
|
+
"StorageError";
|
|
47
|
+
this.code =
|
|
48
|
+
code;
|
|
49
|
+
this.status =
|
|
50
|
+
status;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface StorageObjectMetadata {
|
|
55
|
+
key: string;
|
|
56
|
+
size: number;
|
|
57
|
+
contentType: string;
|
|
58
|
+
lastModified: Date;
|
|
59
|
+
etag: string;
|
|
60
|
+
checksumSha256?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface StoragePutOptions {
|
|
64
|
+
contentType?: string;
|
|
65
|
+
overwrite?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface StoragePutStreamOptions
|
|
69
|
+
extends StoragePutOptions {
|
|
70
|
+
maxBytes?: number;
|
|
71
|
+
signal?: AbortSignal;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface StorageReadOptions {
|
|
75
|
+
start?: number;
|
|
76
|
+
end?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface StorageAdapterCapabilities {
|
|
80
|
+
streamingRead: boolean;
|
|
81
|
+
streamingWrite: boolean;
|
|
82
|
+
ranges: boolean;
|
|
83
|
+
signedUrls: boolean;
|
|
84
|
+
listing: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type StorageReadableStream =
|
|
88
|
+
ReadableStream<
|
|
89
|
+
Uint8Array<ArrayBuffer>
|
|
90
|
+
>;
|
|
91
|
+
|
|
92
|
+
export interface StorageAdapter {
|
|
93
|
+
capabilities?:
|
|
94
|
+
Partial<
|
|
95
|
+
StorageAdapterCapabilities
|
|
96
|
+
>;
|
|
97
|
+
|
|
98
|
+
put(
|
|
99
|
+
key: string,
|
|
100
|
+
value: StorageWriteValue,
|
|
101
|
+
options?: StoragePutOptions
|
|
102
|
+
): Promise<StorageObjectMetadata>;
|
|
103
|
+
|
|
104
|
+
putStream?(
|
|
105
|
+
key: string,
|
|
106
|
+
stream:
|
|
107
|
+
ReadableStream<Uint8Array>,
|
|
108
|
+
options?: StoragePutStreamOptions
|
|
109
|
+
): Promise<StorageObjectMetadata>;
|
|
110
|
+
|
|
111
|
+
stat(
|
|
112
|
+
key: string
|
|
113
|
+
): Promise<StorageObjectMetadata | null>;
|
|
114
|
+
|
|
115
|
+
read(
|
|
116
|
+
key: string,
|
|
117
|
+
options?: StorageReadOptions
|
|
118
|
+
): Promise<Uint8Array>;
|
|
119
|
+
|
|
120
|
+
readStream?(
|
|
121
|
+
key: string,
|
|
122
|
+
options?: StorageReadOptions
|
|
123
|
+
): Promise<StorageReadableStream>;
|
|
124
|
+
|
|
125
|
+
exists(
|
|
126
|
+
key: string
|
|
127
|
+
): Promise<boolean>;
|
|
128
|
+
|
|
129
|
+
delete(
|
|
130
|
+
key: string
|
|
131
|
+
): Promise<boolean>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type StorageWriteValue =
|
|
135
|
+
| string
|
|
136
|
+
| Blob
|
|
137
|
+
| ArrayBuffer
|
|
138
|
+
| ArrayBufferView;
|
|
139
|
+
|
|
140
|
+
export interface LocalStorageOptions {
|
|
141
|
+
directory: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface StoreUploadedFileOptions {
|
|
145
|
+
storage: StorageAdapter;
|
|
146
|
+
key?: string;
|
|
147
|
+
overwrite?: boolean;
|
|
148
|
+
constraints?: UploadConstraints;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface StoredUploadedObject
|
|
152
|
+
extends StorageObjectMetadata {
|
|
153
|
+
originalName: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface LocalMetadataRecord {
|
|
157
|
+
version: 1;
|
|
158
|
+
key: string;
|
|
159
|
+
contentType: string;
|
|
160
|
+
checksumSha256: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function createLocalStorage(
|
|
164
|
+
options: LocalStorageOptions
|
|
165
|
+
): StorageAdapter {
|
|
166
|
+
const rootDirectory =
|
|
167
|
+
path.resolve(
|
|
168
|
+
assertNonEmptyString(
|
|
169
|
+
options.directory,
|
|
170
|
+
"directory"
|
|
171
|
+
)
|
|
172
|
+
);
|
|
173
|
+
const metadataDirectory =
|
|
174
|
+
path.join(
|
|
175
|
+
rootDirectory,
|
|
176
|
+
METADATA_DIRECTORY
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const statObject =
|
|
180
|
+
async (
|
|
181
|
+
key: string
|
|
182
|
+
): Promise<StorageObjectMetadata | null> => {
|
|
183
|
+
const normalizedKey =
|
|
184
|
+
normalizeStorageKey(
|
|
185
|
+
key
|
|
186
|
+
);
|
|
187
|
+
const destination =
|
|
188
|
+
resolveStoragePath(
|
|
189
|
+
rootDirectory,
|
|
190
|
+
normalizedKey
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
let fileStat;
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
fileStat =
|
|
197
|
+
await stat(
|
|
198
|
+
destination
|
|
199
|
+
);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (
|
|
202
|
+
isNodeErrorCode(
|
|
203
|
+
error,
|
|
204
|
+
"ENOENT"
|
|
205
|
+
)
|
|
206
|
+
) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!fileStat.isFile()) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const record =
|
|
218
|
+
await readMetadataRecord(
|
|
219
|
+
metadataDirectory,
|
|
220
|
+
normalizedKey
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
return createObjectMetadata(
|
|
224
|
+
normalizedKey,
|
|
225
|
+
fileStat.size,
|
|
226
|
+
fileStat.mtime,
|
|
227
|
+
record?.contentType ??
|
|
228
|
+
"application/octet-stream",
|
|
229
|
+
record?.checksumSha256
|
|
230
|
+
);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const putStream =
|
|
234
|
+
async (
|
|
235
|
+
key: string,
|
|
236
|
+
stream:
|
|
237
|
+
ReadableStream<Uint8Array>,
|
|
238
|
+
putOptions:
|
|
239
|
+
StoragePutStreamOptions = {}
|
|
240
|
+
): Promise<StorageObjectMetadata> => {
|
|
241
|
+
const normalizedKey =
|
|
242
|
+
normalizeStorageKey(
|
|
243
|
+
key
|
|
244
|
+
);
|
|
245
|
+
const destination =
|
|
246
|
+
resolveStoragePath(
|
|
247
|
+
rootDirectory,
|
|
248
|
+
normalizedKey
|
|
249
|
+
);
|
|
250
|
+
const contentType =
|
|
251
|
+
normalizeContentType(
|
|
252
|
+
putOptions.contentType ??
|
|
253
|
+
""
|
|
254
|
+
);
|
|
255
|
+
const maxBytes =
|
|
256
|
+
normalizeOptionalByteLimit(
|
|
257
|
+
putOptions.maxBytes,
|
|
258
|
+
"stream maxBytes"
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
await mkdir(
|
|
262
|
+
path.dirname(
|
|
263
|
+
destination
|
|
264
|
+
),
|
|
265
|
+
{
|
|
266
|
+
recursive: true,
|
|
267
|
+
}
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const reader =
|
|
271
|
+
stream.getReader();
|
|
272
|
+
let handle:
|
|
273
|
+
Awaited<
|
|
274
|
+
ReturnType<typeof open>
|
|
275
|
+
> | null =
|
|
276
|
+
null;
|
|
277
|
+
let destinationOpened =
|
|
278
|
+
false;
|
|
279
|
+
let completed =
|
|
280
|
+
false;
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
putOptions.signal
|
|
284
|
+
?.throwIfAborted();
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
handle =
|
|
288
|
+
await open(
|
|
289
|
+
destination,
|
|
290
|
+
putOptions.overwrite
|
|
291
|
+
? "w"
|
|
292
|
+
: "wx"
|
|
293
|
+
);
|
|
294
|
+
destinationOpened =
|
|
295
|
+
true;
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (
|
|
298
|
+
isNodeErrorCode(
|
|
299
|
+
error,
|
|
300
|
+
"EEXIST"
|
|
301
|
+
)
|
|
302
|
+
) {
|
|
303
|
+
throw new StorageError(
|
|
304
|
+
"OBJECT_EXISTS",
|
|
305
|
+
`BCP Framework: storage object "${normalizedKey}" already exists.`,
|
|
306
|
+
409
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const hash =
|
|
314
|
+
createHash(
|
|
315
|
+
"sha256"
|
|
316
|
+
);
|
|
317
|
+
let totalBytes =
|
|
318
|
+
0;
|
|
319
|
+
|
|
320
|
+
while (true) {
|
|
321
|
+
putOptions.signal
|
|
322
|
+
?.throwIfAborted();
|
|
323
|
+
|
|
324
|
+
const result =
|
|
325
|
+
await reader.read();
|
|
326
|
+
|
|
327
|
+
if (result.done) {
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const chunk =
|
|
332
|
+
result.value;
|
|
333
|
+
|
|
334
|
+
if (
|
|
335
|
+
!(chunk instanceof
|
|
336
|
+
Uint8Array)
|
|
337
|
+
) {
|
|
338
|
+
throw new TypeError(
|
|
339
|
+
"BCP Framework: storage streams must emit Uint8Array chunks."
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const nextTotal =
|
|
344
|
+
totalBytes +
|
|
345
|
+
chunk.byteLength;
|
|
346
|
+
|
|
347
|
+
if (
|
|
348
|
+
maxBytes !== undefined &&
|
|
349
|
+
nextTotal > maxBytes
|
|
350
|
+
) {
|
|
351
|
+
throw new StorageError(
|
|
352
|
+
"STREAM_TOO_LARGE",
|
|
353
|
+
`BCP Framework: storage stream exceeded ${maxBytes} bytes.`,
|
|
354
|
+
413
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
await writeAll(
|
|
359
|
+
handle,
|
|
360
|
+
chunk
|
|
361
|
+
);
|
|
362
|
+
hash.update(
|
|
363
|
+
chunk
|
|
364
|
+
);
|
|
365
|
+
totalBytes =
|
|
366
|
+
nextTotal;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
await handle.close();
|
|
370
|
+
handle =
|
|
371
|
+
null;
|
|
372
|
+
|
|
373
|
+
const checksumSha256 =
|
|
374
|
+
hash.digest(
|
|
375
|
+
"hex"
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
await writeMetadataRecord(
|
|
379
|
+
metadataDirectory,
|
|
380
|
+
{
|
|
381
|
+
version: 1,
|
|
382
|
+
key:
|
|
383
|
+
normalizedKey,
|
|
384
|
+
contentType,
|
|
385
|
+
checksumSha256,
|
|
386
|
+
}
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
const fileStat =
|
|
390
|
+
await stat(
|
|
391
|
+
destination
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
completed =
|
|
395
|
+
true;
|
|
396
|
+
|
|
397
|
+
return createObjectMetadata(
|
|
398
|
+
normalizedKey,
|
|
399
|
+
fileStat.size,
|
|
400
|
+
fileStat.mtime,
|
|
401
|
+
contentType,
|
|
402
|
+
checksumSha256
|
|
403
|
+
);
|
|
404
|
+
} catch (error) {
|
|
405
|
+
try {
|
|
406
|
+
await reader.cancel(
|
|
407
|
+
error
|
|
408
|
+
);
|
|
409
|
+
} catch {
|
|
410
|
+
// Ignore source cancellation failures.
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
throw error;
|
|
414
|
+
} finally {
|
|
415
|
+
reader.releaseLock();
|
|
416
|
+
|
|
417
|
+
if (handle) {
|
|
418
|
+
try {
|
|
419
|
+
await handle.close();
|
|
420
|
+
} catch {
|
|
421
|
+
// Preserve the original failure.
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (
|
|
426
|
+
destinationOpened &&
|
|
427
|
+
!completed
|
|
428
|
+
) {
|
|
429
|
+
await rm(
|
|
430
|
+
destination,
|
|
431
|
+
{
|
|
432
|
+
force: true,
|
|
433
|
+
}
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const readStream =
|
|
440
|
+
async (
|
|
441
|
+
key: string,
|
|
442
|
+
readOptions:
|
|
443
|
+
StorageReadOptions = {}
|
|
444
|
+
): Promise<StorageReadableStream> => {
|
|
445
|
+
const normalizedKey =
|
|
446
|
+
normalizeStorageKey(
|
|
447
|
+
key
|
|
448
|
+
);
|
|
449
|
+
const destination =
|
|
450
|
+
resolveStoragePath(
|
|
451
|
+
rootDirectory,
|
|
452
|
+
normalizedKey
|
|
453
|
+
);
|
|
454
|
+
const metadata =
|
|
455
|
+
await statObject(
|
|
456
|
+
normalizedKey
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
if (!metadata) {
|
|
460
|
+
throw new StorageError(
|
|
461
|
+
"OBJECT_NOT_FOUND",
|
|
462
|
+
`BCP Framework: storage object "${normalizedKey}" was not found.`,
|
|
463
|
+
404
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const range =
|
|
468
|
+
normalizeReadRange(
|
|
469
|
+
metadata.size,
|
|
470
|
+
readOptions
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
if (
|
|
474
|
+
metadata.size === 0 &&
|
|
475
|
+
range === null
|
|
476
|
+
) {
|
|
477
|
+
return new ReadableStream<
|
|
478
|
+
Uint8Array<ArrayBuffer>
|
|
479
|
+
>({
|
|
480
|
+
start(controller) {
|
|
481
|
+
controller.close();
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const start =
|
|
487
|
+
range?.start ??
|
|
488
|
+
0;
|
|
489
|
+
const end =
|
|
490
|
+
range?.end ??
|
|
491
|
+
metadata.size - 1;
|
|
492
|
+
const handle =
|
|
493
|
+
await open(
|
|
494
|
+
destination,
|
|
495
|
+
"r"
|
|
496
|
+
);
|
|
497
|
+
let position =
|
|
498
|
+
start;
|
|
499
|
+
let closed =
|
|
500
|
+
false;
|
|
501
|
+
|
|
502
|
+
const closeHandle =
|
|
503
|
+
async () => {
|
|
504
|
+
if (closed) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
closed =
|
|
509
|
+
true;
|
|
510
|
+
await handle.close();
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
return new ReadableStream<
|
|
514
|
+
Uint8Array<ArrayBuffer>
|
|
515
|
+
>({
|
|
516
|
+
async pull(controller) {
|
|
517
|
+
try {
|
|
518
|
+
if (
|
|
519
|
+
position > end
|
|
520
|
+
) {
|
|
521
|
+
await closeHandle();
|
|
522
|
+
controller.close();
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const remaining =
|
|
527
|
+
end -
|
|
528
|
+
position +
|
|
529
|
+
1;
|
|
530
|
+
const requested =
|
|
531
|
+
Math.min(
|
|
532
|
+
STREAM_CHUNK_BYTES,
|
|
533
|
+
remaining
|
|
534
|
+
);
|
|
535
|
+
const buffer =
|
|
536
|
+
Buffer.allocUnsafe(
|
|
537
|
+
requested
|
|
538
|
+
);
|
|
539
|
+
const result =
|
|
540
|
+
await handle.read(
|
|
541
|
+
buffer,
|
|
542
|
+
0,
|
|
543
|
+
requested,
|
|
544
|
+
position
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
if (
|
|
548
|
+
result.bytesRead === 0
|
|
549
|
+
) {
|
|
550
|
+
await closeHandle();
|
|
551
|
+
controller.close();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const chunk =
|
|
556
|
+
new Uint8Array(
|
|
557
|
+
result.bytesRead
|
|
558
|
+
);
|
|
559
|
+
chunk.set(
|
|
560
|
+
buffer.subarray(
|
|
561
|
+
0,
|
|
562
|
+
result.bytesRead
|
|
563
|
+
)
|
|
564
|
+
);
|
|
565
|
+
position +=
|
|
566
|
+
result.bytesRead;
|
|
567
|
+
|
|
568
|
+
controller.enqueue(
|
|
569
|
+
chunk
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
if (
|
|
573
|
+
position > end
|
|
574
|
+
) {
|
|
575
|
+
await closeHandle();
|
|
576
|
+
controller.close();
|
|
577
|
+
}
|
|
578
|
+
} catch (error) {
|
|
579
|
+
try {
|
|
580
|
+
await closeHandle();
|
|
581
|
+
} catch {
|
|
582
|
+
// Preserve the read failure.
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
controller.error(
|
|
586
|
+
error
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
|
|
591
|
+
async cancel() {
|
|
592
|
+
await closeHandle();
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
return {
|
|
598
|
+
capabilities: {
|
|
599
|
+
streamingRead: true,
|
|
600
|
+
streamingWrite: true,
|
|
601
|
+
ranges: true,
|
|
602
|
+
signedUrls: false,
|
|
603
|
+
listing: false,
|
|
604
|
+
},
|
|
605
|
+
|
|
606
|
+
async put(
|
|
607
|
+
key,
|
|
608
|
+
value,
|
|
609
|
+
putOptions = {}
|
|
610
|
+
) {
|
|
611
|
+
const normalizedKey =
|
|
612
|
+
normalizeStorageKey(
|
|
613
|
+
key
|
|
614
|
+
);
|
|
615
|
+
const destination =
|
|
616
|
+
resolveStoragePath(
|
|
617
|
+
rootDirectory,
|
|
618
|
+
normalizedKey
|
|
619
|
+
);
|
|
620
|
+
const bytes =
|
|
621
|
+
await toStorageBytes(
|
|
622
|
+
value
|
|
623
|
+
);
|
|
624
|
+
const checksumSha256 =
|
|
625
|
+
createHash(
|
|
626
|
+
"sha256"
|
|
627
|
+
)
|
|
628
|
+
.update(bytes)
|
|
629
|
+
.digest("hex");
|
|
630
|
+
const contentType =
|
|
631
|
+
normalizeContentType(
|
|
632
|
+
putOptions.contentType ??
|
|
633
|
+
(
|
|
634
|
+
value instanceof Blob
|
|
635
|
+
? value.type
|
|
636
|
+
: ""
|
|
637
|
+
)
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
await mkdir(
|
|
641
|
+
path.dirname(
|
|
642
|
+
destination
|
|
643
|
+
),
|
|
644
|
+
{
|
|
645
|
+
recursive: true,
|
|
646
|
+
}
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
try {
|
|
650
|
+
await writeFile(
|
|
651
|
+
destination,
|
|
652
|
+
bytes,
|
|
653
|
+
{
|
|
654
|
+
flag:
|
|
655
|
+
putOptions.overwrite
|
|
656
|
+
? "w"
|
|
657
|
+
: "wx",
|
|
658
|
+
}
|
|
659
|
+
);
|
|
660
|
+
} catch (error) {
|
|
661
|
+
if (
|
|
662
|
+
isNodeErrorCode(
|
|
663
|
+
error,
|
|
664
|
+
"EEXIST"
|
|
665
|
+
)
|
|
666
|
+
) {
|
|
667
|
+
throw new StorageError(
|
|
668
|
+
"OBJECT_EXISTS",
|
|
669
|
+
`BCP Framework: storage object "${normalizedKey}" already exists.`,
|
|
670
|
+
409
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
throw error;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
await writeMetadataRecord(
|
|
678
|
+
metadataDirectory,
|
|
679
|
+
{
|
|
680
|
+
version: 1,
|
|
681
|
+
key:
|
|
682
|
+
normalizedKey,
|
|
683
|
+
contentType,
|
|
684
|
+
checksumSha256,
|
|
685
|
+
}
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
const fileStat =
|
|
689
|
+
await stat(
|
|
690
|
+
destination
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
return createObjectMetadata(
|
|
694
|
+
normalizedKey,
|
|
695
|
+
fileStat.size,
|
|
696
|
+
fileStat.mtime,
|
|
697
|
+
contentType,
|
|
698
|
+
checksumSha256
|
|
699
|
+
);
|
|
700
|
+
},
|
|
701
|
+
|
|
702
|
+
putStream,
|
|
703
|
+
|
|
704
|
+
stat:
|
|
705
|
+
statObject,
|
|
706
|
+
|
|
707
|
+
async read(
|
|
708
|
+
key,
|
|
709
|
+
readOptions = {}
|
|
710
|
+
) {
|
|
711
|
+
const normalizedKey =
|
|
712
|
+
normalizeStorageKey(
|
|
713
|
+
key
|
|
714
|
+
);
|
|
715
|
+
const destination =
|
|
716
|
+
resolveStoragePath(
|
|
717
|
+
rootDirectory,
|
|
718
|
+
normalizedKey
|
|
719
|
+
);
|
|
720
|
+
const metadata =
|
|
721
|
+
await statObject(
|
|
722
|
+
normalizedKey
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
if (!metadata) {
|
|
726
|
+
throw new StorageError(
|
|
727
|
+
"OBJECT_NOT_FOUND",
|
|
728
|
+
`BCP Framework: storage object "${normalizedKey}" was not found.`,
|
|
729
|
+
404
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const range =
|
|
734
|
+
normalizeReadRange(
|
|
735
|
+
metadata.size,
|
|
736
|
+
readOptions
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
if (!range) {
|
|
740
|
+
return Uint8Array.from(
|
|
741
|
+
await readFile(
|
|
742
|
+
destination
|
|
743
|
+
)
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const length =
|
|
748
|
+
range.end -
|
|
749
|
+
range.start +
|
|
750
|
+
1;
|
|
751
|
+
const handle =
|
|
752
|
+
await open(
|
|
753
|
+
destination,
|
|
754
|
+
"r"
|
|
755
|
+
);
|
|
756
|
+
|
|
757
|
+
try {
|
|
758
|
+
const buffer =
|
|
759
|
+
Buffer.allocUnsafe(
|
|
760
|
+
length
|
|
761
|
+
);
|
|
762
|
+
const result =
|
|
763
|
+
await handle.read(
|
|
764
|
+
buffer,
|
|
765
|
+
0,
|
|
766
|
+
length,
|
|
767
|
+
range.start
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
return Uint8Array.from(
|
|
771
|
+
buffer.subarray(
|
|
772
|
+
0,
|
|
773
|
+
result.bytesRead
|
|
774
|
+
)
|
|
775
|
+
);
|
|
776
|
+
} finally {
|
|
777
|
+
await handle.close();
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
|
|
781
|
+
readStream,
|
|
782
|
+
|
|
783
|
+
async exists(key) {
|
|
784
|
+
return (
|
|
785
|
+
await statObject(
|
|
786
|
+
key
|
|
787
|
+
)
|
|
788
|
+
) !== null;
|
|
789
|
+
},
|
|
790
|
+
|
|
791
|
+
async delete(key) {
|
|
792
|
+
const normalizedKey =
|
|
793
|
+
normalizeStorageKey(
|
|
794
|
+
key
|
|
795
|
+
);
|
|
796
|
+
const destination =
|
|
797
|
+
resolveStoragePath(
|
|
798
|
+
rootDirectory,
|
|
799
|
+
normalizedKey
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
try {
|
|
803
|
+
await unlink(
|
|
804
|
+
destination
|
|
805
|
+
);
|
|
806
|
+
} catch (error) {
|
|
807
|
+
if (
|
|
808
|
+
isNodeErrorCode(
|
|
809
|
+
error,
|
|
810
|
+
"ENOENT"
|
|
811
|
+
)
|
|
812
|
+
) {
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
throw error;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
await rm(
|
|
820
|
+
metadataFilePath(
|
|
821
|
+
metadataDirectory,
|
|
822
|
+
normalizedKey
|
|
823
|
+
),
|
|
824
|
+
{
|
|
825
|
+
force: true,
|
|
826
|
+
}
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
return true;
|
|
830
|
+
},
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
export function getStorageCapabilities(
|
|
835
|
+
storage: StorageAdapter
|
|
836
|
+
): StorageAdapterCapabilities {
|
|
837
|
+
return {
|
|
838
|
+
streamingRead:
|
|
839
|
+
storage.capabilities
|
|
840
|
+
?.streamingRead ??
|
|
841
|
+
typeof storage.readStream ===
|
|
842
|
+
"function",
|
|
843
|
+
streamingWrite:
|
|
844
|
+
storage.capabilities
|
|
845
|
+
?.streamingWrite ??
|
|
846
|
+
typeof storage.putStream ===
|
|
847
|
+
"function",
|
|
848
|
+
ranges:
|
|
849
|
+
storage.capabilities
|
|
850
|
+
?.ranges ??
|
|
851
|
+
true,
|
|
852
|
+
signedUrls:
|
|
853
|
+
storage.capabilities
|
|
854
|
+
?.signedUrls ??
|
|
855
|
+
false,
|
|
856
|
+
listing:
|
|
857
|
+
storage.capabilities
|
|
858
|
+
?.listing ??
|
|
859
|
+
false,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
export async function putStorageStream(
|
|
864
|
+
storage: StorageAdapter,
|
|
865
|
+
key: string,
|
|
866
|
+
stream:
|
|
867
|
+
ReadableStream<Uint8Array>,
|
|
868
|
+
options:
|
|
869
|
+
StoragePutStreamOptions = {}
|
|
870
|
+
): Promise<StorageObjectMetadata> {
|
|
871
|
+
if (storage.putStream) {
|
|
872
|
+
return storage.putStream(
|
|
873
|
+
key,
|
|
874
|
+
stream,
|
|
875
|
+
options
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const bytes =
|
|
880
|
+
await collectStorageStream(
|
|
881
|
+
stream,
|
|
882
|
+
options
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
return storage.put(
|
|
886
|
+
key,
|
|
887
|
+
bytes,
|
|
888
|
+
{
|
|
889
|
+
contentType:
|
|
890
|
+
options.contentType,
|
|
891
|
+
overwrite:
|
|
892
|
+
options.overwrite,
|
|
893
|
+
}
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
export async function readStorageStream(
|
|
898
|
+
storage: StorageAdapter,
|
|
899
|
+
key: string,
|
|
900
|
+
options:
|
|
901
|
+
StorageReadOptions = {}
|
|
902
|
+
): Promise<StorageReadableStream> {
|
|
903
|
+
if (storage.readStream) {
|
|
904
|
+
return storage.readStream(
|
|
905
|
+
key,
|
|
906
|
+
options
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const bytes =
|
|
911
|
+
await storage.read(
|
|
912
|
+
key,
|
|
913
|
+
options
|
|
914
|
+
);
|
|
915
|
+
const chunk =
|
|
916
|
+
new Uint8Array(
|
|
917
|
+
bytes.byteLength
|
|
918
|
+
);
|
|
919
|
+
|
|
920
|
+
chunk.set(
|
|
921
|
+
bytes
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
return new ReadableStream<
|
|
925
|
+
Uint8Array<ArrayBuffer>
|
|
926
|
+
>({
|
|
927
|
+
start(controller) {
|
|
928
|
+
if (
|
|
929
|
+
chunk.byteLength > 0
|
|
930
|
+
) {
|
|
931
|
+
controller.enqueue(
|
|
932
|
+
chunk
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
controller.close();
|
|
937
|
+
},
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
export async function storeUploadedFile(
|
|
942
|
+
file: File,
|
|
943
|
+
options: StoreUploadedFileOptions
|
|
944
|
+
): Promise<StoredUploadedObject> {
|
|
945
|
+
validateUploadedFile(
|
|
946
|
+
file,
|
|
947
|
+
options.constraints
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
const key =
|
|
951
|
+
options.key === undefined
|
|
952
|
+
? createUploadStorageKey(
|
|
953
|
+
file.name
|
|
954
|
+
)
|
|
955
|
+
: normalizeStorageKey(
|
|
956
|
+
options.key
|
|
957
|
+
);
|
|
958
|
+
const metadata =
|
|
959
|
+
await putStorageStream(
|
|
960
|
+
options.storage,
|
|
961
|
+
key,
|
|
962
|
+
file.stream(),
|
|
963
|
+
{
|
|
964
|
+
overwrite:
|
|
965
|
+
options.overwrite,
|
|
966
|
+
contentType:
|
|
967
|
+
file.type ||
|
|
968
|
+
"application/octet-stream",
|
|
969
|
+
maxBytes:
|
|
970
|
+
options.constraints
|
|
971
|
+
?.maxBytes,
|
|
972
|
+
}
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
return {
|
|
976
|
+
...metadata,
|
|
977
|
+
originalName:
|
|
978
|
+
file.name,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
export function normalizeStorageKey(
|
|
983
|
+
value: string
|
|
984
|
+
): string {
|
|
985
|
+
if (
|
|
986
|
+
typeof value !== "string" ||
|
|
987
|
+
value.trim().length === 0
|
|
988
|
+
) {
|
|
989
|
+
throw new StorageError(
|
|
990
|
+
"INVALID_KEY",
|
|
991
|
+
"BCP Framework: storage key must be a non-empty relative path.",
|
|
992
|
+
400
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const normalized =
|
|
997
|
+
value
|
|
998
|
+
.replace(
|
|
999
|
+
/\\/g,
|
|
1000
|
+
"/"
|
|
1001
|
+
)
|
|
1002
|
+
.replace(
|
|
1003
|
+
/^\/+|\/+$/g,
|
|
1004
|
+
""
|
|
1005
|
+
);
|
|
1006
|
+
const segments =
|
|
1007
|
+
normalized.split("/");
|
|
1008
|
+
|
|
1009
|
+
if (
|
|
1010
|
+
normalized.length === 0 ||
|
|
1011
|
+
path.isAbsolute(
|
|
1012
|
+
value
|
|
1013
|
+
) ||
|
|
1014
|
+
segments.some(
|
|
1015
|
+
(segment) =>
|
|
1016
|
+
segment.length === 0 ||
|
|
1017
|
+
segment === "." ||
|
|
1018
|
+
segment === ".." ||
|
|
1019
|
+
/[\u0000-\u001F\u007F]/.test(
|
|
1020
|
+
segment
|
|
1021
|
+
)
|
|
1022
|
+
) ||
|
|
1023
|
+
segments[0] ===
|
|
1024
|
+
METADATA_DIRECTORY
|
|
1025
|
+
) {
|
|
1026
|
+
throw new StorageError(
|
|
1027
|
+
"INVALID_KEY",
|
|
1028
|
+
`BCP Framework: invalid storage key "${value}".`,
|
|
1029
|
+
400
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
return segments.join("/");
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function resolveStoragePath(
|
|
1037
|
+
rootDirectory: string,
|
|
1038
|
+
key: string
|
|
1039
|
+
): string {
|
|
1040
|
+
const destination =
|
|
1041
|
+
path.resolve(
|
|
1042
|
+
rootDirectory,
|
|
1043
|
+
...key.split("/")
|
|
1044
|
+
);
|
|
1045
|
+
const relative =
|
|
1046
|
+
path.relative(
|
|
1047
|
+
rootDirectory,
|
|
1048
|
+
destination
|
|
1049
|
+
);
|
|
1050
|
+
|
|
1051
|
+
if (
|
|
1052
|
+
relative === "" ||
|
|
1053
|
+
relative.startsWith("..") ||
|
|
1054
|
+
path.isAbsolute(
|
|
1055
|
+
relative
|
|
1056
|
+
)
|
|
1057
|
+
) {
|
|
1058
|
+
throw new StorageError(
|
|
1059
|
+
"INVALID_KEY",
|
|
1060
|
+
`BCP Framework: storage key "${key}" escapes the configured storage directory.`,
|
|
1061
|
+
400
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
return destination;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
async function toStorageBytes(
|
|
1069
|
+
value: StorageWriteValue
|
|
1070
|
+
): Promise<Uint8Array> {
|
|
1071
|
+
if (
|
|
1072
|
+
typeof value ===
|
|
1073
|
+
"string"
|
|
1074
|
+
) {
|
|
1075
|
+
return Uint8Array.from(
|
|
1076
|
+
Buffer.from(
|
|
1077
|
+
value,
|
|
1078
|
+
"utf8"
|
|
1079
|
+
)
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
if (
|
|
1084
|
+
value instanceof Blob
|
|
1085
|
+
) {
|
|
1086
|
+
return new Uint8Array(
|
|
1087
|
+
await value.arrayBuffer()
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (
|
|
1092
|
+
value instanceof ArrayBuffer
|
|
1093
|
+
) {
|
|
1094
|
+
return new Uint8Array(
|
|
1095
|
+
value
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (
|
|
1100
|
+
ArrayBuffer.isView(
|
|
1101
|
+
value
|
|
1102
|
+
)
|
|
1103
|
+
) {
|
|
1104
|
+
return Uint8Array.from(
|
|
1105
|
+
new Uint8Array(
|
|
1106
|
+
value.buffer,
|
|
1107
|
+
value.byteOffset,
|
|
1108
|
+
value.byteLength
|
|
1109
|
+
)
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
throw new TypeError(
|
|
1114
|
+
"BCP Framework: unsupported storage write value."
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
async function collectStorageStream(
|
|
1119
|
+
stream:
|
|
1120
|
+
ReadableStream<Uint8Array>,
|
|
1121
|
+
options:
|
|
1122
|
+
Pick<
|
|
1123
|
+
StoragePutStreamOptions,
|
|
1124
|
+
"maxBytes" |
|
|
1125
|
+
"signal"
|
|
1126
|
+
>
|
|
1127
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
1128
|
+
const maxBytes =
|
|
1129
|
+
normalizeOptionalByteLimit(
|
|
1130
|
+
options.maxBytes,
|
|
1131
|
+
"stream maxBytes"
|
|
1132
|
+
);
|
|
1133
|
+
const chunks:
|
|
1134
|
+
Uint8Array<ArrayBuffer>[] = [];
|
|
1135
|
+
let totalBytes =
|
|
1136
|
+
0;
|
|
1137
|
+
const reader =
|
|
1138
|
+
stream.getReader();
|
|
1139
|
+
|
|
1140
|
+
try {
|
|
1141
|
+
while (true) {
|
|
1142
|
+
options.signal
|
|
1143
|
+
?.throwIfAborted();
|
|
1144
|
+
|
|
1145
|
+
const result =
|
|
1146
|
+
await reader.read();
|
|
1147
|
+
|
|
1148
|
+
if (result.done) {
|
|
1149
|
+
break;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const chunk =
|
|
1153
|
+
result.value;
|
|
1154
|
+
const nextTotal =
|
|
1155
|
+
totalBytes +
|
|
1156
|
+
chunk.byteLength;
|
|
1157
|
+
|
|
1158
|
+
if (
|
|
1159
|
+
maxBytes !== undefined &&
|
|
1160
|
+
nextTotal > maxBytes
|
|
1161
|
+
) {
|
|
1162
|
+
throw new StorageError(
|
|
1163
|
+
"STREAM_TOO_LARGE",
|
|
1164
|
+
`BCP Framework: storage stream exceeded ${maxBytes} bytes.`,
|
|
1165
|
+
413
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const ownedChunk =
|
|
1170
|
+
new Uint8Array(
|
|
1171
|
+
chunk.byteLength
|
|
1172
|
+
);
|
|
1173
|
+
ownedChunk.set(
|
|
1174
|
+
chunk
|
|
1175
|
+
);
|
|
1176
|
+
chunks.push(
|
|
1177
|
+
ownedChunk
|
|
1178
|
+
);
|
|
1179
|
+
totalBytes =
|
|
1180
|
+
nextTotal;
|
|
1181
|
+
}
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
try {
|
|
1184
|
+
await reader.cancel(
|
|
1185
|
+
error
|
|
1186
|
+
);
|
|
1187
|
+
} catch {
|
|
1188
|
+
// Ignore source cancellation failures.
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
throw error;
|
|
1192
|
+
} finally {
|
|
1193
|
+
reader.releaseLock();
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const bytes =
|
|
1197
|
+
new Uint8Array(
|
|
1198
|
+
totalBytes
|
|
1199
|
+
);
|
|
1200
|
+
let offset =
|
|
1201
|
+
0;
|
|
1202
|
+
|
|
1203
|
+
for (const chunk of chunks) {
|
|
1204
|
+
bytes.set(
|
|
1205
|
+
chunk,
|
|
1206
|
+
offset
|
|
1207
|
+
);
|
|
1208
|
+
offset +=
|
|
1209
|
+
chunk.byteLength;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
return bytes;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
async function writeAll(
|
|
1216
|
+
handle:
|
|
1217
|
+
Awaited<
|
|
1218
|
+
ReturnType<typeof open>
|
|
1219
|
+
>,
|
|
1220
|
+
bytes: Uint8Array
|
|
1221
|
+
): Promise<void> {
|
|
1222
|
+
let offset =
|
|
1223
|
+
0;
|
|
1224
|
+
|
|
1225
|
+
while (
|
|
1226
|
+
offset < bytes.byteLength
|
|
1227
|
+
) {
|
|
1228
|
+
const result =
|
|
1229
|
+
await handle.write(
|
|
1230
|
+
bytes,
|
|
1231
|
+
offset,
|
|
1232
|
+
bytes.byteLength -
|
|
1233
|
+
offset,
|
|
1234
|
+
null
|
|
1235
|
+
);
|
|
1236
|
+
|
|
1237
|
+
if (
|
|
1238
|
+
result.bytesWritten <= 0
|
|
1239
|
+
) {
|
|
1240
|
+
throw new Error(
|
|
1241
|
+
"BCP Framework: storage stream write made no progress."
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
offset +=
|
|
1246
|
+
result.bytesWritten;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function normalizeOptionalByteLimit(
|
|
1251
|
+
value: number | undefined,
|
|
1252
|
+
label: string
|
|
1253
|
+
): number | undefined {
|
|
1254
|
+
if (value === undefined) {
|
|
1255
|
+
return undefined;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
if (
|
|
1259
|
+
!Number.isSafeInteger(
|
|
1260
|
+
value
|
|
1261
|
+
) ||
|
|
1262
|
+
value < 0
|
|
1263
|
+
) {
|
|
1264
|
+
throw new TypeError(
|
|
1265
|
+
`BCP Framework: storage ${label} must be a non-negative safe integer.`
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
return value;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function normalizeReadRange(
|
|
1273
|
+
size: number,
|
|
1274
|
+
options: StorageReadOptions
|
|
1275
|
+
): {
|
|
1276
|
+
start: number;
|
|
1277
|
+
end: number;
|
|
1278
|
+
} | null {
|
|
1279
|
+
if (
|
|
1280
|
+
options.start === undefined &&
|
|
1281
|
+
options.end === undefined
|
|
1282
|
+
) {
|
|
1283
|
+
return null;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const start =
|
|
1287
|
+
options.start ??
|
|
1288
|
+
0;
|
|
1289
|
+
const end =
|
|
1290
|
+
options.end ??
|
|
1291
|
+
size - 1;
|
|
1292
|
+
|
|
1293
|
+
if (
|
|
1294
|
+
!Number.isSafeInteger(start) ||
|
|
1295
|
+
!Number.isSafeInteger(end) ||
|
|
1296
|
+
start < 0 ||
|
|
1297
|
+
end < start ||
|
|
1298
|
+
start >= size ||
|
|
1299
|
+
end >= size
|
|
1300
|
+
) {
|
|
1301
|
+
throw new StorageError(
|
|
1302
|
+
"RANGE_NOT_SATISFIABLE",
|
|
1303
|
+
`BCP Framework: requested storage range ${start}-${end} is invalid for ${size} bytes.`,
|
|
1304
|
+
416
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
return {
|
|
1309
|
+
start,
|
|
1310
|
+
end,
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function createObjectMetadata(
|
|
1315
|
+
key: string,
|
|
1316
|
+
size: number,
|
|
1317
|
+
lastModified: Date,
|
|
1318
|
+
contentType: string,
|
|
1319
|
+
checksumSha256?: string
|
|
1320
|
+
): StorageObjectMetadata {
|
|
1321
|
+
const etag =
|
|
1322
|
+
checksumSha256
|
|
1323
|
+
? `"sha256-${checksumSha256}"`
|
|
1324
|
+
: `W/"${size}-${Math.trunc(
|
|
1325
|
+
lastModified.getTime()
|
|
1326
|
+
)}"`;
|
|
1327
|
+
|
|
1328
|
+
return {
|
|
1329
|
+
key,
|
|
1330
|
+
size,
|
|
1331
|
+
contentType:
|
|
1332
|
+
normalizeContentType(
|
|
1333
|
+
contentType
|
|
1334
|
+
),
|
|
1335
|
+
lastModified:
|
|
1336
|
+
new Date(
|
|
1337
|
+
lastModified.getTime()
|
|
1338
|
+
),
|
|
1339
|
+
etag,
|
|
1340
|
+
...(checksumSha256
|
|
1341
|
+
? {
|
|
1342
|
+
checksumSha256,
|
|
1343
|
+
}
|
|
1344
|
+
: {}),
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
async function writeMetadataRecord(
|
|
1349
|
+
metadataDirectory: string,
|
|
1350
|
+
record: LocalMetadataRecord
|
|
1351
|
+
): Promise<void> {
|
|
1352
|
+
await mkdir(
|
|
1353
|
+
metadataDirectory,
|
|
1354
|
+
{
|
|
1355
|
+
recursive: true,
|
|
1356
|
+
}
|
|
1357
|
+
);
|
|
1358
|
+
|
|
1359
|
+
await writeFile(
|
|
1360
|
+
metadataFilePath(
|
|
1361
|
+
metadataDirectory,
|
|
1362
|
+
record.key
|
|
1363
|
+
),
|
|
1364
|
+
JSON.stringify(
|
|
1365
|
+
record
|
|
1366
|
+
),
|
|
1367
|
+
"utf8"
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
async function readMetadataRecord(
|
|
1372
|
+
metadataDirectory: string,
|
|
1373
|
+
key: string
|
|
1374
|
+
): Promise<LocalMetadataRecord | null> {
|
|
1375
|
+
try {
|
|
1376
|
+
const raw =
|
|
1377
|
+
await readFile(
|
|
1378
|
+
metadataFilePath(
|
|
1379
|
+
metadataDirectory,
|
|
1380
|
+
key
|
|
1381
|
+
),
|
|
1382
|
+
"utf8"
|
|
1383
|
+
);
|
|
1384
|
+
const parsed =
|
|
1385
|
+
JSON.parse(
|
|
1386
|
+
raw
|
|
1387
|
+
) as Partial<
|
|
1388
|
+
LocalMetadataRecord
|
|
1389
|
+
>;
|
|
1390
|
+
|
|
1391
|
+
if (
|
|
1392
|
+
parsed.version !== 1 ||
|
|
1393
|
+
parsed.key !== key ||
|
|
1394
|
+
typeof parsed.contentType !==
|
|
1395
|
+
"string" ||
|
|
1396
|
+
typeof parsed.checksumSha256 !==
|
|
1397
|
+
"string"
|
|
1398
|
+
) {
|
|
1399
|
+
return null;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
return {
|
|
1403
|
+
version: 1,
|
|
1404
|
+
key,
|
|
1405
|
+
contentType:
|
|
1406
|
+
parsed.contentType,
|
|
1407
|
+
checksumSha256:
|
|
1408
|
+
parsed.checksumSha256,
|
|
1409
|
+
};
|
|
1410
|
+
} catch (error) {
|
|
1411
|
+
if (
|
|
1412
|
+
isNodeErrorCode(
|
|
1413
|
+
error,
|
|
1414
|
+
"ENOENT"
|
|
1415
|
+
) ||
|
|
1416
|
+
error instanceof SyntaxError
|
|
1417
|
+
) {
|
|
1418
|
+
return null;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
throw error;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function metadataFilePath(
|
|
1426
|
+
metadataDirectory: string,
|
|
1427
|
+
key: string
|
|
1428
|
+
): string {
|
|
1429
|
+
const digest =
|
|
1430
|
+
createHash(
|
|
1431
|
+
"sha256"
|
|
1432
|
+
)
|
|
1433
|
+
.update(
|
|
1434
|
+
key
|
|
1435
|
+
)
|
|
1436
|
+
.digest(
|
|
1437
|
+
"hex"
|
|
1438
|
+
);
|
|
1439
|
+
|
|
1440
|
+
return path.join(
|
|
1441
|
+
metadataDirectory,
|
|
1442
|
+
`${digest}.json`
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function createUploadStorageKey(
|
|
1447
|
+
originalName: string
|
|
1448
|
+
): string {
|
|
1449
|
+
const extension =
|
|
1450
|
+
path.extname(
|
|
1451
|
+
originalName
|
|
1452
|
+
)
|
|
1453
|
+
.toLowerCase()
|
|
1454
|
+
.replace(
|
|
1455
|
+
/[^.a-z0-9]/g,
|
|
1456
|
+
""
|
|
1457
|
+
)
|
|
1458
|
+
.slice(
|
|
1459
|
+
0,
|
|
1460
|
+
16
|
|
1461
|
+
);
|
|
1462
|
+
|
|
1463
|
+
return `${randomUUID()}${extension}`;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function normalizeContentType(
|
|
1467
|
+
value: string
|
|
1468
|
+
): string {
|
|
1469
|
+
const normalized =
|
|
1470
|
+
value
|
|
1471
|
+
.trim()
|
|
1472
|
+
.toLowerCase();
|
|
1473
|
+
|
|
1474
|
+
if (
|
|
1475
|
+
normalized.length === 0 ||
|
|
1476
|
+
/[\r\n]/.test(
|
|
1477
|
+
normalized
|
|
1478
|
+
)
|
|
1479
|
+
) {
|
|
1480
|
+
return "application/octet-stream";
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
return normalized;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function assertNonEmptyString(
|
|
1487
|
+
value: string,
|
|
1488
|
+
label: string
|
|
1489
|
+
): string {
|
|
1490
|
+
if (
|
|
1491
|
+
typeof value !== "string" ||
|
|
1492
|
+
value.trim().length === 0
|
|
1493
|
+
) {
|
|
1494
|
+
throw new TypeError(
|
|
1495
|
+
`BCP Framework: storage ${label} must be a non-empty string.`
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
return value;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function isNodeErrorCode(
|
|
1503
|
+
error: unknown,
|
|
1504
|
+
code: string
|
|
1505
|
+
): boolean {
|
|
1506
|
+
return (
|
|
1507
|
+
error instanceof Error &&
|
|
1508
|
+
"code" in error &&
|
|
1509
|
+
(
|
|
1510
|
+
error as Error & {
|
|
1511
|
+
code?: string;
|
|
1512
|
+
}
|
|
1513
|
+
).code === code
|
|
1514
|
+
);
|
|
1515
|
+
}
|