@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.
@@ -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
+ }