@chidchanun/bcp 0.1.23 → 0.1.25
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 +658 -578
- package/docs/README.md +317 -318
- package/docs/auth-route-guards.md +14 -0
- package/docs/developer-tools.md +45 -22
- package/docs/file-upload.md +280 -0
- package/docs/releases/0.1.24.md +166 -0
- package/docs/releases/0.1.25.md +132 -0
- package/docs/storage.md +285 -0
- package/package.json +1 -1
- package/packages/bundler/src/server-production-actions.ts +15 -0
- package/packages/bundler/src/server-production-guards.ts +15 -0
- package/packages/client/src/server.ts +40 -0
- package/packages/server/src/dev-route-graph.ts +91 -0
- package/packages/server/src/file-delivery.ts +629 -0
- package/packages/server/src/middleware-dev-server.ts +217 -9
- package/packages/server/src/storage.ts +829 -0
- package/packages/server/src/upload.ts +556 -0
|
@@ -0,0 +1,829 @@
|
|
|
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
|
+
|
|
24
|
+
export type StorageErrorCode =
|
|
25
|
+
| "INVALID_KEY"
|
|
26
|
+
| "OBJECT_NOT_FOUND"
|
|
27
|
+
| "OBJECT_EXISTS"
|
|
28
|
+
| "RANGE_NOT_SATISFIABLE";
|
|
29
|
+
|
|
30
|
+
export class StorageError extends Error {
|
|
31
|
+
readonly code:
|
|
32
|
+
StorageErrorCode;
|
|
33
|
+
readonly status:
|
|
34
|
+
number;
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
code: StorageErrorCode,
|
|
38
|
+
message: string,
|
|
39
|
+
status: number
|
|
40
|
+
) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name =
|
|
43
|
+
"StorageError";
|
|
44
|
+
this.code =
|
|
45
|
+
code;
|
|
46
|
+
this.status =
|
|
47
|
+
status;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface StorageObjectMetadata {
|
|
52
|
+
key: string;
|
|
53
|
+
size: number;
|
|
54
|
+
contentType: string;
|
|
55
|
+
lastModified: Date;
|
|
56
|
+
etag: string;
|
|
57
|
+
checksumSha256?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface StoragePutOptions {
|
|
61
|
+
contentType?: string;
|
|
62
|
+
overwrite?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface StorageReadOptions {
|
|
66
|
+
start?: number;
|
|
67
|
+
end?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface StorageAdapter {
|
|
71
|
+
put(
|
|
72
|
+
key: string,
|
|
73
|
+
value: StorageWriteValue,
|
|
74
|
+
options?: StoragePutOptions
|
|
75
|
+
): Promise<StorageObjectMetadata>;
|
|
76
|
+
|
|
77
|
+
stat(
|
|
78
|
+
key: string
|
|
79
|
+
): Promise<StorageObjectMetadata | null>;
|
|
80
|
+
|
|
81
|
+
read(
|
|
82
|
+
key: string,
|
|
83
|
+
options?: StorageReadOptions
|
|
84
|
+
): Promise<Uint8Array>;
|
|
85
|
+
|
|
86
|
+
exists(
|
|
87
|
+
key: string
|
|
88
|
+
): Promise<boolean>;
|
|
89
|
+
|
|
90
|
+
delete(
|
|
91
|
+
key: string
|
|
92
|
+
): Promise<boolean>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type StorageWriteValue =
|
|
96
|
+
| string
|
|
97
|
+
| Blob
|
|
98
|
+
| ArrayBuffer
|
|
99
|
+
| ArrayBufferView;
|
|
100
|
+
|
|
101
|
+
export interface LocalStorageOptions {
|
|
102
|
+
directory: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface StoreUploadedFileOptions {
|
|
106
|
+
storage: StorageAdapter;
|
|
107
|
+
key?: string;
|
|
108
|
+
overwrite?: boolean;
|
|
109
|
+
constraints?: UploadConstraints;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface StoredUploadedObject
|
|
113
|
+
extends StorageObjectMetadata {
|
|
114
|
+
originalName: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface LocalMetadataRecord {
|
|
118
|
+
version: 1;
|
|
119
|
+
key: string;
|
|
120
|
+
contentType: string;
|
|
121
|
+
checksumSha256: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createLocalStorage(
|
|
125
|
+
options: LocalStorageOptions
|
|
126
|
+
): StorageAdapter {
|
|
127
|
+
const rootDirectory =
|
|
128
|
+
path.resolve(
|
|
129
|
+
assertNonEmptyString(
|
|
130
|
+
options.directory,
|
|
131
|
+
"directory"
|
|
132
|
+
)
|
|
133
|
+
);
|
|
134
|
+
const metadataDirectory =
|
|
135
|
+
path.join(
|
|
136
|
+
rootDirectory,
|
|
137
|
+
METADATA_DIRECTORY
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
async put(
|
|
142
|
+
key,
|
|
143
|
+
value,
|
|
144
|
+
putOptions = {}
|
|
145
|
+
) {
|
|
146
|
+
const normalizedKey =
|
|
147
|
+
normalizeStorageKey(
|
|
148
|
+
key
|
|
149
|
+
);
|
|
150
|
+
const destination =
|
|
151
|
+
resolveStoragePath(
|
|
152
|
+
rootDirectory,
|
|
153
|
+
normalizedKey
|
|
154
|
+
);
|
|
155
|
+
const bytes =
|
|
156
|
+
await toStorageBytes(
|
|
157
|
+
value
|
|
158
|
+
);
|
|
159
|
+
const checksumSha256 =
|
|
160
|
+
createHash(
|
|
161
|
+
"sha256"
|
|
162
|
+
)
|
|
163
|
+
.update(bytes)
|
|
164
|
+
.digest("hex");
|
|
165
|
+
const contentType =
|
|
166
|
+
normalizeContentType(
|
|
167
|
+
putOptions.contentType ??
|
|
168
|
+
(
|
|
169
|
+
value instanceof Blob
|
|
170
|
+
? value.type
|
|
171
|
+
: ""
|
|
172
|
+
)
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
await mkdir(
|
|
176
|
+
path.dirname(
|
|
177
|
+
destination
|
|
178
|
+
),
|
|
179
|
+
{
|
|
180
|
+
recursive: true,
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
await writeFile(
|
|
186
|
+
destination,
|
|
187
|
+
bytes,
|
|
188
|
+
{
|
|
189
|
+
flag:
|
|
190
|
+
putOptions.overwrite
|
|
191
|
+
? "w"
|
|
192
|
+
: "wx",
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (
|
|
197
|
+
isNodeErrorCode(
|
|
198
|
+
error,
|
|
199
|
+
"EEXIST"
|
|
200
|
+
)
|
|
201
|
+
) {
|
|
202
|
+
throw new StorageError(
|
|
203
|
+
"OBJECT_EXISTS",
|
|
204
|
+
`BCP Framework: storage object "${normalizedKey}" already exists.`,
|
|
205
|
+
409
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await writeMetadataRecord(
|
|
213
|
+
metadataDirectory,
|
|
214
|
+
{
|
|
215
|
+
version: 1,
|
|
216
|
+
key:
|
|
217
|
+
normalizedKey,
|
|
218
|
+
contentType,
|
|
219
|
+
checksumSha256,
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const fileStat =
|
|
224
|
+
await stat(
|
|
225
|
+
destination
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
return createObjectMetadata(
|
|
229
|
+
normalizedKey,
|
|
230
|
+
fileStat.size,
|
|
231
|
+
fileStat.mtime,
|
|
232
|
+
contentType,
|
|
233
|
+
checksumSha256
|
|
234
|
+
);
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async stat(key) {
|
|
238
|
+
const normalizedKey =
|
|
239
|
+
normalizeStorageKey(
|
|
240
|
+
key
|
|
241
|
+
);
|
|
242
|
+
const destination =
|
|
243
|
+
resolveStoragePath(
|
|
244
|
+
rootDirectory,
|
|
245
|
+
normalizedKey
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
let fileStat;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
fileStat =
|
|
252
|
+
await stat(
|
|
253
|
+
destination
|
|
254
|
+
);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (
|
|
257
|
+
isNodeErrorCode(
|
|
258
|
+
error,
|
|
259
|
+
"ENOENT"
|
|
260
|
+
)
|
|
261
|
+
) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!fileStat.isFile()) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const record =
|
|
273
|
+
await readMetadataRecord(
|
|
274
|
+
metadataDirectory,
|
|
275
|
+
normalizedKey
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
return createObjectMetadata(
|
|
279
|
+
normalizedKey,
|
|
280
|
+
fileStat.size,
|
|
281
|
+
fileStat.mtime,
|
|
282
|
+
record?.contentType ??
|
|
283
|
+
"application/octet-stream",
|
|
284
|
+
record?.checksumSha256
|
|
285
|
+
);
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
async read(
|
|
289
|
+
key,
|
|
290
|
+
readOptions = {}
|
|
291
|
+
) {
|
|
292
|
+
const normalizedKey =
|
|
293
|
+
normalizeStorageKey(
|
|
294
|
+
key
|
|
295
|
+
);
|
|
296
|
+
const destination =
|
|
297
|
+
resolveStoragePath(
|
|
298
|
+
rootDirectory,
|
|
299
|
+
normalizedKey
|
|
300
|
+
);
|
|
301
|
+
const metadata =
|
|
302
|
+
await this.stat(
|
|
303
|
+
normalizedKey
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
if (!metadata) {
|
|
307
|
+
throw new StorageError(
|
|
308
|
+
"OBJECT_NOT_FOUND",
|
|
309
|
+
`BCP Framework: storage object "${normalizedKey}" was not found.`,
|
|
310
|
+
404
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const range =
|
|
315
|
+
normalizeReadRange(
|
|
316
|
+
metadata.size,
|
|
317
|
+
readOptions
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
if (!range) {
|
|
321
|
+
return Uint8Array.from(
|
|
322
|
+
await readFile(
|
|
323
|
+
destination
|
|
324
|
+
)
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const length =
|
|
329
|
+
range.end -
|
|
330
|
+
range.start +
|
|
331
|
+
1;
|
|
332
|
+
const handle =
|
|
333
|
+
await open(
|
|
334
|
+
destination,
|
|
335
|
+
"r"
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
const buffer =
|
|
340
|
+
Buffer.allocUnsafe(
|
|
341
|
+
length
|
|
342
|
+
);
|
|
343
|
+
const result =
|
|
344
|
+
await handle.read(
|
|
345
|
+
buffer,
|
|
346
|
+
0,
|
|
347
|
+
length,
|
|
348
|
+
range.start
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
return Uint8Array.from(
|
|
352
|
+
buffer.subarray(
|
|
353
|
+
0,
|
|
354
|
+
result.bytesRead
|
|
355
|
+
)
|
|
356
|
+
);
|
|
357
|
+
} finally {
|
|
358
|
+
await handle.close();
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
async exists(key) {
|
|
363
|
+
return (
|
|
364
|
+
await this.stat(
|
|
365
|
+
key
|
|
366
|
+
)
|
|
367
|
+
) !== null;
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
async delete(key) {
|
|
371
|
+
const normalizedKey =
|
|
372
|
+
normalizeStorageKey(
|
|
373
|
+
key
|
|
374
|
+
);
|
|
375
|
+
const destination =
|
|
376
|
+
resolveStoragePath(
|
|
377
|
+
rootDirectory,
|
|
378
|
+
normalizedKey
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
await unlink(
|
|
383
|
+
destination
|
|
384
|
+
);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
if (
|
|
387
|
+
isNodeErrorCode(
|
|
388
|
+
error,
|
|
389
|
+
"ENOENT"
|
|
390
|
+
)
|
|
391
|
+
) {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
await rm(
|
|
399
|
+
metadataFilePath(
|
|
400
|
+
metadataDirectory,
|
|
401
|
+
normalizedKey
|
|
402
|
+
),
|
|
403
|
+
{
|
|
404
|
+
force: true,
|
|
405
|
+
}
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
return true;
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export async function storeUploadedFile(
|
|
414
|
+
file: File,
|
|
415
|
+
options: StoreUploadedFileOptions
|
|
416
|
+
): Promise<StoredUploadedObject> {
|
|
417
|
+
validateUploadedFile(
|
|
418
|
+
file,
|
|
419
|
+
options.constraints
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const key =
|
|
423
|
+
options.key === undefined
|
|
424
|
+
? createUploadStorageKey(
|
|
425
|
+
file.name
|
|
426
|
+
)
|
|
427
|
+
: normalizeStorageKey(
|
|
428
|
+
options.key
|
|
429
|
+
);
|
|
430
|
+
const metadata =
|
|
431
|
+
await options.storage.put(
|
|
432
|
+
key,
|
|
433
|
+
file,
|
|
434
|
+
{
|
|
435
|
+
overwrite:
|
|
436
|
+
options.overwrite,
|
|
437
|
+
contentType:
|
|
438
|
+
file.type ||
|
|
439
|
+
"application/octet-stream",
|
|
440
|
+
}
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
...metadata,
|
|
445
|
+
originalName:
|
|
446
|
+
file.name,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function normalizeStorageKey(
|
|
451
|
+
value: string
|
|
452
|
+
): string {
|
|
453
|
+
if (
|
|
454
|
+
typeof value !== "string" ||
|
|
455
|
+
value.trim().length === 0
|
|
456
|
+
) {
|
|
457
|
+
throw new StorageError(
|
|
458
|
+
"INVALID_KEY",
|
|
459
|
+
"BCP Framework: storage key must be a non-empty relative path.",
|
|
460
|
+
400
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const normalized =
|
|
465
|
+
value
|
|
466
|
+
.replace(
|
|
467
|
+
/\\/g,
|
|
468
|
+
"/"
|
|
469
|
+
)
|
|
470
|
+
.replace(
|
|
471
|
+
/^\/+|\/+$/g,
|
|
472
|
+
""
|
|
473
|
+
);
|
|
474
|
+
const segments =
|
|
475
|
+
normalized.split("/");
|
|
476
|
+
|
|
477
|
+
if (
|
|
478
|
+
normalized.length === 0 ||
|
|
479
|
+
path.isAbsolute(
|
|
480
|
+
value
|
|
481
|
+
) ||
|
|
482
|
+
segments.some(
|
|
483
|
+
(segment) =>
|
|
484
|
+
segment.length === 0 ||
|
|
485
|
+
segment === "." ||
|
|
486
|
+
segment === ".." ||
|
|
487
|
+
/[\u0000-\u001F\u007F]/.test(
|
|
488
|
+
segment
|
|
489
|
+
)
|
|
490
|
+
) ||
|
|
491
|
+
segments[0] ===
|
|
492
|
+
METADATA_DIRECTORY
|
|
493
|
+
) {
|
|
494
|
+
throw new StorageError(
|
|
495
|
+
"INVALID_KEY",
|
|
496
|
+
`BCP Framework: invalid storage key "${value}".`,
|
|
497
|
+
400
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return segments.join("/");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function resolveStoragePath(
|
|
505
|
+
rootDirectory: string,
|
|
506
|
+
key: string
|
|
507
|
+
): string {
|
|
508
|
+
const destination =
|
|
509
|
+
path.resolve(
|
|
510
|
+
rootDirectory,
|
|
511
|
+
...key.split("/")
|
|
512
|
+
);
|
|
513
|
+
const relative =
|
|
514
|
+
path.relative(
|
|
515
|
+
rootDirectory,
|
|
516
|
+
destination
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
if (
|
|
520
|
+
relative === "" ||
|
|
521
|
+
relative.startsWith("..") ||
|
|
522
|
+
path.isAbsolute(
|
|
523
|
+
relative
|
|
524
|
+
)
|
|
525
|
+
) {
|
|
526
|
+
throw new StorageError(
|
|
527
|
+
"INVALID_KEY",
|
|
528
|
+
`BCP Framework: storage key "${key}" escapes the configured storage directory.`,
|
|
529
|
+
400
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return destination;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function toStorageBytes(
|
|
537
|
+
value: StorageWriteValue
|
|
538
|
+
): Promise<Uint8Array> {
|
|
539
|
+
if (
|
|
540
|
+
typeof value ===
|
|
541
|
+
"string"
|
|
542
|
+
) {
|
|
543
|
+
return Uint8Array.from(
|
|
544
|
+
Buffer.from(
|
|
545
|
+
value,
|
|
546
|
+
"utf8"
|
|
547
|
+
)
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (
|
|
552
|
+
value instanceof Blob
|
|
553
|
+
) {
|
|
554
|
+
return new Uint8Array(
|
|
555
|
+
await value.arrayBuffer()
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (
|
|
560
|
+
value instanceof ArrayBuffer
|
|
561
|
+
) {
|
|
562
|
+
return new Uint8Array(
|
|
563
|
+
value
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (
|
|
568
|
+
ArrayBuffer.isView(
|
|
569
|
+
value
|
|
570
|
+
)
|
|
571
|
+
) {
|
|
572
|
+
return Uint8Array.from(
|
|
573
|
+
new Uint8Array(
|
|
574
|
+
value.buffer,
|
|
575
|
+
value.byteOffset,
|
|
576
|
+
value.byteLength
|
|
577
|
+
)
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
throw new TypeError(
|
|
582
|
+
"BCP Framework: unsupported storage write value."
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function normalizeReadRange(
|
|
587
|
+
size: number,
|
|
588
|
+
options: StorageReadOptions
|
|
589
|
+
): {
|
|
590
|
+
start: number;
|
|
591
|
+
end: number;
|
|
592
|
+
} | null {
|
|
593
|
+
if (
|
|
594
|
+
options.start === undefined &&
|
|
595
|
+
options.end === undefined
|
|
596
|
+
) {
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const start =
|
|
601
|
+
options.start ??
|
|
602
|
+
0;
|
|
603
|
+
const end =
|
|
604
|
+
options.end ??
|
|
605
|
+
size - 1;
|
|
606
|
+
|
|
607
|
+
if (
|
|
608
|
+
!Number.isSafeInteger(start) ||
|
|
609
|
+
!Number.isSafeInteger(end) ||
|
|
610
|
+
start < 0 ||
|
|
611
|
+
end < start ||
|
|
612
|
+
start >= size ||
|
|
613
|
+
end >= size
|
|
614
|
+
) {
|
|
615
|
+
throw new StorageError(
|
|
616
|
+
"RANGE_NOT_SATISFIABLE",
|
|
617
|
+
`BCP Framework: requested storage range ${start}-${end} is invalid for ${size} bytes.`,
|
|
618
|
+
416
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return {
|
|
623
|
+
start,
|
|
624
|
+
end,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function createObjectMetadata(
|
|
629
|
+
key: string,
|
|
630
|
+
size: number,
|
|
631
|
+
lastModified: Date,
|
|
632
|
+
contentType: string,
|
|
633
|
+
checksumSha256?: string
|
|
634
|
+
): StorageObjectMetadata {
|
|
635
|
+
const etag =
|
|
636
|
+
checksumSha256
|
|
637
|
+
? `"sha256-${checksumSha256}"`
|
|
638
|
+
: `W/"${size}-${Math.trunc(
|
|
639
|
+
lastModified.getTime()
|
|
640
|
+
)}"`;
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
key,
|
|
644
|
+
size,
|
|
645
|
+
contentType:
|
|
646
|
+
normalizeContentType(
|
|
647
|
+
contentType
|
|
648
|
+
),
|
|
649
|
+
lastModified:
|
|
650
|
+
new Date(
|
|
651
|
+
lastModified.getTime()
|
|
652
|
+
),
|
|
653
|
+
etag,
|
|
654
|
+
...(checksumSha256
|
|
655
|
+
? {
|
|
656
|
+
checksumSha256,
|
|
657
|
+
}
|
|
658
|
+
: {}),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function writeMetadataRecord(
|
|
663
|
+
metadataDirectory: string,
|
|
664
|
+
record: LocalMetadataRecord
|
|
665
|
+
): Promise<void> {
|
|
666
|
+
await mkdir(
|
|
667
|
+
metadataDirectory,
|
|
668
|
+
{
|
|
669
|
+
recursive: true,
|
|
670
|
+
}
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
await writeFile(
|
|
674
|
+
metadataFilePath(
|
|
675
|
+
metadataDirectory,
|
|
676
|
+
record.key
|
|
677
|
+
),
|
|
678
|
+
JSON.stringify(
|
|
679
|
+
record
|
|
680
|
+
),
|
|
681
|
+
"utf8"
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function readMetadataRecord(
|
|
686
|
+
metadataDirectory: string,
|
|
687
|
+
key: string
|
|
688
|
+
): Promise<LocalMetadataRecord | null> {
|
|
689
|
+
try {
|
|
690
|
+
const raw =
|
|
691
|
+
await readFile(
|
|
692
|
+
metadataFilePath(
|
|
693
|
+
metadataDirectory,
|
|
694
|
+
key
|
|
695
|
+
),
|
|
696
|
+
"utf8"
|
|
697
|
+
);
|
|
698
|
+
const parsed =
|
|
699
|
+
JSON.parse(
|
|
700
|
+
raw
|
|
701
|
+
) as Partial<
|
|
702
|
+
LocalMetadataRecord
|
|
703
|
+
>;
|
|
704
|
+
|
|
705
|
+
if (
|
|
706
|
+
parsed.version !== 1 ||
|
|
707
|
+
parsed.key !== key ||
|
|
708
|
+
typeof parsed.contentType !==
|
|
709
|
+
"string" ||
|
|
710
|
+
typeof parsed.checksumSha256 !==
|
|
711
|
+
"string"
|
|
712
|
+
) {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return {
|
|
717
|
+
version: 1,
|
|
718
|
+
key,
|
|
719
|
+
contentType:
|
|
720
|
+
parsed.contentType,
|
|
721
|
+
checksumSha256:
|
|
722
|
+
parsed.checksumSha256,
|
|
723
|
+
};
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if (
|
|
726
|
+
isNodeErrorCode(
|
|
727
|
+
error,
|
|
728
|
+
"ENOENT"
|
|
729
|
+
) ||
|
|
730
|
+
error instanceof SyntaxError
|
|
731
|
+
) {
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
throw error;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function metadataFilePath(
|
|
740
|
+
metadataDirectory: string,
|
|
741
|
+
key: string
|
|
742
|
+
): string {
|
|
743
|
+
const digest =
|
|
744
|
+
createHash(
|
|
745
|
+
"sha256"
|
|
746
|
+
)
|
|
747
|
+
.update(
|
|
748
|
+
key
|
|
749
|
+
)
|
|
750
|
+
.digest(
|
|
751
|
+
"hex"
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
return path.join(
|
|
755
|
+
metadataDirectory,
|
|
756
|
+
`${digest}.json`
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function createUploadStorageKey(
|
|
761
|
+
originalName: string
|
|
762
|
+
): string {
|
|
763
|
+
const extension =
|
|
764
|
+
path.extname(
|
|
765
|
+
originalName
|
|
766
|
+
)
|
|
767
|
+
.toLowerCase()
|
|
768
|
+
.replace(
|
|
769
|
+
/[^.a-z0-9]/g,
|
|
770
|
+
""
|
|
771
|
+
)
|
|
772
|
+
.slice(
|
|
773
|
+
0,
|
|
774
|
+
16
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
return `${randomUUID()}${extension}`;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function normalizeContentType(
|
|
781
|
+
value: string
|
|
782
|
+
): string {
|
|
783
|
+
const normalized =
|
|
784
|
+
value
|
|
785
|
+
.trim()
|
|
786
|
+
.toLowerCase();
|
|
787
|
+
|
|
788
|
+
if (
|
|
789
|
+
normalized.length === 0 ||
|
|
790
|
+
/[\r\n]/.test(
|
|
791
|
+
normalized
|
|
792
|
+
)
|
|
793
|
+
) {
|
|
794
|
+
return "application/octet-stream";
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return normalized;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function assertNonEmptyString(
|
|
801
|
+
value: string,
|
|
802
|
+
label: string
|
|
803
|
+
): string {
|
|
804
|
+
if (
|
|
805
|
+
typeof value !== "string" ||
|
|
806
|
+
value.trim().length === 0
|
|
807
|
+
) {
|
|
808
|
+
throw new TypeError(
|
|
809
|
+
`BCP Framework: storage ${label} must be a non-empty string.`
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
return value;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function isNodeErrorCode(
|
|
817
|
+
error: unknown,
|
|
818
|
+
code: string
|
|
819
|
+
): boolean {
|
|
820
|
+
return (
|
|
821
|
+
error instanceof Error &&
|
|
822
|
+
"code" in error &&
|
|
823
|
+
(
|
|
824
|
+
error as Error & {
|
|
825
|
+
code?: string;
|
|
826
|
+
}
|
|
827
|
+
).code === code
|
|
828
|
+
);
|
|
829
|
+
}
|