@beignet/core 0.0.31 → 0.0.33
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/CHANGELOG.md +115 -0
- package/README.md +346 -21
- package/dist/application/index.d.ts.map +1 -1
- package/dist/application/index.js +63 -47
- package/dist/application/index.js.map +1 -1
- package/dist/domain/entity.d.ts +37 -6
- package/dist/domain/entity.d.ts.map +1 -1
- package/dist/domain/entity.js +9 -7
- package/dist/domain/entity.js.map +1 -1
- package/dist/domain/index.d.ts +4 -4
- package/dist/domain/index.d.ts.map +1 -1
- package/dist/domain/index.js +4 -4
- package/dist/domain/index.js.map +1 -1
- package/dist/events/index.d.ts +6 -0
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +20 -10
- package/dist/events/index.js.map +1 -1
- package/dist/flags/index.d.ts +5 -4
- package/dist/flags/index.d.ts.map +1 -1
- package/dist/flags/index.js +10 -9
- package/dist/flags/index.js.map +1 -1
- package/dist/idempotency/index.d.ts +11 -1
- package/dist/idempotency/index.d.ts.map +1 -1
- package/dist/idempotency/index.js +4 -3
- package/dist/idempotency/index.js.map +1 -1
- package/dist/jobs/index.d.ts +449 -9
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js +472 -12
- package/dist/jobs/index.js.map +1 -1
- package/dist/outbox/index.d.ts +170 -3
- package/dist/outbox/index.d.ts.map +1 -1
- package/dist/outbox/index.js +192 -8
- package/dist/outbox/index.js.map +1 -1
- package/dist/ports/events.d.ts +2 -2
- package/dist/ports/index.d.ts +6 -1
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/index.js +1 -0
- package/dist/ports/index.js.map +1 -1
- package/dist/providers/instrumentation.d.ts +4 -0
- package/dist/providers/instrumentation.d.ts.map +1 -1
- package/dist/providers/instrumentation.js.map +1 -1
- package/dist/providers/provider.d.ts +10 -2
- package/dist/providers/provider.d.ts.map +1 -1
- package/dist/providers/provider.js.map +1 -1
- package/dist/schedules/index.d.ts +8 -7
- package/dist/schedules/index.d.ts.map +1 -1
- package/dist/schedules/index.js +47 -16
- package/dist/schedules/index.js.map +1 -1
- package/dist/search/index.d.ts +2 -1
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js +1 -0
- package/dist/search/index.js.map +1 -1
- package/dist/server/hooks/index.d.ts +1 -0
- package/dist/server/hooks/index.d.ts.map +1 -1
- package/dist/server/hooks/index.js +1 -0
- package/dist/server/hooks/index.js.map +1 -1
- package/dist/server/hooks/rate-limit.d.ts +26 -13
- package/dist/server/hooks/rate-limit.d.ts.map +1 -1
- package/dist/server/hooks/rate-limit.js +28 -34
- package/dist/server/hooks/rate-limit.js.map +1 -1
- package/dist/server/hooks/security.d.ts +179 -0
- package/dist/server/hooks/security.d.ts.map +1 -0
- package/dist/server/hooks/security.js +225 -0
- package/dist/server/hooks/security.js.map +1 -0
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +8 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/instrumentation.d.ts.map +1 -1
- package/dist/server/instrumentation.js +22 -6
- package/dist/server/instrumentation.js.map +1 -1
- package/dist/server/providers/loadProviderConfig.d.ts.map +1 -1
- package/dist/server/providers/loadProviderConfig.js +15 -2
- package/dist/server/providers/loadProviderConfig.js.map +1 -1
- package/dist/server/request-context.d.ts +4 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +3 -0
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/runtime-integrity.d.ts +84 -0
- package/dist/server/runtime-integrity.d.ts.map +1 -0
- package/dist/server/runtime-integrity.js +160 -0
- package/dist/server/runtime-integrity.js.map +1 -0
- package/dist/server/server.d.ts +10 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +55 -1
- package/dist/server/server.js.map +1 -1
- package/dist/server/trusted-proxy.d.ts +77 -0
- package/dist/server/trusted-proxy.d.ts.map +1 -0
- package/dist/server/trusted-proxy.js +129 -0
- package/dist/server/trusted-proxy.js.map +1 -0
- package/dist/tasks/index.d.ts +6 -7
- package/dist/tasks/index.d.ts.map +1 -1
- package/dist/tasks/index.js +22 -5
- package/dist/tasks/index.js.map +1 -1
- package/dist/tenancy/index.d.ts +41 -0
- package/dist/tenancy/index.d.ts.map +1 -0
- package/dist/tenancy/index.js +34 -0
- package/dist/tenancy/index.js.map +1 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +12 -3
- package/dist/testing/index.js.map +1 -1
- package/dist/tracing/execution.d.ts +14 -0
- package/dist/tracing/execution.d.ts.map +1 -0
- package/dist/tracing/execution.js +17 -0
- package/dist/tracing/execution.js.map +1 -0
- package/dist/tracing/index.d.ts +49 -0
- package/dist/tracing/index.d.ts.map +1 -1
- package/dist/tracing/index.js +59 -0
- package/dist/tracing/index.js.map +1 -1
- package/dist/uploads/client.d.ts.map +1 -1
- package/dist/uploads/client.js +44 -6
- package/dist/uploads/client.js.map +1 -1
- package/dist/uploads/index.d.ts +182 -2
- package/dist/uploads/index.d.ts.map +1 -1
- package/dist/uploads/index.js +468 -76
- package/dist/uploads/index.js.map +1 -1
- package/dist/webhooks/index.d.ts +56 -4
- package/dist/webhooks/index.d.ts.map +1 -1
- package/dist/webhooks/index.js +107 -2
- package/dist/webhooks/index.js.map +1 -1
- package/package.json +5 -1
- package/skills/app-architecture/SKILL.md +23 -2
- package/src/application/index.ts +88 -56
- package/src/domain/entity.ts +61 -13
- package/src/domain/index.ts +8 -7
- package/src/events/index.ts +27 -14
- package/src/flags/index.ts +23 -19
- package/src/idempotency/index.ts +17 -3
- package/src/jobs/index.ts +1022 -19
- package/src/outbox/index.ts +409 -9
- package/src/ports/events.ts +2 -2
- package/src/ports/index.ts +17 -0
- package/src/providers/instrumentation.ts +4 -0
- package/src/providers/provider.ts +11 -2
- package/src/schedules/index.ts +60 -26
- package/src/search/index.ts +3 -1
- package/src/server/hooks/index.ts +10 -0
- package/src/server/hooks/rate-limit.ts +52 -46
- package/src/server/hooks/security.ts +503 -0
- package/src/server/index.ts +8 -0
- package/src/server/instrumentation.ts +25 -5
- package/src/server/providers/loadProviderConfig.ts +19 -2
- package/src/server/request-context.ts +7 -0
- package/src/server/runtime-integrity.ts +322 -0
- package/src/server/server.ts +86 -1
- package/src/server/trusted-proxy.ts +249 -0
- package/src/tasks/index.ts +29 -12
- package/src/tenancy/index.ts +55 -0
- package/src/testing/index.ts +19 -3
- package/src/tracing/execution.ts +37 -0
- package/src/tracing/index.ts +137 -0
- package/src/uploads/client.ts +65 -6
- package/src/uploads/index.ts +713 -76
- package/src/webhooks/index.ts +240 -9
- package/src/domain/events.ts +0 -59
package/src/uploads/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
|
2
2
|
import type {
|
|
3
3
|
StorageMetadata,
|
|
4
4
|
StorageObject,
|
|
5
|
+
StorageObjectBody,
|
|
5
6
|
StoragePort,
|
|
6
7
|
} from "../ports/index.js";
|
|
7
8
|
import type { ProviderInstrumentationTarget } from "../providers/index.js";
|
|
@@ -39,6 +40,46 @@ export interface UploadFileIntent {
|
|
|
39
40
|
* File size in bytes.
|
|
40
41
|
*/
|
|
41
42
|
size: number;
|
|
43
|
+
/**
|
|
44
|
+
* Optional expected checksum for this file. Direct uploads use this to
|
|
45
|
+
* verify the object bytes during completion.
|
|
46
|
+
*/
|
|
47
|
+
checksum?: UploadFileChecksum;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Checksum algorithms supported by Beignet upload verification.
|
|
52
|
+
*/
|
|
53
|
+
export type UploadChecksumAlgorithm = "sha256";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Expected or computed file checksum.
|
|
57
|
+
*/
|
|
58
|
+
export interface UploadFileChecksum {
|
|
59
|
+
/**
|
|
60
|
+
* Hash algorithm used for the checksum.
|
|
61
|
+
*/
|
|
62
|
+
algorithm: UploadChecksumAlgorithm;
|
|
63
|
+
/**
|
|
64
|
+
* Lowercase hex digest.
|
|
65
|
+
*/
|
|
66
|
+
value: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Checksum verification requested by an upload definition.
|
|
71
|
+
*/
|
|
72
|
+
export interface UploadChecksumRequirement {
|
|
73
|
+
/**
|
|
74
|
+
* Hash algorithm to compute.
|
|
75
|
+
*/
|
|
76
|
+
algorithm: UploadChecksumAlgorithm;
|
|
77
|
+
/**
|
|
78
|
+
* Whether direct upload preparation/completion must include a checksum.
|
|
79
|
+
*
|
|
80
|
+
* @default true
|
|
81
|
+
*/
|
|
82
|
+
required?: boolean;
|
|
42
83
|
}
|
|
43
84
|
|
|
44
85
|
/**
|
|
@@ -69,6 +110,18 @@ export interface UploadFileConstraints {
|
|
|
69
110
|
* Cache-Control value written to storage.
|
|
70
111
|
*/
|
|
71
112
|
cacheControl?: string;
|
|
113
|
+
/**
|
|
114
|
+
* Verify supported MIME types against file signatures instead of trusting
|
|
115
|
+
* only the client-declared content type.
|
|
116
|
+
*
|
|
117
|
+
* @default "signature"
|
|
118
|
+
*/
|
|
119
|
+
contentTypeVerification?: "signature" | false;
|
|
120
|
+
/**
|
|
121
|
+
* Request checksum verification for direct uploads and expose the requirement
|
|
122
|
+
* through the upload manifest so browser clients can send expected digests.
|
|
123
|
+
*/
|
|
124
|
+
checksum?: UploadChecksumRequirement;
|
|
72
125
|
}
|
|
73
126
|
|
|
74
127
|
/**
|
|
@@ -223,6 +276,28 @@ export interface UploadCompleteHookArgs<Metadata, Ctx>
|
|
|
223
276
|
files: CompletedUploadFile[];
|
|
224
277
|
}
|
|
225
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Arguments passed to `verifyFile(...)` after a file exists in storage and
|
|
281
|
+
* before `onComplete(...)` runs.
|
|
282
|
+
*/
|
|
283
|
+
export interface UploadVerifyFileHookArgs<Metadata, Ctx>
|
|
284
|
+
extends UploadHookArgs<Metadata, Ctx> {
|
|
285
|
+
file: CompletedUploadFile;
|
|
286
|
+
storage: StoragePort;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Verification result accepted by `verifyFile(...)`.
|
|
291
|
+
*/
|
|
292
|
+
export type UploadFileVerificationResult =
|
|
293
|
+
| boolean
|
|
294
|
+
| undefined
|
|
295
|
+
| {
|
|
296
|
+
valid: boolean;
|
|
297
|
+
reason?: string;
|
|
298
|
+
details?: unknown;
|
|
299
|
+
};
|
|
300
|
+
|
|
226
301
|
/**
|
|
227
302
|
* Upload definition options.
|
|
228
303
|
*/
|
|
@@ -267,6 +342,13 @@ export interface DefineUploadOptions<
|
|
|
267
342
|
storageMetadata?(
|
|
268
343
|
args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
|
|
269
344
|
): MaybePromise<StorageMetadata>;
|
|
345
|
+
/**
|
|
346
|
+
* Run after a file exists in storage and before `onComplete(...)`. Use this
|
|
347
|
+
* for app-owned scanning, moderation, or quarantine decisions.
|
|
348
|
+
*/
|
|
349
|
+
verifyFile?(
|
|
350
|
+
args: UploadVerifyFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
|
|
351
|
+
): MaybePromise<UploadFileVerificationResult>;
|
|
270
352
|
/**
|
|
271
353
|
* Run after files exist in storage.
|
|
272
354
|
*/
|
|
@@ -299,6 +381,9 @@ export interface UploadDef<
|
|
|
299
381
|
storageMetadata?(
|
|
300
382
|
args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
|
|
301
383
|
): MaybePromise<StorageMetadata>;
|
|
384
|
+
verifyFile?(
|
|
385
|
+
args: UploadVerifyFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
|
|
386
|
+
): MaybePromise<UploadFileVerificationResult>;
|
|
302
387
|
onComplete?(
|
|
303
388
|
args: UploadCompleteHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
|
|
304
389
|
): MaybePromise<Result>;
|
|
@@ -452,14 +537,19 @@ export type UploadErrorCode =
|
|
|
452
537
|
| "INVALID_UPLOAD_BODY";
|
|
453
538
|
|
|
454
539
|
/**
|
|
455
|
-
*
|
|
540
|
+
* Base error for expected upload failures.
|
|
541
|
+
*
|
|
542
|
+
* Upload workflows throw one of the specific subclasses, such as
|
|
543
|
+
* {@link UploadNotFoundError} or {@link UnauthorizedUploadError}. Catch this
|
|
544
|
+
* base class to handle any expected upload failure; the upload router maps it
|
|
545
|
+
* to an HTTP response using `code`, `status`, and `details`.
|
|
456
546
|
*/
|
|
457
547
|
export class UploadError extends Error {
|
|
458
548
|
readonly code: UploadErrorCode;
|
|
459
549
|
readonly status: number;
|
|
460
550
|
readonly details?: unknown;
|
|
461
551
|
|
|
462
|
-
constructor(args: {
|
|
552
|
+
protected constructor(args: {
|
|
463
553
|
code: UploadErrorCode;
|
|
464
554
|
message: string;
|
|
465
555
|
status?: number;
|
|
@@ -473,6 +563,111 @@ export class UploadError extends Error {
|
|
|
473
563
|
}
|
|
474
564
|
}
|
|
475
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Error thrown when an upload name is not registered on the router.
|
|
568
|
+
*/
|
|
569
|
+
export class UploadNotFoundError extends UploadError {
|
|
570
|
+
declare readonly code: "UPLOAD_NOT_FOUND";
|
|
571
|
+
|
|
572
|
+
constructor(args: { message: string; details?: unknown }) {
|
|
573
|
+
super({ code: "UPLOAD_NOT_FOUND", status: 404, ...args });
|
|
574
|
+
this.name = "UploadNotFoundError";
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Error thrown when an upload request targets an unknown upload action.
|
|
580
|
+
*/
|
|
581
|
+
export class InvalidUploadActionError extends UploadError {
|
|
582
|
+
declare readonly code: "INVALID_UPLOAD_ACTION";
|
|
583
|
+
|
|
584
|
+
constructor(args: { message: string; details?: unknown }) {
|
|
585
|
+
super({ code: "INVALID_UPLOAD_ACTION", status: 400, ...args });
|
|
586
|
+
this.name = "InvalidUploadActionError";
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Error thrown when upload metadata fails schema validation.
|
|
592
|
+
*/
|
|
593
|
+
export class InvalidUploadMetadataError extends UploadError {
|
|
594
|
+
declare readonly code: "INVALID_UPLOAD_METADATA";
|
|
595
|
+
|
|
596
|
+
constructor(args: { message: string; details?: unknown }) {
|
|
597
|
+
super({ code: "INVALID_UPLOAD_METADATA", status: 422, ...args });
|
|
598
|
+
this.name = "InvalidUploadMetadataError";
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Error thrown when a file fails upload validation, such as file count,
|
|
604
|
+
* content type (415), size limits (413), or prepared-file mismatches.
|
|
605
|
+
*/
|
|
606
|
+
export class InvalidUploadFileError extends UploadError {
|
|
607
|
+
declare readonly code: "INVALID_UPLOAD_FILE";
|
|
608
|
+
|
|
609
|
+
constructor(args: { message: string; details?: unknown; status?: number }) {
|
|
610
|
+
super({
|
|
611
|
+
code: "INVALID_UPLOAD_FILE",
|
|
612
|
+
message: args.message,
|
|
613
|
+
status: args.status ?? 422,
|
|
614
|
+
details: args.details,
|
|
615
|
+
});
|
|
616
|
+
this.name = "InvalidUploadFileError";
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Error thrown when an upload is denied by authorize(...) or lacks
|
|
622
|
+
* authorization configuration.
|
|
623
|
+
*/
|
|
624
|
+
export class UnauthorizedUploadError extends UploadError {
|
|
625
|
+
declare readonly code: "UNAUTHORIZED_UPLOAD";
|
|
626
|
+
|
|
627
|
+
constructor(args: { message: string; details?: unknown }) {
|
|
628
|
+
super({ code: "UNAUTHORIZED_UPLOAD", status: 403, ...args });
|
|
629
|
+
this.name = "UnauthorizedUploadError";
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Error thrown when a completed upload references a stored object that does
|
|
635
|
+
* not exist.
|
|
636
|
+
*/
|
|
637
|
+
export class UploadObjectNotFoundError extends UploadError {
|
|
638
|
+
declare readonly code: "UPLOAD_OBJECT_NOT_FOUND";
|
|
639
|
+
|
|
640
|
+
constructor(args: { message: string; details?: unknown }) {
|
|
641
|
+
super({ code: "UPLOAD_OBJECT_NOT_FOUND", status: 404, ...args });
|
|
642
|
+
this.name = "UploadObjectNotFoundError";
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Error thrown when an upload request body exceeds the configured size limit.
|
|
648
|
+
*/
|
|
649
|
+
export class UploadBodyTooLargeError extends UploadError {
|
|
650
|
+
declare readonly code: "UPLOAD_BODY_TOO_LARGE";
|
|
651
|
+
|
|
652
|
+
constructor(args: { message: string; details?: unknown }) {
|
|
653
|
+
super({ code: "UPLOAD_BODY_TOO_LARGE", status: 413, ...args });
|
|
654
|
+
this.name = "UploadBodyTooLargeError";
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Error thrown when an upload request body cannot be parsed or has an
|
|
660
|
+
* invalid shape.
|
|
661
|
+
*/
|
|
662
|
+
export class InvalidUploadBodyError extends UploadError {
|
|
663
|
+
declare readonly code: "INVALID_UPLOAD_BODY";
|
|
664
|
+
|
|
665
|
+
constructor(args: { message: string; details?: unknown }) {
|
|
666
|
+
super({ code: "INVALID_UPLOAD_BODY", status: 400, ...args });
|
|
667
|
+
this.name = "InvalidUploadBodyError";
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
476
671
|
/**
|
|
477
672
|
* Define a typed upload workflow.
|
|
478
673
|
*/
|
|
@@ -503,10 +698,52 @@ export function defineUpload<
|
|
|
503
698
|
...(options.storageMetadata
|
|
504
699
|
? { storageMetadata: options.storageMetadata }
|
|
505
700
|
: {}),
|
|
701
|
+
...(options.verifyFile ? { verifyFile: options.verifyFile } : {}),
|
|
506
702
|
...(options.onComplete ? { onComplete: options.onComplete } : {}),
|
|
507
703
|
};
|
|
508
704
|
}
|
|
509
705
|
|
|
706
|
+
/**
|
|
707
|
+
* Context-bound upload helper factory.
|
|
708
|
+
*/
|
|
709
|
+
export interface Uploads<Ctx> {
|
|
710
|
+
/**
|
|
711
|
+
* Define an upload workflow with the bound context type.
|
|
712
|
+
*/
|
|
713
|
+
defineUpload<
|
|
714
|
+
const Name extends string,
|
|
715
|
+
MetadataSchema extends StandardSchema,
|
|
716
|
+
Result = unknown,
|
|
717
|
+
>(
|
|
718
|
+
name: Name,
|
|
719
|
+
options: DefineUploadOptions<MetadataSchema, Ctx, Result>,
|
|
720
|
+
): UploadDef<Name, MetadataSchema, Ctx, Result>;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Create upload helper methods bound to an application context type.
|
|
725
|
+
*
|
|
726
|
+
* Call it once in `lib/uploads.ts`:
|
|
727
|
+
*
|
|
728
|
+
* ```ts
|
|
729
|
+
* export const { defineUpload } = createUploads<AppContext>();
|
|
730
|
+
* ```
|
|
731
|
+
*/
|
|
732
|
+
export function createUploads<Ctx>(): Uploads<Ctx> {
|
|
733
|
+
return {
|
|
734
|
+
defineUpload<
|
|
735
|
+
const Name extends string,
|
|
736
|
+
MetadataSchema extends StandardSchema,
|
|
737
|
+
Result = unknown,
|
|
738
|
+
>(
|
|
739
|
+
name: Name,
|
|
740
|
+
options: DefineUploadOptions<MetadataSchema, Ctx, Result>,
|
|
741
|
+
): UploadDef<Name, MetadataSchema, Ctx, Result> {
|
|
742
|
+
return defineUpload(name, options);
|
|
743
|
+
},
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
510
747
|
/**
|
|
511
748
|
* Define a nested upload registry while preserving upload names and metadata
|
|
512
749
|
* types for client code.
|
|
@@ -612,9 +849,7 @@ export function createUploadRouter<Ctx>(
|
|
|
612
849
|
const registered = [...uploads.keys()]
|
|
613
850
|
.map((registeredName) => `"${registeredName}"`)
|
|
614
851
|
.join(", ");
|
|
615
|
-
throw new
|
|
616
|
-
code: "UPLOAD_NOT_FOUND",
|
|
617
|
-
status: 404,
|
|
852
|
+
throw new UploadNotFoundError({
|
|
618
853
|
message: `Upload "${name}" is not registered. Registered uploads: ${registered || "none"}. Upload routes resolve the defineUpload(...) name, not the defineUploads({...}) registry key.`,
|
|
619
854
|
});
|
|
620
855
|
}
|
|
@@ -634,7 +869,9 @@ export function createUploadRouter<Ctx>(
|
|
|
634
869
|
const parsed = parsePrepareInput(uploadName, input);
|
|
635
870
|
const ctx = await resolveCtx();
|
|
636
871
|
const metadata = await parseMetadata(upload, parsed.metadata);
|
|
637
|
-
assertFiles(upload, parsed.files
|
|
872
|
+
assertFiles(upload, parsed.files, {
|
|
873
|
+
requireChecksum: Boolean(options.signer),
|
|
874
|
+
});
|
|
638
875
|
|
|
639
876
|
const files: PreparedUploadResultFile[] = [];
|
|
640
877
|
for (const file of parsed.files) {
|
|
@@ -706,7 +943,7 @@ export function createUploadRouter<Ctx>(
|
|
|
706
943
|
const parsed = parseCompleteInput(uploadName, input);
|
|
707
944
|
const ctx = await resolveCtx();
|
|
708
945
|
const metadata = await parseMetadata(upload, parsed.metadata);
|
|
709
|
-
assertFiles(upload, parsed.files);
|
|
946
|
+
assertFiles(upload, parsed.files, { requireChecksum: true });
|
|
710
947
|
|
|
711
948
|
const files: CompletedUploadFile[] = [];
|
|
712
949
|
for (const file of parsed.files) {
|
|
@@ -723,9 +960,7 @@ export function createUploadRouter<Ctx>(
|
|
|
723
960
|
uploadId: file.uploadId,
|
|
724
961
|
});
|
|
725
962
|
if (file.key !== expectedKey) {
|
|
726
|
-
throw new
|
|
727
|
-
code: "INVALID_UPLOAD_FILE",
|
|
728
|
-
status: 422,
|
|
963
|
+
throw new InvalidUploadFileError({
|
|
729
964
|
message: `Uploaded object key does not match upload "${upload.name}".`,
|
|
730
965
|
details: {
|
|
731
966
|
expectedKey,
|
|
@@ -733,16 +968,29 @@ export function createUploadRouter<Ctx>(
|
|
|
733
968
|
},
|
|
734
969
|
});
|
|
735
970
|
}
|
|
736
|
-
const object =
|
|
971
|
+
const object = needsUploadBodyVerification(upload, file)
|
|
972
|
+
? await options.storage.get(file.key)
|
|
973
|
+
: await options.storage.stat(file.key);
|
|
737
974
|
if (!object) {
|
|
738
|
-
throw new
|
|
739
|
-
code: "UPLOAD_OBJECT_NOT_FOUND",
|
|
740
|
-
status: 404,
|
|
975
|
+
throw new UploadObjectNotFoundError({
|
|
741
976
|
message: `Uploaded object "${file.key}" was not found.`,
|
|
742
977
|
});
|
|
743
978
|
}
|
|
744
979
|
assertStoredObject(upload, file, object);
|
|
745
|
-
|
|
980
|
+
const verified = await verifyStoredUploadFile(upload, file, object);
|
|
981
|
+
const completedObject = storageObjectMetadata(object);
|
|
982
|
+
const completedFile = {
|
|
983
|
+
...file,
|
|
984
|
+
...(verified.checksum ? { checksum: verified.checksum } : {}),
|
|
985
|
+
object: completedObject,
|
|
986
|
+
};
|
|
987
|
+
await assertVerifiedFile(upload, {
|
|
988
|
+
ctx,
|
|
989
|
+
metadata,
|
|
990
|
+
file: completedFile,
|
|
991
|
+
storage: options.storage,
|
|
992
|
+
});
|
|
993
|
+
files.push(completedFile);
|
|
746
994
|
}
|
|
747
995
|
|
|
748
996
|
const result = await upload.onComplete?.({ ctx, metadata, files });
|
|
@@ -785,46 +1033,58 @@ export function createUploadRouter<Ctx>(
|
|
|
785
1033
|
);
|
|
786
1034
|
const webFiles = filesFromFormData(input.formData);
|
|
787
1035
|
const intents = webFiles.map(fileIntentFromFile);
|
|
788
|
-
assertFiles(definition, intents);
|
|
1036
|
+
assertFiles(definition, intents, { requireChecksum: false });
|
|
789
1037
|
|
|
790
1038
|
const completed: CompletedUploadFile[] = [];
|
|
791
1039
|
for (const [index, file] of webFiles.entries()) {
|
|
792
1040
|
const intent = intents[index];
|
|
793
1041
|
if (!intent) continue;
|
|
1042
|
+
const verified = await verifyBlobUploadFile(definition, intent, file);
|
|
1043
|
+
const verifiedIntent = {
|
|
1044
|
+
...intent,
|
|
1045
|
+
...(verified.checksum ? { checksum: verified.checksum } : {}),
|
|
1046
|
+
};
|
|
794
1047
|
const uploadId = id();
|
|
795
1048
|
await assertAuthorized(definition, {
|
|
796
1049
|
ctx,
|
|
797
1050
|
metadata,
|
|
798
|
-
file:
|
|
1051
|
+
file: verifiedIntent,
|
|
799
1052
|
uploadId,
|
|
800
1053
|
});
|
|
801
1054
|
const key = await definition.key({
|
|
802
1055
|
ctx,
|
|
803
1056
|
metadata,
|
|
804
|
-
file:
|
|
1057
|
+
file: verifiedIntent,
|
|
805
1058
|
uploadId,
|
|
806
1059
|
});
|
|
807
1060
|
const storageMetadata =
|
|
808
1061
|
(await definition.storageMetadata?.({
|
|
809
1062
|
ctx,
|
|
810
1063
|
metadata,
|
|
811
|
-
file:
|
|
1064
|
+
file: verifiedIntent,
|
|
812
1065
|
uploadId,
|
|
813
1066
|
})) ?? {};
|
|
814
1067
|
const object = await options.storage.put(key, file, {
|
|
815
|
-
contentType:
|
|
1068
|
+
contentType: verifiedIntent.contentType,
|
|
816
1069
|
...(definition.file.cacheControl !== undefined
|
|
817
1070
|
? { cacheControl: definition.file.cacheControl }
|
|
818
1071
|
: {}),
|
|
819
1072
|
metadata: storageMetadata,
|
|
820
1073
|
visibility: definition.file.visibility ?? "private",
|
|
821
1074
|
});
|
|
822
|
-
|
|
823
|
-
...
|
|
1075
|
+
const completedFile = {
|
|
1076
|
+
...verifiedIntent,
|
|
824
1077
|
uploadId,
|
|
825
1078
|
key,
|
|
826
1079
|
object,
|
|
1080
|
+
};
|
|
1081
|
+
await assertVerifiedFile(definition, {
|
|
1082
|
+
ctx,
|
|
1083
|
+
metadata,
|
|
1084
|
+
file: completedFile,
|
|
1085
|
+
storage: options.storage,
|
|
827
1086
|
});
|
|
1087
|
+
completed.push(completedFile);
|
|
828
1088
|
}
|
|
829
1089
|
|
|
830
1090
|
const result = await definition.onComplete?.({
|
|
@@ -970,9 +1230,7 @@ function assertUploadContentLengthWithinLimit(
|
|
|
970
1230
|
const actualBytes = Number(contentLength);
|
|
971
1231
|
if (!Number.isFinite(actualBytes) || actualBytes < 0) return;
|
|
972
1232
|
if (actualBytes > context.maxBytes) {
|
|
973
|
-
throw new
|
|
974
|
-
code: "UPLOAD_BODY_TOO_LARGE",
|
|
975
|
-
status: 413,
|
|
1233
|
+
throw new UploadBodyTooLargeError({
|
|
976
1234
|
message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
|
|
977
1235
|
details: {
|
|
978
1236
|
maxBytes: context.maxBytes,
|
|
@@ -996,9 +1254,7 @@ async function readLimitedRequestText(
|
|
|
996
1254
|
const text = await request.text();
|
|
997
1255
|
const actualBytes = new TextEncoder().encode(text).byteLength;
|
|
998
1256
|
if (actualBytes > context.maxBytes) {
|
|
999
|
-
throw new
|
|
1000
|
-
code: "UPLOAD_BODY_TOO_LARGE",
|
|
1001
|
-
status: 413,
|
|
1257
|
+
throw new UploadBodyTooLargeError({
|
|
1002
1258
|
message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
|
|
1003
1259
|
details: {
|
|
1004
1260
|
maxBytes: context.maxBytes,
|
|
@@ -1020,9 +1276,7 @@ async function readLimitedRequestText(
|
|
|
1020
1276
|
if (result.done) break;
|
|
1021
1277
|
actualBytes += result.value.byteLength;
|
|
1022
1278
|
if (actualBytes > context.maxBytes) {
|
|
1023
|
-
throw new
|
|
1024
|
-
code: "UPLOAD_BODY_TOO_LARGE",
|
|
1025
|
-
status: 413,
|
|
1279
|
+
throw new UploadBodyTooLargeError({
|
|
1026
1280
|
message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
|
|
1027
1281
|
details: {
|
|
1028
1282
|
maxBytes: context.maxBytes,
|
|
@@ -1052,9 +1306,7 @@ async function readJsonBody(
|
|
|
1052
1306
|
return JSON.parse(await readLimitedRequestText(request, context));
|
|
1053
1307
|
} catch (error) {
|
|
1054
1308
|
if (error instanceof UploadError) throw error;
|
|
1055
|
-
throw new
|
|
1056
|
-
code: "INVALID_UPLOAD_BODY",
|
|
1057
|
-
status: 400,
|
|
1309
|
+
throw new InvalidUploadBodyError({
|
|
1058
1310
|
message: `Upload "${context.uploadName}" ${context.action} body must be valid JSON.`,
|
|
1059
1311
|
});
|
|
1060
1312
|
}
|
|
@@ -1115,9 +1367,7 @@ function parsePrepareInput(
|
|
|
1115
1367
|
requireCompletedFileFields: false,
|
|
1116
1368
|
});
|
|
1117
1369
|
if (issues.length > 0) {
|
|
1118
|
-
throw new
|
|
1119
|
-
code: "INVALID_UPLOAD_BODY",
|
|
1120
|
-
status: 400,
|
|
1370
|
+
throw new InvalidUploadBodyError({
|
|
1121
1371
|
message: `Upload "${uploadName}" prepare body is invalid.`,
|
|
1122
1372
|
details: { issues },
|
|
1123
1373
|
});
|
|
@@ -1133,9 +1383,7 @@ function parseCompleteInput(
|
|
|
1133
1383
|
requireCompletedFileFields: true,
|
|
1134
1384
|
});
|
|
1135
1385
|
if (issues.length > 0) {
|
|
1136
|
-
throw new
|
|
1137
|
-
code: "INVALID_UPLOAD_BODY",
|
|
1138
|
-
status: 400,
|
|
1386
|
+
throw new InvalidUploadBodyError({
|
|
1139
1387
|
message: `Upload "${uploadName}" complete body is invalid.`,
|
|
1140
1388
|
details: { issues },
|
|
1141
1389
|
});
|
|
@@ -1150,9 +1398,7 @@ async function parseMetadata<U extends UploadDef>(
|
|
|
1150
1398
|
const result = await upload.metadata["~standard"].validate(input);
|
|
1151
1399
|
|
|
1152
1400
|
if (result.issues?.length) {
|
|
1153
|
-
throw new
|
|
1154
|
-
code: "INVALID_UPLOAD_METADATA",
|
|
1155
|
-
status: 422,
|
|
1401
|
+
throw new InvalidUploadMetadataError({
|
|
1156
1402
|
message: `Invalid metadata for upload "${upload.name}".`,
|
|
1157
1403
|
details: { issues: result.issues },
|
|
1158
1404
|
});
|
|
@@ -1168,12 +1414,11 @@ async function parseMetadata<U extends UploadDef>(
|
|
|
1168
1414
|
function assertFiles(
|
|
1169
1415
|
upload: UploadDef,
|
|
1170
1416
|
files: readonly UploadFileIntent[],
|
|
1417
|
+
options: { requireChecksum?: boolean } = {},
|
|
1171
1418
|
): void {
|
|
1172
1419
|
const maxFiles = upload.file.maxFiles ?? 1;
|
|
1173
1420
|
if (files.length === 0 || files.length > maxFiles) {
|
|
1174
|
-
throw new
|
|
1175
|
-
code: "INVALID_UPLOAD_FILE",
|
|
1176
|
-
status: 422,
|
|
1421
|
+
throw new InvalidUploadFileError({
|
|
1177
1422
|
message: `Upload "${upload.name}" requires between 1 and ${maxFiles} file${maxFiles === 1 ? "" : "s"}.`,
|
|
1178
1423
|
details: { fileCount: files.length, maxFiles },
|
|
1179
1424
|
});
|
|
@@ -1181,20 +1426,21 @@ function assertFiles(
|
|
|
1181
1426
|
|
|
1182
1427
|
for (const file of files) {
|
|
1183
1428
|
if (!file.name || !file.contentType || !Number.isFinite(file.size)) {
|
|
1184
|
-
throw new
|
|
1185
|
-
code: "INVALID_UPLOAD_FILE",
|
|
1186
|
-
status: 422,
|
|
1429
|
+
throw new InvalidUploadFileError({
|
|
1187
1430
|
message: `Upload "${upload.name}" received invalid file metadata.`,
|
|
1188
1431
|
details: { file },
|
|
1189
1432
|
});
|
|
1190
1433
|
}
|
|
1191
1434
|
|
|
1435
|
+
assertFileChecksum(upload, file, options);
|
|
1436
|
+
|
|
1192
1437
|
if (
|
|
1193
1438
|
upload.file.contentTypes?.length &&
|
|
1194
|
-
!upload.file.contentTypes
|
|
1439
|
+
!upload.file.contentTypes
|
|
1440
|
+
.map(normalizeContentType)
|
|
1441
|
+
.includes(normalizeContentType(file.contentType))
|
|
1195
1442
|
) {
|
|
1196
|
-
throw new
|
|
1197
|
-
code: "INVALID_UPLOAD_FILE",
|
|
1443
|
+
throw new InvalidUploadFileError({
|
|
1198
1444
|
status: 415,
|
|
1199
1445
|
message: `Upload "${upload.name}" does not accept "${file.contentType}".`,
|
|
1200
1446
|
details: {
|
|
@@ -1208,8 +1454,7 @@ function assertFiles(
|
|
|
1208
1454
|
upload.file.maxSizeBytes !== undefined &&
|
|
1209
1455
|
file.size > upload.file.maxSizeBytes
|
|
1210
1456
|
) {
|
|
1211
|
-
throw new
|
|
1212
|
-
code: "INVALID_UPLOAD_FILE",
|
|
1457
|
+
throw new InvalidUploadFileError({
|
|
1213
1458
|
status: 413,
|
|
1214
1459
|
message: `Upload "${upload.name}" exceeds the maximum file size.`,
|
|
1215
1460
|
details: {
|
|
@@ -1221,6 +1466,408 @@ function assertFiles(
|
|
|
1221
1466
|
}
|
|
1222
1467
|
}
|
|
1223
1468
|
|
|
1469
|
+
function assertFileChecksum(
|
|
1470
|
+
upload: UploadDef,
|
|
1471
|
+
file: UploadFileIntent,
|
|
1472
|
+
options: { requireChecksum?: boolean },
|
|
1473
|
+
): void {
|
|
1474
|
+
const requirement = upload.file.checksum;
|
|
1475
|
+
if (!requirement && !file.checksum) return;
|
|
1476
|
+
|
|
1477
|
+
const configuredAlgorithm = requirement?.algorithm as string | undefined;
|
|
1478
|
+
if (configuredAlgorithm && configuredAlgorithm !== "sha256") {
|
|
1479
|
+
throw new InvalidUploadFileError({
|
|
1480
|
+
message: `Upload "${upload.name}" uses an unsupported checksum algorithm.`,
|
|
1481
|
+
details: {
|
|
1482
|
+
algorithm: configuredAlgorithm,
|
|
1483
|
+
},
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
const required = requirement
|
|
1488
|
+
? requirement.required !== false && options.requireChecksum === true
|
|
1489
|
+
: false;
|
|
1490
|
+
if (!file.checksum) {
|
|
1491
|
+
if (!required) return;
|
|
1492
|
+
throw new InvalidUploadFileError({
|
|
1493
|
+
message: `Upload "${upload.name}" requires a sha256 checksum for "${file.name}".`,
|
|
1494
|
+
details: {
|
|
1495
|
+
fileName: file.name,
|
|
1496
|
+
algorithm: "sha256",
|
|
1497
|
+
},
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
if (
|
|
1502
|
+
file.checksum.algorithm !== "sha256" ||
|
|
1503
|
+
!isSha256Hex(file.checksum.value)
|
|
1504
|
+
) {
|
|
1505
|
+
throw new InvalidUploadFileError({
|
|
1506
|
+
message: `Upload "${upload.name}" received an invalid checksum for "${file.name}".`,
|
|
1507
|
+
details: {
|
|
1508
|
+
fileName: file.name,
|
|
1509
|
+
checksum: file.checksum,
|
|
1510
|
+
},
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const UPLOAD_SIGNATURE_MAX_BYTES = 512;
|
|
1516
|
+
|
|
1517
|
+
type UploadFileBodyVerification = {
|
|
1518
|
+
checksum?: UploadFileChecksum;
|
|
1519
|
+
};
|
|
1520
|
+
|
|
1521
|
+
function needsUploadBodyVerification(
|
|
1522
|
+
upload: UploadDef,
|
|
1523
|
+
file: UploadFileIntent,
|
|
1524
|
+
): boolean {
|
|
1525
|
+
return (
|
|
1526
|
+
needsUploadChecksumVerification(upload, file) ||
|
|
1527
|
+
needsUploadContentTypeVerification(upload, file)
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function needsUploadChecksumVerification(
|
|
1532
|
+
upload: UploadDef,
|
|
1533
|
+
file: UploadFileIntent,
|
|
1534
|
+
): boolean {
|
|
1535
|
+
return Boolean(
|
|
1536
|
+
file.checksum ||
|
|
1537
|
+
(upload.file.checksum && upload.file.checksum.required !== false),
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function needsUploadContentTypeVerification(
|
|
1542
|
+
upload: UploadDef,
|
|
1543
|
+
file: UploadFileIntent,
|
|
1544
|
+
): boolean {
|
|
1545
|
+
if (upload.file.contentTypeVerification === false) return false;
|
|
1546
|
+
|
|
1547
|
+
if (hasContentTypeSignature(file.contentType)) return true;
|
|
1548
|
+
return Boolean(
|
|
1549
|
+
upload.file.contentTypes?.some((contentType) =>
|
|
1550
|
+
hasContentTypeSignature(contentType),
|
|
1551
|
+
),
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
async function verifyBlobUploadFile(
|
|
1556
|
+
upload: UploadDef,
|
|
1557
|
+
file: UploadFileIntent,
|
|
1558
|
+
blob: Blob,
|
|
1559
|
+
): Promise<UploadFileBodyVerification> {
|
|
1560
|
+
if (!needsUploadBodyVerification(upload, file)) return {};
|
|
1561
|
+
|
|
1562
|
+
const needsChecksum = needsUploadChecksumVerification(upload, file);
|
|
1563
|
+
const bytes = needsChecksum
|
|
1564
|
+
? new Uint8Array(await blob.arrayBuffer())
|
|
1565
|
+
: new Uint8Array(
|
|
1566
|
+
await blob.slice(0, UPLOAD_SIGNATURE_MAX_BYTES).arrayBuffer(),
|
|
1567
|
+
);
|
|
1568
|
+
|
|
1569
|
+
assertUploadContentTypeSignature(upload, file, bytes);
|
|
1570
|
+
|
|
1571
|
+
if (!needsChecksum) return {};
|
|
1572
|
+
const checksum = await createUploadChecksum(bytes);
|
|
1573
|
+
assertUploadChecksum(upload, file, checksum);
|
|
1574
|
+
return { checksum };
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
async function verifyStoredUploadFile(
|
|
1578
|
+
upload: UploadDef,
|
|
1579
|
+
file: UploadFileIntent,
|
|
1580
|
+
object: StorageObject | StorageObjectBody,
|
|
1581
|
+
): Promise<UploadFileBodyVerification> {
|
|
1582
|
+
if (!needsUploadBodyVerification(upload, file)) return {};
|
|
1583
|
+
|
|
1584
|
+
if (!("bytes" in object)) {
|
|
1585
|
+
throw new InvalidUploadFileError({
|
|
1586
|
+
message: `Uploaded object "${object.key}" must be readable for verification.`,
|
|
1587
|
+
details: {
|
|
1588
|
+
key: object.key,
|
|
1589
|
+
},
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
const needsChecksum = needsUploadChecksumVerification(upload, file);
|
|
1594
|
+
const bytes = needsChecksum
|
|
1595
|
+
? await object.bytes()
|
|
1596
|
+
: await readObjectPrefix(object, UPLOAD_SIGNATURE_MAX_BYTES);
|
|
1597
|
+
|
|
1598
|
+
assertUploadContentTypeSignature(upload, file, bytes);
|
|
1599
|
+
|
|
1600
|
+
if (!needsChecksum) return {};
|
|
1601
|
+
const checksum = await createUploadChecksum(bytes);
|
|
1602
|
+
assertUploadChecksum(upload, file, checksum);
|
|
1603
|
+
return { checksum };
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function storageObjectMetadata(object: StorageObject): StorageObject {
|
|
1607
|
+
return {
|
|
1608
|
+
key: object.key,
|
|
1609
|
+
size: object.size,
|
|
1610
|
+
...(object.contentType !== undefined
|
|
1611
|
+
? { contentType: object.contentType }
|
|
1612
|
+
: {}),
|
|
1613
|
+
...(object.cacheControl !== undefined
|
|
1614
|
+
? { cacheControl: object.cacheControl }
|
|
1615
|
+
: {}),
|
|
1616
|
+
metadata: object.metadata,
|
|
1617
|
+
visibility: object.visibility,
|
|
1618
|
+
lastModified: object.lastModified,
|
|
1619
|
+
};
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
function assertUploadContentTypeSignature(
|
|
1623
|
+
upload: UploadDef,
|
|
1624
|
+
file: UploadFileIntent,
|
|
1625
|
+
bytes: Uint8Array,
|
|
1626
|
+
): void {
|
|
1627
|
+
if (upload.file.contentTypeVerification === false) return;
|
|
1628
|
+
|
|
1629
|
+
const declaredContentType = normalizeContentType(file.contentType);
|
|
1630
|
+
const acceptedContentTypes =
|
|
1631
|
+
upload.file.contentTypes?.map(normalizeContentType) ?? [];
|
|
1632
|
+
const detectedContentType = detectSupportedContentType(bytes);
|
|
1633
|
+
|
|
1634
|
+
if (
|
|
1635
|
+
detectedContentType &&
|
|
1636
|
+
acceptedContentTypes.length > 0 &&
|
|
1637
|
+
!acceptedContentTypes.includes(detectedContentType)
|
|
1638
|
+
) {
|
|
1639
|
+
throw new InvalidUploadFileError({
|
|
1640
|
+
status: 415,
|
|
1641
|
+
message: `Upload "${upload.name}" does not accept detected content type "${detectedContentType}".`,
|
|
1642
|
+
details: {
|
|
1643
|
+
fileName: file.name,
|
|
1644
|
+
declaredContentType,
|
|
1645
|
+
detectedContentType,
|
|
1646
|
+
acceptedContentTypes,
|
|
1647
|
+
},
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
if (
|
|
1652
|
+
hasContentTypeSignature(declaredContentType) &&
|
|
1653
|
+
!matchesContentTypeSignature(declaredContentType, bytes)
|
|
1654
|
+
) {
|
|
1655
|
+
throw new InvalidUploadFileError({
|
|
1656
|
+
status: 415,
|
|
1657
|
+
message: `Upload "${upload.name}" content type "${declaredContentType}" does not match the file bytes.`,
|
|
1658
|
+
details: {
|
|
1659
|
+
fileName: file.name,
|
|
1660
|
+
expectedContentType: declaredContentType,
|
|
1661
|
+
detectedContentType: detectedContentType ?? "unknown",
|
|
1662
|
+
},
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
function assertUploadChecksum(
|
|
1668
|
+
upload: UploadDef,
|
|
1669
|
+
file: UploadFileIntent,
|
|
1670
|
+
actual: UploadFileChecksum,
|
|
1671
|
+
): void {
|
|
1672
|
+
if (!file.checksum) return;
|
|
1673
|
+
|
|
1674
|
+
if (file.checksum.value.toLowerCase() === actual.value) return;
|
|
1675
|
+
|
|
1676
|
+
throw new InvalidUploadFileError({
|
|
1677
|
+
message: `Upload "${upload.name}" checksum does not match "${file.name}".`,
|
|
1678
|
+
details: {
|
|
1679
|
+
fileName: file.name,
|
|
1680
|
+
algorithm: "sha256",
|
|
1681
|
+
expected: file.checksum.value.toLowerCase(),
|
|
1682
|
+
actual: actual.value,
|
|
1683
|
+
},
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
async function assertVerifiedFile<Metadata, Ctx>(
|
|
1688
|
+
upload: UploadDef<string, StandardSchema, Ctx>,
|
|
1689
|
+
args: UploadVerifyFileHookArgs<Metadata, Ctx>,
|
|
1690
|
+
): Promise<void> {
|
|
1691
|
+
if (!upload.verifyFile) return;
|
|
1692
|
+
|
|
1693
|
+
const result = await upload.verifyFile(
|
|
1694
|
+
args as UploadVerifyFileHookArgs<InferUploadMetadata<typeof upload>, Ctx>,
|
|
1695
|
+
);
|
|
1696
|
+
const invalid =
|
|
1697
|
+
result === false ||
|
|
1698
|
+
(typeof result === "object" &&
|
|
1699
|
+
result !== null &&
|
|
1700
|
+
"valid" in result &&
|
|
1701
|
+
result.valid === false);
|
|
1702
|
+
if (!invalid) return;
|
|
1703
|
+
|
|
1704
|
+
throw new InvalidUploadFileError({
|
|
1705
|
+
message:
|
|
1706
|
+
typeof result === "object" && result?.reason
|
|
1707
|
+
? result.reason
|
|
1708
|
+
: `Upload "${upload.name}" file "${args.file.name}" did not pass verification.`,
|
|
1709
|
+
details:
|
|
1710
|
+
typeof result === "object" && "details" in result
|
|
1711
|
+
? result.details
|
|
1712
|
+
: {
|
|
1713
|
+
fileName: args.file.name,
|
|
1714
|
+
key: args.file.key,
|
|
1715
|
+
},
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
async function readObjectPrefix(
|
|
1720
|
+
object: StorageObjectBody,
|
|
1721
|
+
maxBytes: number,
|
|
1722
|
+
): Promise<Uint8Array> {
|
|
1723
|
+
const reader = object.stream().getReader();
|
|
1724
|
+
const chunks: Uint8Array[] = [];
|
|
1725
|
+
let total = 0;
|
|
1726
|
+
|
|
1727
|
+
try {
|
|
1728
|
+
while (total < maxBytes) {
|
|
1729
|
+
const result = await reader.read();
|
|
1730
|
+
if (result.done) break;
|
|
1731
|
+
const remaining = maxBytes - total;
|
|
1732
|
+
const chunk =
|
|
1733
|
+
result.value.byteLength > remaining
|
|
1734
|
+
? result.value.slice(0, remaining)
|
|
1735
|
+
: result.value;
|
|
1736
|
+
chunks.push(chunk);
|
|
1737
|
+
total += chunk.byteLength;
|
|
1738
|
+
if (result.value.byteLength > remaining) break;
|
|
1739
|
+
}
|
|
1740
|
+
} finally {
|
|
1741
|
+
await reader.cancel().catch(() => undefined);
|
|
1742
|
+
reader.releaseLock();
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
const bytes = new Uint8Array(total);
|
|
1746
|
+
let offset = 0;
|
|
1747
|
+
for (const chunk of chunks) {
|
|
1748
|
+
bytes.set(chunk, offset);
|
|
1749
|
+
offset += chunk.byteLength;
|
|
1750
|
+
}
|
|
1751
|
+
return bytes;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
async function createUploadChecksum(
|
|
1755
|
+
bytes: Uint8Array,
|
|
1756
|
+
): Promise<UploadFileChecksum> {
|
|
1757
|
+
if (!globalThis.crypto?.subtle) {
|
|
1758
|
+
throw new InvalidUploadFileError({
|
|
1759
|
+
message: "Upload checksum verification requires Web Crypto.",
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
const buffer = new ArrayBuffer(bytes.byteLength);
|
|
1764
|
+
new Uint8Array(buffer).set(bytes);
|
|
1765
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", buffer);
|
|
1766
|
+
return {
|
|
1767
|
+
algorithm: "sha256",
|
|
1768
|
+
value: bytesToHex(new Uint8Array(digest)),
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
function bytesToHex(bytes: Uint8Array): string {
|
|
1773
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
function isSha256Hex(value: string): boolean {
|
|
1777
|
+
return /^[a-f0-9]{64}$/i.test(value);
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function normalizeContentType(contentType: string): string {
|
|
1781
|
+
return contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
function hasContentTypeSignature(contentType: string): boolean {
|
|
1785
|
+
return SIGNATURE_CONTENT_TYPES.has(normalizeContentType(contentType));
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function matchesContentTypeSignature(
|
|
1789
|
+
contentType: string,
|
|
1790
|
+
bytes: Uint8Array,
|
|
1791
|
+
): boolean {
|
|
1792
|
+
const normalized = normalizeContentType(contentType);
|
|
1793
|
+
switch (normalized) {
|
|
1794
|
+
case "application/pdf":
|
|
1795
|
+
return startsWithBytes(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d]);
|
|
1796
|
+
case "application/zip":
|
|
1797
|
+
return (
|
|
1798
|
+
startsWithBytes(bytes, [0x50, 0x4b, 0x03, 0x04]) ||
|
|
1799
|
+
startsWithBytes(bytes, [0x50, 0x4b, 0x05, 0x06]) ||
|
|
1800
|
+
startsWithBytes(bytes, [0x50, 0x4b, 0x07, 0x08])
|
|
1801
|
+
);
|
|
1802
|
+
case "image/gif":
|
|
1803
|
+
return (
|
|
1804
|
+
startsWithAscii(bytes, "GIF87a") || startsWithAscii(bytes, "GIF89a")
|
|
1805
|
+
);
|
|
1806
|
+
case "image/jpeg":
|
|
1807
|
+
return startsWithBytes(bytes, [0xff, 0xd8, 0xff]);
|
|
1808
|
+
case "image/png":
|
|
1809
|
+
return startsWithBytes(
|
|
1810
|
+
bytes,
|
|
1811
|
+
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
|
1812
|
+
);
|
|
1813
|
+
case "image/svg+xml":
|
|
1814
|
+
return looksLikeSvg(bytes);
|
|
1815
|
+
case "image/webp":
|
|
1816
|
+
return (
|
|
1817
|
+
startsWithAscii(bytes, "RIFF") &&
|
|
1818
|
+
bytes.length >= 12 &&
|
|
1819
|
+
asciiAt(bytes, 8, 4) === "WEBP"
|
|
1820
|
+
);
|
|
1821
|
+
default:
|
|
1822
|
+
return false;
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function detectSupportedContentType(bytes: Uint8Array): string | undefined {
|
|
1827
|
+
for (const contentType of SIGNATURE_CONTENT_TYPES) {
|
|
1828
|
+
if (matchesContentTypeSignature(contentType, bytes)) return contentType;
|
|
1829
|
+
}
|
|
1830
|
+
return undefined;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
const SIGNATURE_CONTENT_TYPES = new Set([
|
|
1834
|
+
"application/pdf",
|
|
1835
|
+
"application/zip",
|
|
1836
|
+
"image/gif",
|
|
1837
|
+
"image/jpeg",
|
|
1838
|
+
"image/png",
|
|
1839
|
+
"image/svg+xml",
|
|
1840
|
+
"image/webp",
|
|
1841
|
+
]);
|
|
1842
|
+
|
|
1843
|
+
function startsWithBytes(
|
|
1844
|
+
bytes: Uint8Array,
|
|
1845
|
+
prefix: readonly number[],
|
|
1846
|
+
): boolean {
|
|
1847
|
+
if (bytes.length < prefix.length) return false;
|
|
1848
|
+
return prefix.every((byte, index) => bytes[index] === byte);
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
function startsWithAscii(bytes: Uint8Array, prefix: string): boolean {
|
|
1852
|
+
return asciiAt(bytes, 0, prefix.length) === prefix;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
function asciiAt(bytes: Uint8Array, offset: number, length: number): string {
|
|
1856
|
+
return String.fromCharCode(...bytes.slice(offset, offset + length));
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function looksLikeSvg(bytes: Uint8Array): boolean {
|
|
1860
|
+
const text = new TextDecoder()
|
|
1861
|
+
.decode(bytes)
|
|
1862
|
+
.replace(/^\uFEFF/, "")
|
|
1863
|
+
.trimStart()
|
|
1864
|
+
.toLowerCase();
|
|
1865
|
+
return (
|
|
1866
|
+
text.startsWith("<svg") ||
|
|
1867
|
+
(text.startsWith("<?xml") && text.includes("<svg"))
|
|
1868
|
+
);
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1224
1871
|
async function assertAuthorized<Metadata, Ctx>(
|
|
1225
1872
|
upload: UploadDef<string, StandardSchema, Ctx>,
|
|
1226
1873
|
args: UploadFileHookArgs<Metadata, Ctx>,
|
|
@@ -1228,9 +1875,7 @@ async function assertAuthorized<Metadata, Ctx>(
|
|
|
1228
1875
|
if (!upload.authorize) {
|
|
1229
1876
|
if ((upload.access ?? "protected") === "public") return;
|
|
1230
1877
|
|
|
1231
|
-
throw new
|
|
1232
|
-
code: "UNAUTHORIZED_UPLOAD",
|
|
1233
|
-
status: 403,
|
|
1878
|
+
throw new UnauthorizedUploadError({
|
|
1234
1879
|
message: `Upload "${upload.name}" must declare authorize(...) or set access: "public".`,
|
|
1235
1880
|
});
|
|
1236
1881
|
}
|
|
@@ -1246,9 +1891,7 @@ async function assertAuthorized<Metadata, Ctx>(
|
|
|
1246
1891
|
result.allowed === false);
|
|
1247
1892
|
if (!denied) return;
|
|
1248
1893
|
|
|
1249
|
-
throw new
|
|
1250
|
-
code: "UNAUTHORIZED_UPLOAD",
|
|
1251
|
-
status: 403,
|
|
1894
|
+
throw new UnauthorizedUploadError({
|
|
1252
1895
|
message:
|
|
1253
1896
|
typeof result === "object" && result?.reason
|
|
1254
1897
|
? result.reason
|
|
@@ -1267,8 +1910,7 @@ function assertStoredObject(
|
|
|
1267
1910
|
upload.file.maxSizeBytes !== undefined &&
|
|
1268
1911
|
object.size > upload.file.maxSizeBytes
|
|
1269
1912
|
) {
|
|
1270
|
-
throw new
|
|
1271
|
-
code: "INVALID_UPLOAD_FILE",
|
|
1913
|
+
throw new InvalidUploadFileError({
|
|
1272
1914
|
status: 413,
|
|
1273
1915
|
message: `Uploaded object "${object.key}" exceeds the maximum file size.`,
|
|
1274
1916
|
details: {
|
|
@@ -1279,9 +1921,7 @@ function assertStoredObject(
|
|
|
1279
1921
|
}
|
|
1280
1922
|
|
|
1281
1923
|
if (object.size !== file.size) {
|
|
1282
|
-
throw new
|
|
1283
|
-
code: "INVALID_UPLOAD_FILE",
|
|
1284
|
-
status: 422,
|
|
1924
|
+
throw new InvalidUploadFileError({
|
|
1285
1925
|
message: `Uploaded object "${object.key}" size does not match the prepared file.`,
|
|
1286
1926
|
details: {
|
|
1287
1927
|
expected: file.size,
|
|
@@ -1293,11 +1933,10 @@ function assertStoredObject(
|
|
|
1293
1933
|
if (
|
|
1294
1934
|
object.contentType &&
|
|
1295
1935
|
file.contentType &&
|
|
1296
|
-
object.contentType !==
|
|
1936
|
+
normalizeContentType(object.contentType) !==
|
|
1937
|
+
normalizeContentType(file.contentType)
|
|
1297
1938
|
) {
|
|
1298
|
-
throw new
|
|
1299
|
-
code: "INVALID_UPLOAD_FILE",
|
|
1300
|
-
status: 422,
|
|
1939
|
+
throw new InvalidUploadFileError({
|
|
1301
1940
|
message: `Uploaded object "${object.key}" content type does not match the prepared file.`,
|
|
1302
1941
|
details: {
|
|
1303
1942
|
expected: file.contentType,
|
|
@@ -1314,9 +1953,7 @@ function metadataFromFormData(formData: FormData): unknown {
|
|
|
1314
1953
|
try {
|
|
1315
1954
|
return JSON.parse(metadata);
|
|
1316
1955
|
} catch {
|
|
1317
|
-
throw new
|
|
1318
|
-
code: "INVALID_UPLOAD_BODY",
|
|
1319
|
-
status: 400,
|
|
1956
|
+
throw new InvalidUploadBodyError({
|
|
1320
1957
|
message: "Upload metadata must be valid JSON.",
|
|
1321
1958
|
});
|
|
1322
1959
|
}
|
|
@@ -1326,9 +1963,7 @@ function filesFromFormData(formData: FormData): File[] {
|
|
|
1326
1963
|
const values = [...formData.getAll("file"), ...formData.getAll("files")];
|
|
1327
1964
|
const files = values.filter((value): value is File => value instanceof File);
|
|
1328
1965
|
if (files.length === 0) {
|
|
1329
|
-
throw new
|
|
1330
|
-
code: "INVALID_UPLOAD_BODY",
|
|
1331
|
-
status: 400,
|
|
1966
|
+
throw new InvalidUploadBodyError({
|
|
1332
1967
|
message: 'Multipart upload must include at least one "file" field.',
|
|
1333
1968
|
});
|
|
1334
1969
|
}
|
|
@@ -1336,9 +1971,11 @@ function filesFromFormData(formData: FormData): File[] {
|
|
|
1336
1971
|
}
|
|
1337
1972
|
|
|
1338
1973
|
function fileIntentFromFile(file: File): UploadFileIntent {
|
|
1974
|
+
const contentType =
|
|
1975
|
+
normalizeContentType(file.type) || "application/octet-stream";
|
|
1339
1976
|
return {
|
|
1340
1977
|
name: file.name,
|
|
1341
|
-
contentType
|
|
1978
|
+
contentType,
|
|
1342
1979
|
size: file.size,
|
|
1343
1980
|
};
|
|
1344
1981
|
}
|