@chidchanun/bcp 0.1.25 → 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 +169 -263
- package/docs/README.md +148 -210
- package/docs/file-upload.md +194 -32
- package/docs/releases/0.1.26.md +281 -0
- package/docs/s3-storage.md +276 -0
- package/docs/storage.md +278 -162
- package/package.json +5 -2
- package/packages/client/src/database.mjs +236 -0
- package/packages/client/src/server.ts +22 -0
- package/packages/server/src/file-delivery.ts +8 -24
- package/packages/server/src/storage-s3.ts +1159 -0
- package/packages/server/src/storage.ts +740 -54
- package/packages/server/src/upload-stream.ts +846 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
import {
|
|
2
|
+
randomUUID,
|
|
3
|
+
} from "node:crypto";
|
|
4
|
+
import {
|
|
5
|
+
createRequire,
|
|
6
|
+
} from "node:module";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
Readable,
|
|
10
|
+
type Writable,
|
|
11
|
+
} from "node:stream";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
UploadError,
|
|
15
|
+
type UploadConstraints,
|
|
16
|
+
} from "./upload.js";
|
|
17
|
+
import {
|
|
18
|
+
putStorageStream,
|
|
19
|
+
StorageError,
|
|
20
|
+
type StorageAdapter,
|
|
21
|
+
type StorageObjectMetadata,
|
|
22
|
+
} from "./storage.js";
|
|
23
|
+
|
|
24
|
+
const require =
|
|
25
|
+
createRequire(
|
|
26
|
+
import.meta.url
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
interface BusboyFileInfo {
|
|
30
|
+
filename: string;
|
|
31
|
+
encoding: string;
|
|
32
|
+
mimeType: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type MultipartParser =
|
|
36
|
+
Writable & {
|
|
37
|
+
on(
|
|
38
|
+
event: "file",
|
|
39
|
+
listener:
|
|
40
|
+
(
|
|
41
|
+
fieldName: string,
|
|
42
|
+
file: Readable,
|
|
43
|
+
info: BusboyFileInfo
|
|
44
|
+
) => void
|
|
45
|
+
): MultipartParser;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type BusboyFactory =
|
|
49
|
+
(
|
|
50
|
+
options: {
|
|
51
|
+
headers:
|
|
52
|
+
Record<
|
|
53
|
+
string,
|
|
54
|
+
string
|
|
55
|
+
>;
|
|
56
|
+
}
|
|
57
|
+
) => MultipartParser;
|
|
58
|
+
|
|
59
|
+
const createBusboy =
|
|
60
|
+
require(
|
|
61
|
+
"busboy"
|
|
62
|
+
) as BusboyFactory;
|
|
63
|
+
|
|
64
|
+
export interface MultipartStorageFileInfo {
|
|
65
|
+
fieldName: string;
|
|
66
|
+
originalName: string;
|
|
67
|
+
encoding: string;
|
|
68
|
+
contentType: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface MultipartStorageUploadOptions {
|
|
72
|
+
storage: StorageAdapter;
|
|
73
|
+
fieldName: string;
|
|
74
|
+
key?:
|
|
75
|
+
| string
|
|
76
|
+
| (
|
|
77
|
+
(
|
|
78
|
+
file:
|
|
79
|
+
MultipartStorageFileInfo
|
|
80
|
+
) =>
|
|
81
|
+
| string
|
|
82
|
+
| Promise<string>
|
|
83
|
+
);
|
|
84
|
+
overwrite?: boolean;
|
|
85
|
+
maxBytes?: number;
|
|
86
|
+
constraints?: UploadConstraints;
|
|
87
|
+
signal?: AbortSignal;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface StoredMultipartFile
|
|
91
|
+
extends StorageObjectMetadata,
|
|
92
|
+
MultipartStorageFileInfo {}
|
|
93
|
+
|
|
94
|
+
export async function storeMultipartFile(
|
|
95
|
+
request: Request,
|
|
96
|
+
options:
|
|
97
|
+
MultipartStorageUploadOptions
|
|
98
|
+
): Promise<StoredMultipartFile> {
|
|
99
|
+
const fieldName =
|
|
100
|
+
assertNonEmptyString(
|
|
101
|
+
options.fieldName,
|
|
102
|
+
"multipart fieldName"
|
|
103
|
+
);
|
|
104
|
+
const contentType =
|
|
105
|
+
request.headers.get(
|
|
106
|
+
"content-type"
|
|
107
|
+
) ?? "";
|
|
108
|
+
|
|
109
|
+
if (
|
|
110
|
+
!contentType
|
|
111
|
+
.toLowerCase()
|
|
112
|
+
.startsWith(
|
|
113
|
+
"multipart/form-data"
|
|
114
|
+
)
|
|
115
|
+
) {
|
|
116
|
+
throw new UploadError(
|
|
117
|
+
"INVALID_MULTIPART",
|
|
118
|
+
"BCP Framework: streaming upload request must use multipart/form-data.",
|
|
119
|
+
400
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!request.body) {
|
|
124
|
+
throw new UploadError(
|
|
125
|
+
"INVALID_MULTIPART",
|
|
126
|
+
"BCP Framework: streaming multipart request body is missing.",
|
|
127
|
+
400
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const maxRequestBytes =
|
|
132
|
+
normalizePositiveLimit(
|
|
133
|
+
options.maxBytes,
|
|
134
|
+
"maxBytes"
|
|
135
|
+
);
|
|
136
|
+
const contentLength =
|
|
137
|
+
readContentLength(
|
|
138
|
+
request.headers.get(
|
|
139
|
+
"content-length"
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (
|
|
144
|
+
maxRequestBytes !== null &&
|
|
145
|
+
contentLength !== null &&
|
|
146
|
+
contentLength >
|
|
147
|
+
maxRequestBytes
|
|
148
|
+
) {
|
|
149
|
+
throw new UploadError(
|
|
150
|
+
"UPLOAD_TOO_LARGE",
|
|
151
|
+
`BCP Framework: multipart request exceeds the ${maxRequestBytes} byte upload limit.`,
|
|
152
|
+
413
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let parser:
|
|
157
|
+
MultipartParser;
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
parser =
|
|
161
|
+
createBusboy({
|
|
162
|
+
headers:
|
|
163
|
+
Object.fromEntries(
|
|
164
|
+
request.headers
|
|
165
|
+
.entries()
|
|
166
|
+
),
|
|
167
|
+
});
|
|
168
|
+
} catch (error) {
|
|
169
|
+
throw new UploadError(
|
|
170
|
+
"INVALID_MULTIPART",
|
|
171
|
+
`BCP Framework: invalid multipart request (${errorMessage(
|
|
172
|
+
error
|
|
173
|
+
)}).`,
|
|
174
|
+
400
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const uploadAbort =
|
|
179
|
+
new AbortController();
|
|
180
|
+
let targetSeen =
|
|
181
|
+
false;
|
|
182
|
+
const uploadState: {
|
|
183
|
+
current:
|
|
184
|
+
Promise<StoredMultipartFile> |
|
|
185
|
+
null;
|
|
186
|
+
} = {
|
|
187
|
+
current: null,
|
|
188
|
+
};
|
|
189
|
+
let primaryError:
|
|
190
|
+
unknown =
|
|
191
|
+
null;
|
|
192
|
+
let parserSettled =
|
|
193
|
+
false;
|
|
194
|
+
|
|
195
|
+
const recordFailure =
|
|
196
|
+
(error: unknown) => {
|
|
197
|
+
if (primaryError === null) {
|
|
198
|
+
primaryError =
|
|
199
|
+
error;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const fail =
|
|
204
|
+
(error: unknown) => {
|
|
205
|
+
recordFailure(
|
|
206
|
+
error
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
if (
|
|
210
|
+
!uploadAbort.signal
|
|
211
|
+
.aborted
|
|
212
|
+
) {
|
|
213
|
+
uploadAbort.abort(
|
|
214
|
+
error
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (
|
|
219
|
+
!parserSettled &&
|
|
220
|
+
!parser.destroyed
|
|
221
|
+
) {
|
|
222
|
+
parser.destroy(
|
|
223
|
+
asError(
|
|
224
|
+
error
|
|
225
|
+
)
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const parserDone =
|
|
231
|
+
new Promise<void>(
|
|
232
|
+
(
|
|
233
|
+
resolve,
|
|
234
|
+
reject
|
|
235
|
+
) => {
|
|
236
|
+
parser.once(
|
|
237
|
+
"finish",
|
|
238
|
+
() => {
|
|
239
|
+
parserSettled =
|
|
240
|
+
true;
|
|
241
|
+
resolve();
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
parser.once(
|
|
245
|
+
"error",
|
|
246
|
+
(error) => {
|
|
247
|
+
parserSettled =
|
|
248
|
+
true;
|
|
249
|
+
reject(
|
|
250
|
+
error
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
void parserDone.catch(
|
|
258
|
+
() => {
|
|
259
|
+
// The main flow awaits and reports parser failures.
|
|
260
|
+
}
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
parser.on(
|
|
264
|
+
"file",
|
|
265
|
+
(
|
|
266
|
+
incomingField,
|
|
267
|
+
file,
|
|
268
|
+
info
|
|
269
|
+
) => {
|
|
270
|
+
if (
|
|
271
|
+
incomingField !==
|
|
272
|
+
fieldName
|
|
273
|
+
) {
|
|
274
|
+
file.resume();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (targetSeen) {
|
|
279
|
+
file.resume();
|
|
280
|
+
fail(
|
|
281
|
+
new UploadError(
|
|
282
|
+
"INVALID_MULTIPART",
|
|
283
|
+
`BCP Framework: multipart field "${fieldName}" must contain only one streamed file.`,
|
|
284
|
+
400
|
|
285
|
+
)
|
|
286
|
+
);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
targetSeen =
|
|
291
|
+
true;
|
|
292
|
+
|
|
293
|
+
let fileInfo:
|
|
294
|
+
MultipartStorageFileInfo;
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
fileInfo =
|
|
298
|
+
createFileInfo(
|
|
299
|
+
incomingField,
|
|
300
|
+
info,
|
|
301
|
+
options.constraints
|
|
302
|
+
);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
recordFailure(
|
|
305
|
+
error
|
|
306
|
+
);
|
|
307
|
+
file.resume();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
uploadState.current =
|
|
312
|
+
(async () => {
|
|
313
|
+
const key =
|
|
314
|
+
await resolveStorageKey(
|
|
315
|
+
options.key,
|
|
316
|
+
fileInfo
|
|
317
|
+
);
|
|
318
|
+
const stream =
|
|
319
|
+
Readable.toWeb(
|
|
320
|
+
file
|
|
321
|
+
) as ReadableStream<
|
|
322
|
+
Uint8Array
|
|
323
|
+
>;
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
const metadata =
|
|
327
|
+
await putStorageStream(
|
|
328
|
+
options.storage,
|
|
329
|
+
key,
|
|
330
|
+
stream,
|
|
331
|
+
{
|
|
332
|
+
overwrite:
|
|
333
|
+
options.overwrite,
|
|
334
|
+
contentType:
|
|
335
|
+
fileInfo.contentType,
|
|
336
|
+
maxBytes:
|
|
337
|
+
normalizePositiveLimit(
|
|
338
|
+
options.constraints
|
|
339
|
+
?.maxBytes,
|
|
340
|
+
"file maxBytes"
|
|
341
|
+
) ??
|
|
342
|
+
undefined,
|
|
343
|
+
signal:
|
|
344
|
+
uploadAbort.signal,
|
|
345
|
+
}
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
...metadata,
|
|
350
|
+
...fileInfo,
|
|
351
|
+
};
|
|
352
|
+
} catch (error) {
|
|
353
|
+
const mapped =
|
|
354
|
+
mapStorageUploadError(
|
|
355
|
+
error,
|
|
356
|
+
fileInfo.originalName,
|
|
357
|
+
key
|
|
358
|
+
);
|
|
359
|
+
fail(
|
|
360
|
+
mapped
|
|
361
|
+
);
|
|
362
|
+
throw mapped;
|
|
363
|
+
}
|
|
364
|
+
})();
|
|
365
|
+
|
|
366
|
+
void uploadState.current.catch(
|
|
367
|
+
() => {
|
|
368
|
+
// The main flow awaits and reports this failure.
|
|
369
|
+
}
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const onCallerAbort =
|
|
375
|
+
() => {
|
|
376
|
+
fail(
|
|
377
|
+
options.signal
|
|
378
|
+
?.reason ??
|
|
379
|
+
new Error(
|
|
380
|
+
"BCP Framework: streaming multipart upload was aborted."
|
|
381
|
+
)
|
|
382
|
+
);
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
options.signal
|
|
386
|
+
?.addEventListener(
|
|
387
|
+
"abort",
|
|
388
|
+
onCallerAbort,
|
|
389
|
+
{
|
|
390
|
+
once: true,
|
|
391
|
+
}
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
options.signal
|
|
396
|
+
?.throwIfAborted();
|
|
397
|
+
|
|
398
|
+
await pumpRequestBody(
|
|
399
|
+
request.body,
|
|
400
|
+
parser,
|
|
401
|
+
maxRequestBytes
|
|
402
|
+
);
|
|
403
|
+
await parserDone;
|
|
404
|
+
|
|
405
|
+
if (primaryError !== null) {
|
|
406
|
+
throw primaryError;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const uploadPromise =
|
|
410
|
+
uploadState.current;
|
|
411
|
+
|
|
412
|
+
if (
|
|
413
|
+
!targetSeen ||
|
|
414
|
+
uploadPromise === null
|
|
415
|
+
) {
|
|
416
|
+
throw new UploadError(
|
|
417
|
+
"FILE_REQUIRED",
|
|
418
|
+
`BCP Framework: upload field "${fieldName}" must contain a file.`,
|
|
419
|
+
400
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return await uploadPromise;
|
|
424
|
+
} catch (error) {
|
|
425
|
+
fail(
|
|
426
|
+
error
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const uploadPromise =
|
|
430
|
+
uploadState.current;
|
|
431
|
+
|
|
432
|
+
if (uploadPromise !== null) {
|
|
433
|
+
await uploadPromise.catch(
|
|
434
|
+
() => {
|
|
435
|
+
// Preserve the primary parser/upload error.
|
|
436
|
+
}
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
throw primaryError ??
|
|
441
|
+
error;
|
|
442
|
+
} finally {
|
|
443
|
+
options.signal
|
|
444
|
+
?.removeEventListener(
|
|
445
|
+
"abort",
|
|
446
|
+
onCallerAbort
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function pumpRequestBody(
|
|
452
|
+
body:
|
|
453
|
+
ReadableStream<Uint8Array>,
|
|
454
|
+
parser: MultipartParser,
|
|
455
|
+
maxBytes: number | null
|
|
456
|
+
): Promise<void> {
|
|
457
|
+
const reader =
|
|
458
|
+
body.getReader();
|
|
459
|
+
let totalBytes =
|
|
460
|
+
0;
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
while (true) {
|
|
464
|
+
const result =
|
|
465
|
+
await reader.read();
|
|
466
|
+
|
|
467
|
+
if (result.done) {
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const nextTotal =
|
|
472
|
+
totalBytes +
|
|
473
|
+
result.value.byteLength;
|
|
474
|
+
|
|
475
|
+
if (
|
|
476
|
+
maxBytes !== null &&
|
|
477
|
+
nextTotal > maxBytes
|
|
478
|
+
) {
|
|
479
|
+
throw new UploadError(
|
|
480
|
+
"UPLOAD_TOO_LARGE",
|
|
481
|
+
`BCP Framework: multipart payload exceeds the ${maxBytes} byte upload limit.`,
|
|
482
|
+
413
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
totalBytes =
|
|
487
|
+
nextTotal;
|
|
488
|
+
|
|
489
|
+
if (
|
|
490
|
+
!parser.write(
|
|
491
|
+
Buffer.from(
|
|
492
|
+
result.value
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
) {
|
|
496
|
+
await waitForDrain(
|
|
497
|
+
parser
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
parser.end();
|
|
503
|
+
} catch (error) {
|
|
504
|
+
try {
|
|
505
|
+
await reader.cancel(
|
|
506
|
+
error
|
|
507
|
+
);
|
|
508
|
+
} catch {
|
|
509
|
+
// Preserve the parser/upload failure.
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
throw error;
|
|
513
|
+
} finally {
|
|
514
|
+
reader.releaseLock();
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function waitForDrain(
|
|
519
|
+
parser: MultipartParser
|
|
520
|
+
): Promise<void> {
|
|
521
|
+
return new Promise<void>(
|
|
522
|
+
(
|
|
523
|
+
resolve,
|
|
524
|
+
reject
|
|
525
|
+
) => {
|
|
526
|
+
const cleanup =
|
|
527
|
+
() => {
|
|
528
|
+
parser.off(
|
|
529
|
+
"drain",
|
|
530
|
+
onDrain
|
|
531
|
+
);
|
|
532
|
+
parser.off(
|
|
533
|
+
"error",
|
|
534
|
+
onError
|
|
535
|
+
);
|
|
536
|
+
};
|
|
537
|
+
const onDrain =
|
|
538
|
+
() => {
|
|
539
|
+
cleanup();
|
|
540
|
+
resolve();
|
|
541
|
+
};
|
|
542
|
+
const onError =
|
|
543
|
+
(error: Error) => {
|
|
544
|
+
cleanup();
|
|
545
|
+
reject(
|
|
546
|
+
error
|
|
547
|
+
);
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
parser.once(
|
|
551
|
+
"drain",
|
|
552
|
+
onDrain
|
|
553
|
+
);
|
|
554
|
+
parser.once(
|
|
555
|
+
"error",
|
|
556
|
+
onError
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function createFileInfo(
|
|
563
|
+
fieldName: string,
|
|
564
|
+
info: BusboyFileInfo,
|
|
565
|
+
constraints:
|
|
566
|
+
UploadConstraints |
|
|
567
|
+
undefined
|
|
568
|
+
): MultipartStorageFileInfo {
|
|
569
|
+
const originalName =
|
|
570
|
+
path.basename(
|
|
571
|
+
String(
|
|
572
|
+
info.filename ??
|
|
573
|
+
""
|
|
574
|
+
).replace(
|
|
575
|
+
/\\/g,
|
|
576
|
+
"/"
|
|
577
|
+
)
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
if (
|
|
581
|
+
originalName.length === 0 ||
|
|
582
|
+
originalName === "." ||
|
|
583
|
+
originalName === ".."
|
|
584
|
+
) {
|
|
585
|
+
throw new UploadError(
|
|
586
|
+
"INVALID_FILE_NAME",
|
|
587
|
+
"BCP Framework: streamed upload file name is invalid.",
|
|
588
|
+
400
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const contentType =
|
|
593
|
+
String(
|
|
594
|
+
info.mimeType ??
|
|
595
|
+
""
|
|
596
|
+
)
|
|
597
|
+
.trim()
|
|
598
|
+
.toLowerCase() ||
|
|
599
|
+
"application/octet-stream";
|
|
600
|
+
|
|
601
|
+
if (
|
|
602
|
+
constraints?.allowedTypes &&
|
|
603
|
+
constraints.allowedTypes.length >
|
|
604
|
+
0
|
|
605
|
+
) {
|
|
606
|
+
const allowedTypes =
|
|
607
|
+
constraints.allowedTypes.map(
|
|
608
|
+
(value) =>
|
|
609
|
+
value
|
|
610
|
+
.trim()
|
|
611
|
+
.toLowerCase()
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
if (
|
|
615
|
+
!allowedTypes.includes(
|
|
616
|
+
contentType
|
|
617
|
+
)
|
|
618
|
+
) {
|
|
619
|
+
throw new UploadError(
|
|
620
|
+
"FILE_TYPE_NOT_ALLOWED",
|
|
621
|
+
`BCP Framework: file type "${contentType}" is not allowed for "${originalName}".`,
|
|
622
|
+
415
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (
|
|
628
|
+
constraints?.allowedExtensions &&
|
|
629
|
+
constraints.allowedExtensions.length >
|
|
630
|
+
0
|
|
631
|
+
) {
|
|
632
|
+
const extension =
|
|
633
|
+
path.extname(
|
|
634
|
+
originalName
|
|
635
|
+
).toLowerCase();
|
|
636
|
+
const allowedExtensions =
|
|
637
|
+
constraints.allowedExtensions.map(
|
|
638
|
+
normalizeExtension
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
if (
|
|
642
|
+
!allowedExtensions.includes(
|
|
643
|
+
extension
|
|
644
|
+
)
|
|
645
|
+
) {
|
|
646
|
+
throw new UploadError(
|
|
647
|
+
"FILE_EXTENSION_NOT_ALLOWED",
|
|
648
|
+
`BCP Framework: file extension "${extension || "(none)"}" is not allowed for "${originalName}".`,
|
|
649
|
+
415
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return {
|
|
655
|
+
fieldName,
|
|
656
|
+
originalName,
|
|
657
|
+
encoding:
|
|
658
|
+
String(
|
|
659
|
+
info.encoding ??
|
|
660
|
+
""
|
|
661
|
+
),
|
|
662
|
+
contentType,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function resolveStorageKey(
|
|
667
|
+
value:
|
|
668
|
+
MultipartStorageUploadOptions[
|
|
669
|
+
"key"
|
|
670
|
+
],
|
|
671
|
+
file:
|
|
672
|
+
MultipartStorageFileInfo
|
|
673
|
+
): Promise<string> {
|
|
674
|
+
if (
|
|
675
|
+
typeof value ===
|
|
676
|
+
"function"
|
|
677
|
+
) {
|
|
678
|
+
return value(
|
|
679
|
+
file
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (
|
|
684
|
+
typeof value ===
|
|
685
|
+
"string"
|
|
686
|
+
) {
|
|
687
|
+
return value;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const extension =
|
|
691
|
+
path.extname(
|
|
692
|
+
file.originalName
|
|
693
|
+
)
|
|
694
|
+
.toLowerCase()
|
|
695
|
+
.replace(
|
|
696
|
+
/[^.a-z0-9]/g,
|
|
697
|
+
""
|
|
698
|
+
)
|
|
699
|
+
.slice(
|
|
700
|
+
0,
|
|
701
|
+
16
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
return `${randomUUID()}${extension}`;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function mapStorageUploadError(
|
|
708
|
+
error: unknown,
|
|
709
|
+
originalName: string,
|
|
710
|
+
key: string
|
|
711
|
+
): unknown {
|
|
712
|
+
if (
|
|
713
|
+
!(error instanceof
|
|
714
|
+
StorageError)
|
|
715
|
+
) {
|
|
716
|
+
return error;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (
|
|
720
|
+
error.code ===
|
|
721
|
+
"STREAM_TOO_LARGE"
|
|
722
|
+
) {
|
|
723
|
+
return new UploadError(
|
|
724
|
+
"FILE_TOO_LARGE",
|
|
725
|
+
`BCP Framework: file "${originalName}" exceeded the configured streaming file limit.`,
|
|
726
|
+
413
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (
|
|
731
|
+
error.code ===
|
|
732
|
+
"OBJECT_EXISTS"
|
|
733
|
+
) {
|
|
734
|
+
return new UploadError(
|
|
735
|
+
"FILE_EXISTS",
|
|
736
|
+
`BCP Framework: upload storage key "${key}" already exists.`,
|
|
737
|
+
409
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return error;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function normalizePositiveLimit(
|
|
745
|
+
value: number | undefined,
|
|
746
|
+
label: string
|
|
747
|
+
): number | null {
|
|
748
|
+
if (value === undefined) {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (
|
|
753
|
+
!Number.isSafeInteger(
|
|
754
|
+
value
|
|
755
|
+
) ||
|
|
756
|
+
value <= 0
|
|
757
|
+
) {
|
|
758
|
+
throw new TypeError(
|
|
759
|
+
`BCP Framework: streaming upload ${label} must be a positive safe integer.`
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return value;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function readContentLength(
|
|
767
|
+
value: string | null
|
|
768
|
+
): number | null {
|
|
769
|
+
if (!value) {
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (!/^\d+$/.test(value)) {
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const parsed =
|
|
778
|
+
Number(
|
|
779
|
+
value
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
return Number.isSafeInteger(
|
|
783
|
+
parsed
|
|
784
|
+
)
|
|
785
|
+
? parsed
|
|
786
|
+
: null;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function normalizeExtension(
|
|
790
|
+
value: string
|
|
791
|
+
): string {
|
|
792
|
+
const normalized =
|
|
793
|
+
value
|
|
794
|
+
.trim()
|
|
795
|
+
.toLowerCase();
|
|
796
|
+
|
|
797
|
+
if (normalized.length === 0) {
|
|
798
|
+
return normalized;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return normalized.startsWith(
|
|
802
|
+
"."
|
|
803
|
+
)
|
|
804
|
+
? normalized
|
|
805
|
+
: `.${normalized}`;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function assertNonEmptyString(
|
|
809
|
+
value: string,
|
|
810
|
+
label: string
|
|
811
|
+
): string {
|
|
812
|
+
if (
|
|
813
|
+
typeof value !== "string" ||
|
|
814
|
+
value.trim().length === 0
|
|
815
|
+
) {
|
|
816
|
+
throw new TypeError(
|
|
817
|
+
`BCP Framework: ${label} must be a non-empty string.`
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return value.trim();
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function asError(
|
|
825
|
+
value: unknown
|
|
826
|
+
): Error {
|
|
827
|
+
if (value instanceof Error) {
|
|
828
|
+
return value;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return new Error(
|
|
832
|
+
errorMessage(
|
|
833
|
+
value
|
|
834
|
+
)
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function errorMessage(
|
|
839
|
+
value: unknown
|
|
840
|
+
): string {
|
|
841
|
+
return value instanceof Error
|
|
842
|
+
? value.message
|
|
843
|
+
: String(
|
|
844
|
+
value
|
|
845
|
+
);
|
|
846
|
+
}
|