@beignet/core 0.0.32 → 0.0.34
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 +42 -0
- package/README.md +409 -34
- 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/contracts/contract-builder.d.ts +0 -4
- package/dist/contracts/contract-builder.d.ts.map +1 -1
- package/dist/contracts/contract-builder.js +0 -6
- package/dist/contracts/contract-builder.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 +0 -4
- package/dist/flags/index.d.ts.map +1 -1
- package/dist/flags/index.js +0 -4
- package/dist/flags/index.js.map +1 -1
- package/dist/jobs/index.d.ts +415 -4
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js +392 -3
- package/dist/jobs/index.js.map +1 -1
- package/dist/notifications/index.d.ts +194 -10
- package/dist/notifications/index.d.ts.map +1 -1
- package/dist/notifications/index.js +397 -59
- package/dist/notifications/index.js.map +1 -1
- package/dist/outbox/index.d.ts +150 -1
- package/dist/outbox/index.d.ts.map +1 -1
- package/dist/outbox/index.js +170 -1
- package/dist/outbox/index.js.map +1 -1
- package/dist/ports/events.d.ts +2 -2
- package/dist/ports/index.d.ts +7 -2
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/index.js +2 -1
- 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 +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/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/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 -10
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +75 -11
- 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 +1 -1
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +1 -0
- 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 +71 -0
- package/dist/uploads/index.d.ts.map +1 -1
- package/dist/uploads/index.js +344 -15
- package/dist/uploads/index.js.map +1 -1
- package/dist/webhooks/index.d.ts +55 -4
- package/dist/webhooks/index.d.ts.map +1 -1
- package/dist/webhooks/index.js +106 -2
- package/dist/webhooks/index.js.map +1 -1
- package/package.json +5 -5
- package/skills/app-architecture/SKILL.md +31 -2
- package/src/application/index.ts +88 -56
- package/src/contracts/contract-builder.ts +0 -7
- package/src/events/index.ts +27 -14
- package/src/flags/index.ts +0 -5
- package/src/jobs/index.ts +895 -7
- package/src/notifications/index.ts +678 -70
- package/src/outbox/index.ts +358 -2
- package/src/ports/events.ts +2 -2
- package/src/ports/index.ts +17 -1
- package/src/providers/instrumentation.ts +4 -0
- package/src/providers/provider.ts +1 -1
- package/src/schedules/index.ts +60 -26
- 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/request-context.ts +7 -0
- package/src/server/runtime-integrity.ts +322 -0
- package/src/server/server.ts +88 -35
- 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 +2 -0
- 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 +539 -14
- package/src/webhooks/index.ts +238 -9
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>;
|
|
@@ -613,6 +698,7 @@ export function defineUpload<
|
|
|
613
698
|
...(options.storageMetadata
|
|
614
699
|
? { storageMetadata: options.storageMetadata }
|
|
615
700
|
: {}),
|
|
701
|
+
...(options.verifyFile ? { verifyFile: options.verifyFile } : {}),
|
|
616
702
|
...(options.onComplete ? { onComplete: options.onComplete } : {}),
|
|
617
703
|
};
|
|
618
704
|
}
|
|
@@ -783,7 +869,9 @@ export function createUploadRouter<Ctx>(
|
|
|
783
869
|
const parsed = parsePrepareInput(uploadName, input);
|
|
784
870
|
const ctx = await resolveCtx();
|
|
785
871
|
const metadata = await parseMetadata(upload, parsed.metadata);
|
|
786
|
-
assertFiles(upload, parsed.files
|
|
872
|
+
assertFiles(upload, parsed.files, {
|
|
873
|
+
requireChecksum: Boolean(options.signer),
|
|
874
|
+
});
|
|
787
875
|
|
|
788
876
|
const files: PreparedUploadResultFile[] = [];
|
|
789
877
|
for (const file of parsed.files) {
|
|
@@ -855,7 +943,7 @@ export function createUploadRouter<Ctx>(
|
|
|
855
943
|
const parsed = parseCompleteInput(uploadName, input);
|
|
856
944
|
const ctx = await resolveCtx();
|
|
857
945
|
const metadata = await parseMetadata(upload, parsed.metadata);
|
|
858
|
-
assertFiles(upload, parsed.files);
|
|
946
|
+
assertFiles(upload, parsed.files, { requireChecksum: true });
|
|
859
947
|
|
|
860
948
|
const files: CompletedUploadFile[] = [];
|
|
861
949
|
for (const file of parsed.files) {
|
|
@@ -880,14 +968,29 @@ export function createUploadRouter<Ctx>(
|
|
|
880
968
|
},
|
|
881
969
|
});
|
|
882
970
|
}
|
|
883
|
-
const object =
|
|
971
|
+
const object = needsUploadBodyVerification(upload, file)
|
|
972
|
+
? await options.storage.get(file.key)
|
|
973
|
+
: await options.storage.stat(file.key);
|
|
884
974
|
if (!object) {
|
|
885
975
|
throw new UploadObjectNotFoundError({
|
|
886
976
|
message: `Uploaded object "${file.key}" was not found.`,
|
|
887
977
|
});
|
|
888
978
|
}
|
|
889
979
|
assertStoredObject(upload, file, object);
|
|
890
|
-
|
|
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);
|
|
891
994
|
}
|
|
892
995
|
|
|
893
996
|
const result = await upload.onComplete?.({ ctx, metadata, files });
|
|
@@ -930,46 +1033,58 @@ export function createUploadRouter<Ctx>(
|
|
|
930
1033
|
);
|
|
931
1034
|
const webFiles = filesFromFormData(input.formData);
|
|
932
1035
|
const intents = webFiles.map(fileIntentFromFile);
|
|
933
|
-
assertFiles(definition, intents);
|
|
1036
|
+
assertFiles(definition, intents, { requireChecksum: false });
|
|
934
1037
|
|
|
935
1038
|
const completed: CompletedUploadFile[] = [];
|
|
936
1039
|
for (const [index, file] of webFiles.entries()) {
|
|
937
1040
|
const intent = intents[index];
|
|
938
1041
|
if (!intent) continue;
|
|
1042
|
+
const verified = await verifyBlobUploadFile(definition, intent, file);
|
|
1043
|
+
const verifiedIntent = {
|
|
1044
|
+
...intent,
|
|
1045
|
+
...(verified.checksum ? { checksum: verified.checksum } : {}),
|
|
1046
|
+
};
|
|
939
1047
|
const uploadId = id();
|
|
940
1048
|
await assertAuthorized(definition, {
|
|
941
1049
|
ctx,
|
|
942
1050
|
metadata,
|
|
943
|
-
file:
|
|
1051
|
+
file: verifiedIntent,
|
|
944
1052
|
uploadId,
|
|
945
1053
|
});
|
|
946
1054
|
const key = await definition.key({
|
|
947
1055
|
ctx,
|
|
948
1056
|
metadata,
|
|
949
|
-
file:
|
|
1057
|
+
file: verifiedIntent,
|
|
950
1058
|
uploadId,
|
|
951
1059
|
});
|
|
952
1060
|
const storageMetadata =
|
|
953
1061
|
(await definition.storageMetadata?.({
|
|
954
1062
|
ctx,
|
|
955
1063
|
metadata,
|
|
956
|
-
file:
|
|
1064
|
+
file: verifiedIntent,
|
|
957
1065
|
uploadId,
|
|
958
1066
|
})) ?? {};
|
|
959
1067
|
const object = await options.storage.put(key, file, {
|
|
960
|
-
contentType:
|
|
1068
|
+
contentType: verifiedIntent.contentType,
|
|
961
1069
|
...(definition.file.cacheControl !== undefined
|
|
962
1070
|
? { cacheControl: definition.file.cacheControl }
|
|
963
1071
|
: {}),
|
|
964
1072
|
metadata: storageMetadata,
|
|
965
1073
|
visibility: definition.file.visibility ?? "private",
|
|
966
1074
|
});
|
|
967
|
-
|
|
968
|
-
...
|
|
1075
|
+
const completedFile = {
|
|
1076
|
+
...verifiedIntent,
|
|
969
1077
|
uploadId,
|
|
970
1078
|
key,
|
|
971
1079
|
object,
|
|
1080
|
+
};
|
|
1081
|
+
await assertVerifiedFile(definition, {
|
|
1082
|
+
ctx,
|
|
1083
|
+
metadata,
|
|
1084
|
+
file: completedFile,
|
|
1085
|
+
storage: options.storage,
|
|
972
1086
|
});
|
|
1087
|
+
completed.push(completedFile);
|
|
973
1088
|
}
|
|
974
1089
|
|
|
975
1090
|
const result = await definition.onComplete?.({
|
|
@@ -1299,6 +1414,7 @@ async function parseMetadata<U extends UploadDef>(
|
|
|
1299
1414
|
function assertFiles(
|
|
1300
1415
|
upload: UploadDef,
|
|
1301
1416
|
files: readonly UploadFileIntent[],
|
|
1417
|
+
options: { requireChecksum?: boolean } = {},
|
|
1302
1418
|
): void {
|
|
1303
1419
|
const maxFiles = upload.file.maxFiles ?? 1;
|
|
1304
1420
|
if (files.length === 0 || files.length > maxFiles) {
|
|
@@ -1316,9 +1432,13 @@ function assertFiles(
|
|
|
1316
1432
|
});
|
|
1317
1433
|
}
|
|
1318
1434
|
|
|
1435
|
+
assertFileChecksum(upload, file, options);
|
|
1436
|
+
|
|
1319
1437
|
if (
|
|
1320
1438
|
upload.file.contentTypes?.length &&
|
|
1321
|
-
!upload.file.contentTypes
|
|
1439
|
+
!upload.file.contentTypes
|
|
1440
|
+
.map(normalizeContentType)
|
|
1441
|
+
.includes(normalizeContentType(file.contentType))
|
|
1322
1442
|
) {
|
|
1323
1443
|
throw new InvalidUploadFileError({
|
|
1324
1444
|
status: 415,
|
|
@@ -1346,6 +1466,408 @@ function assertFiles(
|
|
|
1346
1466
|
}
|
|
1347
1467
|
}
|
|
1348
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
|
+
|
|
1349
1871
|
async function assertAuthorized<Metadata, Ctx>(
|
|
1350
1872
|
upload: UploadDef<string, StandardSchema, Ctx>,
|
|
1351
1873
|
args: UploadFileHookArgs<Metadata, Ctx>,
|
|
@@ -1411,7 +1933,8 @@ function assertStoredObject(
|
|
|
1411
1933
|
if (
|
|
1412
1934
|
object.contentType &&
|
|
1413
1935
|
file.contentType &&
|
|
1414
|
-
object.contentType !==
|
|
1936
|
+
normalizeContentType(object.contentType) !==
|
|
1937
|
+
normalizeContentType(file.contentType)
|
|
1415
1938
|
) {
|
|
1416
1939
|
throw new InvalidUploadFileError({
|
|
1417
1940
|
message: `Uploaded object "${object.key}" content type does not match the prepared file.`,
|
|
@@ -1448,9 +1971,11 @@ function filesFromFormData(formData: FormData): File[] {
|
|
|
1448
1971
|
}
|
|
1449
1972
|
|
|
1450
1973
|
function fileIntentFromFile(file: File): UploadFileIntent {
|
|
1974
|
+
const contentType =
|
|
1975
|
+
normalizeContentType(file.type) || "application/octet-stream";
|
|
1451
1976
|
return {
|
|
1452
1977
|
name: file.name,
|
|
1453
|
-
contentType
|
|
1978
|
+
contentType,
|
|
1454
1979
|
size: file.size,
|
|
1455
1980
|
};
|
|
1456
1981
|
}
|