Migrate /art/new to SendouForm

This commit is contained in:
Kalle
2026-07-28 16:17:33 +03:00
parent 77b7e05b2c
commit 02c2d74ddc
53 changed files with 778 additions and 726 deletions

View File

@@ -10,6 +10,13 @@ export type ParseResult<T> =
| { success: true; data: T }
| { success: false; fieldErrors: Record<string, string> };
/**
* Ceiling for a request body, in bytes. Sized to fit a form with a couple of `image()` fields (each
* capped at ~3M base64 characters) plus its other fields. Forms that legitimately submit more (e.g.
* art) pass their own `maxBodyBytes`.
*/
const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
/**
* Maps a {@link z.ZodError} to field-level errors keyed by form field name
* (e.g. `members[0].userId`), keeping the first error per field.
@@ -34,14 +41,14 @@ function fieldErrorsFromZodError(error: z.ZodError): Record<string, string> {
export async function parseFormData<T extends z.ZodTypeAny>({
request,
schema,
maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
}: {
request: Request;
schema: T;
/** Overrides {@link DEFAULT_MAX_BODY_BYTES} for forms that legitimately submit a bigger body. */
maxBodyBytes?: number;
}): Promise<ParseResult<z.infer<T>>> {
const data =
request.headers.get("Content-Type") === "application/json"
? await request.json()
: formDataToObject(await request.formData());
const data = await requestBodyToObject(request, maxBodyBytes);
const result = await schema.safeParseAsync(data);
@@ -116,3 +123,52 @@ function imageFields(
return [...fields].map(([key, autoValidate]) => ({ key, autoValidate }));
}
/**
* Reads the request body into the plain object the schema parses, refusing anything over
* `maxBytes`. `Content-Length` is checked up front so an oversized body is rejected before a byte
* of it is read.
*/
async function requestBodyToObject(request: Request, maxBytes: number) {
if (Number(request.headers.get("Content-Length")) > maxBytes) {
throw payloadTooLarge();
}
if (request.headers.get("Content-Type") === "application/json") {
return JSON.parse(await readBodyText(request, maxBytes));
}
return formDataToObject(await request.formData());
}
/**
* Reads the body as text, aborting the stream as soon as `maxBytes` is exceeded. The running total
* is enforced (rather than trusting `Content-Length`) so a chunked body that omits or understates
* the header can't be buffered in full either.
*/
async function readBodyText(request: Request, maxBytes: number) {
const reader = request.body?.getReader();
if (!reader) return "";
const decoder = new TextDecoder();
let bytesRead = 0;
let text = "";
let chunk = await reader.read();
while (!chunk.done) {
bytesRead += chunk.value.byteLength;
if (bytesRead > maxBytes) {
await reader.cancel();
throw payloadTooLarge();
}
text += decoder.decode(chunk.value, { stream: true });
chunk = await reader.read();
}
return text + decoder.decode();
}
function payloadTooLarge() {
return new Response(null, { status: 413 });
}