// A multipart/form-data body's parts (RFC 7578): what the file-server routes // read uploads from. Each part's name, its file name when it's a file, and // its bytes. import type { IncomingMessage } from 'node:http'; export interface FormPart { name: string; fileName?: string; contentType?: string; data: Buffer; } const CRLF = Buffer.from('\r\n'); const HEADERS_END = Buffer.from('\r\n\r\n'); // A body larger than an endpoint takes. GSF's router refuses a multipart // request whose Content-Length is over its MultiPartConfig.maxFileSize // before it reads it (HttpRequestClassifierHandler.kt:83-95 @v8.15.29), with // this text; a body that names no length is counted as it is read. export class BodyTooLarge extends Error { constructor(maxBytes: number) { super(`File exceeds max size of ${maxBytes}`); } } // Whether the request says it is longer than `maxBytes`. export const declaredTooLarge = (req: IncomingMessage, maxBytes: number) => Number(req.headers['content-length']) > maxBytes; export async function readBody( req: IncomingMessage, maxBytes = Number.POSITIVE_INFINITY, ): Promise { if (declaredTooLarge(req, maxBytes)) throw new BodyTooLarge(maxBytes); const chunks: Buffer[] = []; let size = 0; for await (const chunk of req) { size += (chunk as Buffer).length; if (size > maxBytes) throw new BodyTooLarge(maxBytes); chunks.push(chunk as Buffer); } return Buffer.concat(chunks); } // The boundary a Content-Type names, or undefined when it isn't multipart. export function boundaryOf(contentType: string | undefined): string | undefined { if (!contentType || !/^multipart\/form-data/i.test(contentType)) return undefined; const match = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType); return match?.[1] ?? match?.[2]; } // A Content-Disposition's parameters (RFC 6266), each quoted or not, read // one after another so a `;` or `name=` inside a quoted value stays part of // it. function parameters(header: string): Map { const found = new Map(); for (const match of header.matchAll(/;\s*([^=;\s]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;]*))/g)) { const name = match[1].toLowerCase(); const value = match[2] === undefined ? match[3].trim() : match[2].replace(/\\(.)/g, '$1'); found.set(name, value); } return found; } // The parts between the delimiters (RFC 2046 5.1.1): the first `--boundary` // opens the body, and each later one follows a CRLF, which belongs to it, // so a part's content may hold `--boundary` itself. export function parseMultipart(body: Buffer, boundary: string): FormPart[] { const first = Buffer.from(`--${boundary}`); const delimiter = Buffer.from(`\r\n--${boundary}`); const parts: FormPart[] = []; let at = body.subarray(0, first.length).equals(first) ? 0 : body.indexOf(delimiter); let length = at === 0 ? first.length : delimiter.length; while (at !== -1) { const start = at + length; // The closing delimiter ends with --. if (body.subarray(start, start + 2).toString() === '--') break; const next = body.indexOf(delimiter, start); if (next === -1) break; // Each part: CRLF, headers, a blank line, the content. const part = body.subarray(start + CRLF.length, next); const split = part.indexOf(HEADERS_END); if (split !== -1) { const headers = Object.fromEntries( part .subarray(0, split) .toString('utf8') .split('\r\n') .map((line) => { const colon = line.indexOf(':'); return [line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim()]; }), ); const disposition = parameters(headers['content-disposition'] ?? ''); const name = disposition.get('name'); if (name !== undefined) { const fileName = disposition.get('filename'); parts.push({ name, ...(fileName === undefined ? {} : { fileName }), ...(headers['content-type'] ? { contentType: headers['content-type'] } : {}), data: Buffer.from(part.subarray(split + HEADERS_END.length)), }); } } at = next; length = delimiter.length; } return parts; }