@chidchanun/bcp 0.1.23 → 0.1.24

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,556 @@
1
+ import {
2
+ createHash,
3
+ randomUUID,
4
+ } from "node:crypto";
5
+ import {
6
+ mkdir,
7
+ writeFile,
8
+ } from "node:fs/promises";
9
+ import path from "node:path";
10
+
11
+ export type UploadErrorCode =
12
+ | "INVALID_MULTIPART"
13
+ | "UPLOAD_TOO_LARGE"
14
+ | "FILE_REQUIRED"
15
+ | "FILE_TOO_LARGE"
16
+ | "FILE_TYPE_NOT_ALLOWED"
17
+ | "FILE_EXTENSION_NOT_ALLOWED"
18
+ | "INVALID_FILE_NAME"
19
+ | "FILE_EXISTS";
20
+
21
+ export interface MultipartUploadOptions {
22
+ maxBytes?: number;
23
+ }
24
+
25
+ export interface UploadConstraints {
26
+ maxBytes?: number;
27
+ allowedTypes?: readonly string[];
28
+ allowedExtensions?: readonly string[];
29
+ }
30
+
31
+ export interface SaveUploadedFileOptions {
32
+ directory: string;
33
+ fileName?: string;
34
+ overwrite?: boolean;
35
+ constraints?: UploadConstraints;
36
+ }
37
+
38
+ export interface SavedUploadedFile {
39
+ originalName: string;
40
+ fileName: string;
41
+ path: string;
42
+ type: string;
43
+ size: number;
44
+ checksumSha256: string;
45
+ }
46
+
47
+ export class UploadError extends Error {
48
+ readonly code:
49
+ UploadErrorCode;
50
+ readonly status:
51
+ number;
52
+
53
+ constructor(
54
+ code: UploadErrorCode,
55
+ message: string,
56
+ status: number
57
+ ) {
58
+ super(
59
+ message
60
+ );
61
+ this.name =
62
+ "UploadError";
63
+ this.code =
64
+ code;
65
+ this.status =
66
+ status;
67
+ }
68
+ }
69
+
70
+ export async function parseMultipartFormData(
71
+ request: Request,
72
+ options: MultipartUploadOptions = {}
73
+ ): Promise<FormData> {
74
+ const contentType =
75
+ request.headers.get(
76
+ "content-type"
77
+ ) ?? "";
78
+
79
+ if (
80
+ !contentType
81
+ .toLowerCase()
82
+ .startsWith(
83
+ "multipart/form-data"
84
+ )
85
+ ) {
86
+ throw new UploadError(
87
+ "INVALID_MULTIPART",
88
+ "BCP Framework: upload request must use multipart/form-data.",
89
+ 400
90
+ );
91
+ }
92
+
93
+ const maxBytes =
94
+ normalizePositiveLimit(
95
+ options.maxBytes,
96
+ "maxBytes"
97
+ );
98
+ const contentLength =
99
+ readContentLength(
100
+ request.headers.get(
101
+ "content-length"
102
+ )
103
+ );
104
+
105
+ if (
106
+ maxBytes !== null &&
107
+ contentLength !== null &&
108
+ contentLength > maxBytes
109
+ ) {
110
+ throw new UploadError(
111
+ "UPLOAD_TOO_LARGE",
112
+ `BCP Framework: multipart request exceeds the ${maxBytes} byte upload limit.`,
113
+ 413
114
+ );
115
+ }
116
+
117
+ const formData =
118
+ await request.formData();
119
+
120
+ if (
121
+ maxBytes !== null &&
122
+ estimateFormDataBytes(
123
+ formData
124
+ ) > maxBytes
125
+ ) {
126
+ throw new UploadError(
127
+ "UPLOAD_TOO_LARGE",
128
+ `BCP Framework: multipart payload exceeds the ${maxBytes} byte upload limit.`,
129
+ 413
130
+ );
131
+ }
132
+
133
+ return formData;
134
+ }
135
+
136
+ export function getUploadedFile(
137
+ formData: FormData,
138
+ fieldName: string,
139
+ constraints: UploadConstraints = {}
140
+ ): File | null {
141
+ const value =
142
+ formData.get(
143
+ fieldName
144
+ );
145
+
146
+ if (
147
+ !isUploadedFile(
148
+ value
149
+ )
150
+ ) {
151
+ return null;
152
+ }
153
+
154
+ validateUploadedFile(
155
+ value,
156
+ constraints
157
+ );
158
+
159
+ return value;
160
+ }
161
+
162
+ export function requireUploadedFile(
163
+ formData: FormData,
164
+ fieldName: string,
165
+ constraints: UploadConstraints = {}
166
+ ): File {
167
+ const file =
168
+ getUploadedFile(
169
+ formData,
170
+ fieldName,
171
+ constraints
172
+ );
173
+
174
+ if (!file) {
175
+ throw new UploadError(
176
+ "FILE_REQUIRED",
177
+ `BCP Framework: upload field "${fieldName}" must contain a file.`,
178
+ 400
179
+ );
180
+ }
181
+
182
+ return file;
183
+ }
184
+
185
+ export function validateUploadedFile(
186
+ file: File,
187
+ constraints: UploadConstraints = {}
188
+ ): void {
189
+ const maxBytes =
190
+ normalizePositiveLimit(
191
+ constraints.maxBytes,
192
+ "maxBytes"
193
+ );
194
+
195
+ if (
196
+ maxBytes !== null &&
197
+ file.size > maxBytes
198
+ ) {
199
+ throw new UploadError(
200
+ "FILE_TOO_LARGE",
201
+ `BCP Framework: file "${file.name}" exceeds the ${maxBytes} byte file limit.`,
202
+ 413
203
+ );
204
+ }
205
+
206
+ if (
207
+ constraints.allowedTypes &&
208
+ constraints.allowedTypes.length > 0
209
+ ) {
210
+ const allowedTypes =
211
+ constraints.allowedTypes.map(
212
+ (value) =>
213
+ value
214
+ .trim()
215
+ .toLowerCase()
216
+ );
217
+ const fileType =
218
+ file.type
219
+ .trim()
220
+ .toLowerCase();
221
+
222
+ if (
223
+ !allowedTypes.includes(
224
+ fileType
225
+ )
226
+ ) {
227
+ throw new UploadError(
228
+ "FILE_TYPE_NOT_ALLOWED",
229
+ `BCP Framework: file type "${file.type || "unknown"}" is not allowed for "${file.name}".`,
230
+ 415
231
+ );
232
+ }
233
+ }
234
+
235
+ if (
236
+ constraints.allowedExtensions &&
237
+ constraints.allowedExtensions.length > 0
238
+ ) {
239
+ const extension =
240
+ path.extname(
241
+ file.name
242
+ ).toLowerCase();
243
+ const allowedExtensions =
244
+ constraints.allowedExtensions.map(
245
+ normalizeExtension
246
+ );
247
+
248
+ if (
249
+ !allowedExtensions.includes(
250
+ extension
251
+ )
252
+ ) {
253
+ throw new UploadError(
254
+ "FILE_EXTENSION_NOT_ALLOWED",
255
+ `BCP Framework: file extension "${extension || "(none)"}" is not allowed for "${file.name}".`,
256
+ 415
257
+ );
258
+ }
259
+ }
260
+ }
261
+
262
+ export async function saveUploadedFile(
263
+ file: File,
264
+ options: SaveUploadedFileOptions
265
+ ): Promise<SavedUploadedFile> {
266
+ validateUploadedFile(
267
+ file,
268
+ options.constraints
269
+ );
270
+
271
+ const directory =
272
+ path.resolve(
273
+ options.directory
274
+ );
275
+ const requestedName =
276
+ options.fileName ??
277
+ createStoredFileName(
278
+ file.name
279
+ );
280
+ const fileName =
281
+ sanitizeUploadFileName(
282
+ requestedName
283
+ );
284
+ const destination =
285
+ path.resolve(
286
+ directory,
287
+ fileName
288
+ );
289
+ const relative =
290
+ path.relative(
291
+ directory,
292
+ destination
293
+ );
294
+
295
+ if (
296
+ relative === "" ||
297
+ relative.startsWith(
298
+ ".."
299
+ ) ||
300
+ path.isAbsolute(
301
+ relative
302
+ )
303
+ ) {
304
+ throw new UploadError(
305
+ "INVALID_FILE_NAME",
306
+ "BCP Framework: upload destination must remain inside the configured directory.",
307
+ 400
308
+ );
309
+ }
310
+
311
+ await mkdir(
312
+ directory,
313
+ {
314
+ recursive: true,
315
+ }
316
+ );
317
+
318
+ const bytes =
319
+ Buffer.from(
320
+ await file.arrayBuffer()
321
+ );
322
+
323
+ try {
324
+ await writeFile(
325
+ destination,
326
+ bytes,
327
+ {
328
+ flag:
329
+ options.overwrite
330
+ ? "w"
331
+ : "wx",
332
+ }
333
+ );
334
+ } catch (error) {
335
+ if (
336
+ isNodeErrorCode(
337
+ error,
338
+ "EEXIST"
339
+ )
340
+ ) {
341
+ throw new UploadError(
342
+ "FILE_EXISTS",
343
+ `BCP Framework: upload destination "${fileName}" already exists.`,
344
+ 409
345
+ );
346
+ }
347
+
348
+ throw error;
349
+ }
350
+
351
+ return {
352
+ originalName:
353
+ file.name,
354
+ fileName,
355
+ path:
356
+ destination,
357
+ type:
358
+ file.type,
359
+ size:
360
+ file.size,
361
+ checksumSha256:
362
+ createHash(
363
+ "sha256"
364
+ )
365
+ .update(
366
+ bytes
367
+ )
368
+ .digest(
369
+ "hex"
370
+ ),
371
+ };
372
+ }
373
+
374
+ export function sanitizeUploadFileName(
375
+ value: string
376
+ ): string {
377
+ const baseName =
378
+ path.basename(
379
+ value
380
+ .replace(
381
+ /\\/g,
382
+ "/"
383
+ )
384
+ );
385
+ const sanitized =
386
+ baseName
387
+ .replace(
388
+ /[\u0000-\u001F\u007F]/g,
389
+ ""
390
+ )
391
+ .replace(
392
+ /[<>:"/\\|?*]/g,
393
+ "-"
394
+ )
395
+ .replace(
396
+ /\s+/g,
397
+ "-"
398
+ )
399
+ .replace(
400
+ /-+/g,
401
+ "-"
402
+ )
403
+ .replace(
404
+ /^[.\s-]+|[.\s-]+$/g,
405
+ ""
406
+ );
407
+
408
+ if (
409
+ !sanitized ||
410
+ sanitized === "." ||
411
+ sanitized === ".."
412
+ ) {
413
+ throw new UploadError(
414
+ "INVALID_FILE_NAME",
415
+ "BCP Framework: upload file name is invalid.",
416
+ 400
417
+ );
418
+ }
419
+
420
+ return sanitized;
421
+ }
422
+
423
+ function createStoredFileName(
424
+ originalName: string
425
+ ): string {
426
+ const extension =
427
+ path.extname(
428
+ originalName
429
+ )
430
+ .toLowerCase()
431
+ .replace(
432
+ /[^.a-z0-9]/g,
433
+ ""
434
+ )
435
+ .slice(
436
+ 0,
437
+ 16
438
+ );
439
+
440
+ return `${randomUUID()}${extension}`;
441
+ }
442
+
443
+ function estimateFormDataBytes(
444
+ formData: FormData
445
+ ): number {
446
+ let total =
447
+ 0;
448
+
449
+ for (
450
+ const value
451
+ of formData.values()
452
+ ) {
453
+ total +=
454
+ isUploadedFile(
455
+ value
456
+ )
457
+ ? value.size
458
+ : Buffer.byteLength(
459
+ value
460
+ );
461
+ }
462
+
463
+ return total;
464
+ }
465
+
466
+ function isUploadedFile(
467
+ value: FormDataEntryValue | null
468
+ ): value is File {
469
+ return (
470
+ value !== null &&
471
+ typeof value !== "string" &&
472
+ typeof value.name === "string" &&
473
+ typeof value.type === "string" &&
474
+ typeof value.size === "number" &&
475
+ typeof value.arrayBuffer ===
476
+ "function"
477
+ );
478
+ }
479
+
480
+ function normalizeExtension(
481
+ value: string
482
+ ): string {
483
+ const normalized =
484
+ value
485
+ .trim()
486
+ .toLowerCase();
487
+
488
+ if (!normalized) {
489
+ return "";
490
+ }
491
+
492
+ return normalized.startsWith(
493
+ "."
494
+ )
495
+ ? normalized
496
+ : `.${normalized}`;
497
+ }
498
+
499
+ function normalizePositiveLimit(
500
+ value: number | undefined,
501
+ label: string
502
+ ): number | null {
503
+ if (
504
+ value === undefined
505
+ ) {
506
+ return null;
507
+ }
508
+
509
+ if (
510
+ !Number.isSafeInteger(
511
+ value
512
+ ) ||
513
+ value <= 0
514
+ ) {
515
+ throw new TypeError(
516
+ `BCP Framework: upload ${label} must be a positive safe integer.`
517
+ );
518
+ }
519
+
520
+ return value;
521
+ }
522
+
523
+ function readContentLength(
524
+ value: string | null
525
+ ): number | null {
526
+ if (!value) {
527
+ return null;
528
+ }
529
+
530
+ const parsed =
531
+ Number(
532
+ value
533
+ );
534
+
535
+ return Number.isSafeInteger(
536
+ parsed
537
+ ) &&
538
+ parsed >= 0
539
+ ? parsed
540
+ : null;
541
+ }
542
+
543
+ function isNodeErrorCode(
544
+ error: unknown,
545
+ code: string
546
+ ): boolean {
547
+ return (
548
+ error instanceof Error &&
549
+ "code" in error &&
550
+ (
551
+ error as Error & {
552
+ code?: string;
553
+ }
554
+ ).code === code
555
+ );
556
+ }