@beignet/core 0.0.23 → 0.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +38 -6
- package/dist/client-only.d.ts +2 -0
- package/dist/client-only.d.ts.map +1 -1
- package/dist/client-only.js +2 -0
- package/dist/client-only.js.map +1 -1
- package/dist/providers/metadata.d.ts +2 -0
- package/dist/providers/metadata.d.ts.map +1 -1
- package/dist/providers/metadata.js +12 -0
- package/dist/providers/metadata.js.map +1 -1
- package/dist/server/hooks/cors.d.ts +4 -3
- package/dist/server/hooks/cors.d.ts.map +1 -1
- package/dist/server/hooks/cors.js +21 -16
- package/dist/server/hooks/cors.js.map +1 -1
- package/dist/server/hooks/rate-limit.d.ts +7 -6
- package/dist/server/hooks/rate-limit.d.ts.map +1 -1
- package/dist/server/hooks/rate-limit.js +4 -1
- package/dist/server/hooks/rate-limit.js.map +1 -1
- package/dist/server/server.d.ts +15 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +82 -9
- package/dist/server/server.js.map +1 -1
- package/dist/server-only.d.ts +2 -0
- package/dist/server-only.d.ts.map +1 -1
- package/dist/server-only.js +2 -0
- package/dist/server-only.js.map +1 -1
- package/dist/uploads/index.d.ts +37 -1
- package/dist/uploads/index.d.ts.map +1 -1
- package/dist/uploads/index.js +127 -2
- package/dist/uploads/index.js.map +1 -1
- package/package.json +4 -2
- package/skills/app-architecture/SKILL.md +29 -2
- package/src/client-only.ts +2 -0
- package/src/providers/metadata.ts +23 -0
- package/src/server/hooks/cors.ts +36 -16
- package/src/server/hooks/rate-limit.ts +12 -6
- package/src/server/server.ts +130 -16
- package/src/server-only.ts +2 -0
- package/src/uploads/index.ts +209 -3
package/src/server/server.ts
CHANGED
|
@@ -275,6 +275,18 @@ type AnyServiceProvider = ServiceProvider<
|
|
|
275
275
|
any
|
|
276
276
|
>;
|
|
277
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Request body limits enforced before contract body validation.
|
|
280
|
+
*/
|
|
281
|
+
export interface RequestBodyOptions {
|
|
282
|
+
/**
|
|
283
|
+
* Maximum request body size in bytes for JSON/text route bodies.
|
|
284
|
+
*
|
|
285
|
+
* @default 1048576
|
|
286
|
+
*/
|
|
287
|
+
maxBytes?: number;
|
|
288
|
+
}
|
|
289
|
+
|
|
278
290
|
/**
|
|
279
291
|
* Options for creating a Beignet server instance.
|
|
280
292
|
*/
|
|
@@ -350,6 +362,10 @@ export type CreateServerOptions<
|
|
|
350
362
|
* @default true
|
|
351
363
|
*/
|
|
352
364
|
validateResponses?: boolean;
|
|
365
|
+
/**
|
|
366
|
+
* Request body parsing limits for JSON/text contract routes.
|
|
367
|
+
*/
|
|
368
|
+
requestBody?: RequestBodyOptions;
|
|
353
369
|
/**
|
|
354
370
|
* Route list to register up front.
|
|
355
371
|
*/
|
|
@@ -937,7 +953,89 @@ function defaultErrorResponse(err: unknown, ctx?: unknown): HttpResponseLike {
|
|
|
937
953
|
};
|
|
938
954
|
}
|
|
939
955
|
|
|
940
|
-
|
|
956
|
+
const DEFAULT_REQUEST_BODY_MAX_BYTES = 1024 * 1024;
|
|
957
|
+
|
|
958
|
+
class RequestBodyTooLargeError extends Error {
|
|
959
|
+
readonly maxBytes: number;
|
|
960
|
+
readonly actualBytes?: number;
|
|
961
|
+
|
|
962
|
+
constructor(maxBytes: number, actualBytes?: number) {
|
|
963
|
+
super("Request body exceeds the configured size limit.");
|
|
964
|
+
this.name = "RequestBodyTooLargeError";
|
|
965
|
+
this.maxBytes = maxBytes;
|
|
966
|
+
this.actualBytes = actualBytes;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function requestBodyLimit(options: RequestBodyOptions | undefined): number {
|
|
971
|
+
const maxBytes = options?.maxBytes ?? DEFAULT_REQUEST_BODY_MAX_BYTES;
|
|
972
|
+
|
|
973
|
+
if (!Number.isFinite(maxBytes) || maxBytes <= 0) {
|
|
974
|
+
throw new Error(
|
|
975
|
+
"createServer requestBody.maxBytes must be a positive number.",
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
return maxBytes;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function assertContentLengthWithinLimit(
|
|
983
|
+
headers: Headers,
|
|
984
|
+
maxBytes: number,
|
|
985
|
+
): void {
|
|
986
|
+
const contentLength = headers.get("content-length");
|
|
987
|
+
if (contentLength === null) return;
|
|
988
|
+
|
|
989
|
+
const actualBytes = Number(contentLength);
|
|
990
|
+
if (!Number.isFinite(actualBytes) || actualBytes < 0) return;
|
|
991
|
+
if (actualBytes > maxBytes) {
|
|
992
|
+
throw new RequestBodyTooLargeError(maxBytes, actualBytes);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
async function readLimitedRequestText(
|
|
997
|
+
req: HttpRequestLike,
|
|
998
|
+
maxBytes: number,
|
|
999
|
+
): Promise<string> {
|
|
1000
|
+
assertContentLengthWithinLimit(req.headers, maxBytes);
|
|
1001
|
+
|
|
1002
|
+
const body = req.raw?.body;
|
|
1003
|
+
if (!body) {
|
|
1004
|
+
const text = await req.text();
|
|
1005
|
+
const actualBytes = new TextEncoder().encode(text).byteLength;
|
|
1006
|
+
if (actualBytes > maxBytes) {
|
|
1007
|
+
throw new RequestBodyTooLargeError(maxBytes, actualBytes);
|
|
1008
|
+
}
|
|
1009
|
+
return text;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const reader = body.getReader();
|
|
1013
|
+
const decoder = new TextDecoder();
|
|
1014
|
+
let received = 0;
|
|
1015
|
+
let text = "";
|
|
1016
|
+
|
|
1017
|
+
try {
|
|
1018
|
+
while (true) {
|
|
1019
|
+
const result = await reader.read();
|
|
1020
|
+
if (result.done) break;
|
|
1021
|
+
received += result.value.byteLength;
|
|
1022
|
+
if (received > maxBytes) {
|
|
1023
|
+
throw new RequestBodyTooLargeError(maxBytes, received);
|
|
1024
|
+
}
|
|
1025
|
+
text += decoder.decode(result.value, { stream: true });
|
|
1026
|
+
}
|
|
1027
|
+
text += decoder.decode();
|
|
1028
|
+
} finally {
|
|
1029
|
+
reader.releaseLock();
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
return text;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
async function parseBody(
|
|
1036
|
+
req: HttpRequestLike,
|
|
1037
|
+
maxBytes: number,
|
|
1038
|
+
): Promise<unknown> {
|
|
941
1039
|
const method = req.method.toUpperCase() as HttpContractConfig["method"];
|
|
942
1040
|
if (!methodSupportsRequestBody(method)) {
|
|
943
1041
|
return undefined;
|
|
@@ -946,15 +1044,16 @@ async function parseBody(req: HttpRequestLike): Promise<unknown> {
|
|
|
946
1044
|
const bodyReq = req.clone?.() ?? req;
|
|
947
1045
|
const contentType = req.headers.get("content-type") || "";
|
|
948
1046
|
if (contentType.includes("application/json")) {
|
|
949
|
-
const text = await bodyReq
|
|
1047
|
+
const text = await readLimitedRequestText(bodyReq, maxBytes);
|
|
950
1048
|
if (text === "") return undefined;
|
|
951
1049
|
return JSON.parse(text);
|
|
952
1050
|
}
|
|
953
1051
|
|
|
954
1052
|
try {
|
|
955
|
-
const text = await bodyReq
|
|
1053
|
+
const text = await readLimitedRequestText(bodyReq, maxBytes);
|
|
956
1054
|
return text === "" ? undefined : text;
|
|
957
|
-
} catch {
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
if (error instanceof RequestBodyTooLargeError) throw error;
|
|
958
1057
|
return undefined;
|
|
959
1058
|
}
|
|
960
1059
|
}
|
|
@@ -1011,6 +1110,7 @@ function createRequestExecutor<
|
|
|
1011
1110
|
preMatchedParams?: Record<string, string>,
|
|
1012
1111
|
) => Promise<HttpResponse> {
|
|
1013
1112
|
const warnedNativeReplacementHooks = new WeakSet<object>();
|
|
1113
|
+
const maxRequestBodyBytes = requestBodyLimit(options.requestBody);
|
|
1014
1114
|
|
|
1015
1115
|
return async (
|
|
1016
1116
|
target: ExecutionTarget<Ctx, C>,
|
|
@@ -1521,19 +1621,33 @@ function createRequestExecutor<
|
|
|
1521
1621
|
let body: any;
|
|
1522
1622
|
if (!result) {
|
|
1523
1623
|
try {
|
|
1524
|
-
body = await parseBody(req);
|
|
1624
|
+
body = await parseBody(req, maxRequestBodyBytes);
|
|
1525
1625
|
} catch (error) {
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1626
|
+
if (error instanceof RequestBodyTooLargeError) {
|
|
1627
|
+
result = {
|
|
1628
|
+
response: requestValidationError(
|
|
1629
|
+
contract,
|
|
1630
|
+
413,
|
|
1631
|
+
"PAYLOAD_TOO_LARGE",
|
|
1632
|
+
"Request body is too large",
|
|
1633
|
+
"body",
|
|
1634
|
+
error,
|
|
1635
|
+
),
|
|
1636
|
+
owner: "framework",
|
|
1637
|
+
};
|
|
1638
|
+
} else {
|
|
1639
|
+
result = {
|
|
1640
|
+
response: requestValidationError(
|
|
1641
|
+
contract,
|
|
1642
|
+
400,
|
|
1643
|
+
"INVALID_BODY",
|
|
1644
|
+
"Malformed JSON",
|
|
1645
|
+
"body",
|
|
1646
|
+
error,
|
|
1647
|
+
),
|
|
1648
|
+
owner: "framework",
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1537
1651
|
}
|
|
1538
1652
|
}
|
|
1539
1653
|
|
package/src/server-only.ts
CHANGED
|
@@ -3,5 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Host frameworks may provide their own runtime-only enforcement. This marker
|
|
5
5
|
* is intentionally a no-op so Beignet can enforce intent through `beignet lint`.
|
|
6
|
+
* Import it for side effects at the top of server-only modules:
|
|
7
|
+
* `import "@beignet/core/server-only";`.
|
|
6
8
|
*/
|
|
7
9
|
export const beignetServerOnly = true;
|
package/src/uploads/index.ts
CHANGED
|
@@ -190,6 +190,14 @@ export type UploadAuthorizeResult =
|
|
|
190
190
|
reason?: string;
|
|
191
191
|
};
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Upload access mode.
|
|
195
|
+
*
|
|
196
|
+
* Protected uploads require an `authorize(...)` hook. Public uploads are
|
|
197
|
+
* intentionally reachable without that hook.
|
|
198
|
+
*/
|
|
199
|
+
export type UploadAccess = "protected" | "public";
|
|
200
|
+
|
|
193
201
|
/**
|
|
194
202
|
* Arguments passed to upload definition hooks.
|
|
195
203
|
*/
|
|
@@ -231,6 +239,12 @@ export interface DefineUploadOptions<
|
|
|
231
239
|
* File constraints for this upload workflow.
|
|
232
240
|
*/
|
|
233
241
|
file: UploadFileConstraints;
|
|
242
|
+
/**
|
|
243
|
+
* Whether this upload may be prepared without an authorize hook.
|
|
244
|
+
*
|
|
245
|
+
* @default "protected"
|
|
246
|
+
*/
|
|
247
|
+
access?: UploadAccess;
|
|
234
248
|
/**
|
|
235
249
|
* Optional human-readable description for docs and tooling.
|
|
236
250
|
*/
|
|
@@ -274,6 +288,7 @@ export interface UploadDef<
|
|
|
274
288
|
readonly name: Name;
|
|
275
289
|
readonly metadata: MetadataSchema;
|
|
276
290
|
readonly file: UploadFileConstraints;
|
|
291
|
+
readonly access?: UploadAccess;
|
|
277
292
|
readonly description?: string;
|
|
278
293
|
authorize?(
|
|
279
294
|
args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
|
|
@@ -348,6 +363,25 @@ export interface UploadManifestEntry {
|
|
|
348
363
|
file: UploadFileConstraints;
|
|
349
364
|
}
|
|
350
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Request body limits for upload route actions.
|
|
368
|
+
*/
|
|
369
|
+
export interface UploadRequestLimits {
|
|
370
|
+
/**
|
|
371
|
+
* Maximum JSON body size for prepare and complete actions.
|
|
372
|
+
*
|
|
373
|
+
* @default 262144
|
|
374
|
+
*/
|
|
375
|
+
jsonMaxBytes?: number;
|
|
376
|
+
/**
|
|
377
|
+
* Maximum multipart body size for server-handled upload actions when
|
|
378
|
+
* Content-Length is present. File constraints still enforce per-file limits.
|
|
379
|
+
*
|
|
380
|
+
* @default 26214400
|
|
381
|
+
*/
|
|
382
|
+
multipartMaxBytes?: number;
|
|
383
|
+
}
|
|
384
|
+
|
|
351
385
|
/**
|
|
352
386
|
* Options for `createUploadRouter(...)`.
|
|
353
387
|
*/
|
|
@@ -368,6 +402,10 @@ export interface CreateUploadRouterOptions<Ctx> {
|
|
|
368
402
|
* Optional signer used to prepare direct upload instructions.
|
|
369
403
|
*/
|
|
370
404
|
signer?: UploadSignerPort;
|
|
405
|
+
/**
|
|
406
|
+
* Request body limits for upload actions.
|
|
407
|
+
*/
|
|
408
|
+
limits?: UploadRequestLimits;
|
|
371
409
|
/**
|
|
372
410
|
* Optional instrumentation target used by devtools/provider watchers.
|
|
373
411
|
*/
|
|
@@ -410,6 +448,7 @@ export type UploadErrorCode =
|
|
|
410
448
|
| "INVALID_UPLOAD_FILE"
|
|
411
449
|
| "UNAUTHORIZED_UPLOAD"
|
|
412
450
|
| "UPLOAD_OBJECT_NOT_FOUND"
|
|
451
|
+
| "UPLOAD_BODY_TOO_LARGE"
|
|
413
452
|
| "INVALID_UPLOAD_BODY";
|
|
414
453
|
|
|
415
454
|
/**
|
|
@@ -455,6 +494,7 @@ export function defineUpload<
|
|
|
455
494
|
maxFiles: options.file.maxFiles ?? 1,
|
|
456
495
|
visibility: options.file.visibility ?? "private",
|
|
457
496
|
},
|
|
497
|
+
access: options.access ?? "protected",
|
|
458
498
|
...(options.description !== undefined
|
|
459
499
|
? { description: options.description }
|
|
460
500
|
: {}),
|
|
@@ -558,6 +598,7 @@ export function createUploadRouter<Ctx>(
|
|
|
558
598
|
watcher: "uploads",
|
|
559
599
|
},
|
|
560
600
|
);
|
|
601
|
+
const requestLimits = uploadRequestLimits(options.limits);
|
|
561
602
|
|
|
562
603
|
async function resolveCtx(): Promise<Ctx> {
|
|
563
604
|
return typeof options.ctx === "function"
|
|
@@ -841,6 +882,7 @@ export function createUploadRouter<Ctx>(
|
|
|
841
882
|
const input = await readJsonBody(request, {
|
|
842
883
|
uploadName: requestOptions.uploadName,
|
|
843
884
|
action: "prepare",
|
|
885
|
+
maxBytes: requestLimits.jsonMaxBytes,
|
|
844
886
|
});
|
|
845
887
|
return jsonResponse(
|
|
846
888
|
await prepare(
|
|
@@ -854,6 +896,7 @@ export function createUploadRouter<Ctx>(
|
|
|
854
896
|
const input = await readJsonBody(request, {
|
|
855
897
|
uploadName: requestOptions.uploadName,
|
|
856
898
|
action: "complete",
|
|
899
|
+
maxBytes: requestLimits.jsonMaxBytes,
|
|
857
900
|
});
|
|
858
901
|
return jsonResponse(
|
|
859
902
|
await complete(
|
|
@@ -863,6 +906,11 @@ export function createUploadRouter<Ctx>(
|
|
|
863
906
|
);
|
|
864
907
|
}
|
|
865
908
|
|
|
909
|
+
assertUploadContentLengthWithinLimit(request.headers, {
|
|
910
|
+
uploadName: requestOptions.uploadName,
|
|
911
|
+
action: "upload",
|
|
912
|
+
maxBytes: requestLimits.multipartMaxBytes,
|
|
913
|
+
});
|
|
866
914
|
const formData = await request.formData();
|
|
867
915
|
return jsonResponse(
|
|
868
916
|
await upload(requestOptions.uploadName, {
|
|
@@ -876,13 +924,134 @@ export function createUploadRouter<Ctx>(
|
|
|
876
924
|
};
|
|
877
925
|
}
|
|
878
926
|
|
|
927
|
+
const DEFAULT_UPLOAD_JSON_MAX_BYTES = 256 * 1024;
|
|
928
|
+
const DEFAULT_UPLOAD_MULTIPART_MAX_BYTES = 25 * 1024 * 1024;
|
|
929
|
+
|
|
930
|
+
function uploadRequestLimits(
|
|
931
|
+
limits: UploadRequestLimits | undefined,
|
|
932
|
+
): Required<UploadRequestLimits> {
|
|
933
|
+
return {
|
|
934
|
+
jsonMaxBytes: positiveLimit(
|
|
935
|
+
limits?.jsonMaxBytes,
|
|
936
|
+
DEFAULT_UPLOAD_JSON_MAX_BYTES,
|
|
937
|
+
"limits.jsonMaxBytes",
|
|
938
|
+
),
|
|
939
|
+
multipartMaxBytes: positiveLimit(
|
|
940
|
+
limits?.multipartMaxBytes,
|
|
941
|
+
DEFAULT_UPLOAD_MULTIPART_MAX_BYTES,
|
|
942
|
+
"limits.multipartMaxBytes",
|
|
943
|
+
),
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function positiveLimit(
|
|
948
|
+
value: number | undefined,
|
|
949
|
+
fallback: number,
|
|
950
|
+
name: string,
|
|
951
|
+
): number {
|
|
952
|
+
const limit = value ?? fallback;
|
|
953
|
+
if (!Number.isFinite(limit) || limit <= 0) {
|
|
954
|
+
throw new Error(`createUploadRouter ${name} must be a positive number.`);
|
|
955
|
+
}
|
|
956
|
+
return limit;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function assertUploadContentLengthWithinLimit(
|
|
960
|
+
headers: Headers,
|
|
961
|
+
context: {
|
|
962
|
+
uploadName: string;
|
|
963
|
+
action: "prepare" | "complete" | "upload";
|
|
964
|
+
maxBytes: number;
|
|
965
|
+
},
|
|
966
|
+
): void {
|
|
967
|
+
const contentLength = headers.get("content-length");
|
|
968
|
+
if (contentLength === null) return;
|
|
969
|
+
|
|
970
|
+
const actualBytes = Number(contentLength);
|
|
971
|
+
if (!Number.isFinite(actualBytes) || actualBytes < 0) return;
|
|
972
|
+
if (actualBytes > context.maxBytes) {
|
|
973
|
+
throw new UploadError({
|
|
974
|
+
code: "UPLOAD_BODY_TOO_LARGE",
|
|
975
|
+
status: 413,
|
|
976
|
+
message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
|
|
977
|
+
details: {
|
|
978
|
+
maxBytes: context.maxBytes,
|
|
979
|
+
actualBytes,
|
|
980
|
+
},
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async function readLimitedRequestText(
|
|
986
|
+
request: Request,
|
|
987
|
+
context: {
|
|
988
|
+
uploadName: string;
|
|
989
|
+
action: "prepare" | "complete";
|
|
990
|
+
maxBytes: number;
|
|
991
|
+
},
|
|
992
|
+
): Promise<string> {
|
|
993
|
+
assertUploadContentLengthWithinLimit(request.headers, context);
|
|
994
|
+
|
|
995
|
+
if (!request.body) {
|
|
996
|
+
const text = await request.text();
|
|
997
|
+
const actualBytes = new TextEncoder().encode(text).byteLength;
|
|
998
|
+
if (actualBytes > context.maxBytes) {
|
|
999
|
+
throw new UploadError({
|
|
1000
|
+
code: "UPLOAD_BODY_TOO_LARGE",
|
|
1001
|
+
status: 413,
|
|
1002
|
+
message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
|
|
1003
|
+
details: {
|
|
1004
|
+
maxBytes: context.maxBytes,
|
|
1005
|
+
actualBytes,
|
|
1006
|
+
},
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
return text;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const reader = request.body.getReader();
|
|
1013
|
+
const decoder = new TextDecoder();
|
|
1014
|
+
let actualBytes = 0;
|
|
1015
|
+
let text = "";
|
|
1016
|
+
|
|
1017
|
+
try {
|
|
1018
|
+
while (true) {
|
|
1019
|
+
const result = await reader.read();
|
|
1020
|
+
if (result.done) break;
|
|
1021
|
+
actualBytes += result.value.byteLength;
|
|
1022
|
+
if (actualBytes > context.maxBytes) {
|
|
1023
|
+
throw new UploadError({
|
|
1024
|
+
code: "UPLOAD_BODY_TOO_LARGE",
|
|
1025
|
+
status: 413,
|
|
1026
|
+
message: `Upload "${context.uploadName}" ${context.action} body is too large.`,
|
|
1027
|
+
details: {
|
|
1028
|
+
maxBytes: context.maxBytes,
|
|
1029
|
+
actualBytes,
|
|
1030
|
+
},
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
text += decoder.decode(result.value, { stream: true });
|
|
1034
|
+
}
|
|
1035
|
+
text += decoder.decode();
|
|
1036
|
+
} finally {
|
|
1037
|
+
reader.releaseLock();
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return text;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
879
1043
|
async function readJsonBody(
|
|
880
1044
|
request: Request,
|
|
881
|
-
context: {
|
|
1045
|
+
context: {
|
|
1046
|
+
uploadName: string;
|
|
1047
|
+
action: "prepare" | "complete";
|
|
1048
|
+
maxBytes: number;
|
|
1049
|
+
},
|
|
882
1050
|
): Promise<unknown> {
|
|
883
1051
|
try {
|
|
884
|
-
return await request
|
|
885
|
-
} catch {
|
|
1052
|
+
return JSON.parse(await readLimitedRequestText(request, context));
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
if (error instanceof UploadError) throw error;
|
|
886
1055
|
throw new UploadError({
|
|
887
1056
|
code: "INVALID_UPLOAD_BODY",
|
|
888
1057
|
status: 400,
|
|
@@ -1056,6 +1225,16 @@ async function assertAuthorized<Metadata, Ctx>(
|
|
|
1056
1225
|
upload: UploadDef<string, StandardSchema, Ctx>,
|
|
1057
1226
|
args: UploadFileHookArgs<Metadata, Ctx>,
|
|
1058
1227
|
): Promise<void> {
|
|
1228
|
+
if (!upload.authorize) {
|
|
1229
|
+
if ((upload.access ?? "protected") === "public") return;
|
|
1230
|
+
|
|
1231
|
+
throw new UploadError({
|
|
1232
|
+
code: "UNAUTHORIZED_UPLOAD",
|
|
1233
|
+
status: 403,
|
|
1234
|
+
message: `Upload "${upload.name}" must declare authorize(...) or set access: "public".`,
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1059
1238
|
const result = await upload.authorize?.(
|
|
1060
1239
|
args as UploadFileHookArgs<InferUploadMetadata<typeof upload>, Ctx>,
|
|
1061
1240
|
);
|
|
@@ -1084,6 +1263,33 @@ function assertStoredObject(
|
|
|
1084
1263
|
): void {
|
|
1085
1264
|
assertFiles(upload, [file]);
|
|
1086
1265
|
|
|
1266
|
+
if (
|
|
1267
|
+
upload.file.maxSizeBytes !== undefined &&
|
|
1268
|
+
object.size > upload.file.maxSizeBytes
|
|
1269
|
+
) {
|
|
1270
|
+
throw new UploadError({
|
|
1271
|
+
code: "INVALID_UPLOAD_FILE",
|
|
1272
|
+
status: 413,
|
|
1273
|
+
message: `Uploaded object "${object.key}" exceeds the maximum file size.`,
|
|
1274
|
+
details: {
|
|
1275
|
+
size: object.size,
|
|
1276
|
+
maxSizeBytes: upload.file.maxSizeBytes,
|
|
1277
|
+
},
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (object.size !== file.size) {
|
|
1282
|
+
throw new UploadError({
|
|
1283
|
+
code: "INVALID_UPLOAD_FILE",
|
|
1284
|
+
status: 422,
|
|
1285
|
+
message: `Uploaded object "${object.key}" size does not match the prepared file.`,
|
|
1286
|
+
details: {
|
|
1287
|
+
expected: file.size,
|
|
1288
|
+
actual: object.size,
|
|
1289
|
+
},
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1087
1293
|
if (
|
|
1088
1294
|
object.contentType &&
|
|
1089
1295
|
file.contentType &&
|