/* A small multipart/form-data reader for the app zip uploads, and a body reader with a size limit. No dependencies; the whole body stays in memory (the limit is 20 MB). */ import type http from 'node:http' export const MAX_UPLOAD = 20 * 1024 * 1024 export class TooLarge extends Error { constructor(limit: number) { super(`The upload is larger than ${Math.floor(limit / (1024 * 1024))} MB.`) this.name = 'TooLarge' } } /* Reads the request body, counting bytes as they arrive; throws `TooLarge` above `limit`. Past the limit nothing more is kept, but the rest is still read (up to DRAIN more bytes), so the client gets its 413 instead of a reset connection. */ const DRAIN = 32 * 1024 * 1024 export async function readLimited(req: http.IncomingMessage, limit: number = MAX_UPLOAD): Promise { const declared = Number(req.headers['content-length']) if (Number.isFinite(declared) && declared > limit + DRAIN) throw new TooLarge(limit) const chunks: Buffer[] = [] let size = 0 for await (const chunk of req) { size += (chunk as Buffer).length if (size > limit + DRAIN) throw new TooLarge(limit) if (size <= limit) chunks.push(chunk as Buffer) } if (size > limit) throw new TooLarge(limit) return Buffer.concat(chunks) } export interface UploadedFile { field: string filename: string type: string data: Buffer } export interface Multipart { fields: Record files: UploadedFile[] } export function boundaryOf(contentType: string): string | null { const match = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType) return match === null ? null : (match[1] ?? match[2]) } const CRLF2 = Buffer.from('\r\n\r\n') /* Throws on anything that is not a well-formed multipart body. */ export function parseMultipart(body: Buffer, contentType: string): Multipart { const boundary = boundaryOf(contentType) if (boundary === null || !/^multipart\/form-data/i.test(contentType)) throw new Error('Expected multipart/form-data with a boundary.') const delimiter = Buffer.from(`--${boundary}`) const result: Multipart = { fields: {}, files: [] } let at = body.indexOf(delimiter) if (at < 0) throw new Error('The multipart body has no boundary.') for (;;) { at += delimiter.length // "--" after a delimiter closes the body. if (body[at] === 0x2d && body[at + 1] === 0x2d) return result if (body[at] !== 0x0d || body[at + 1] !== 0x0a) throw new Error('Malformed multipart body.') at += 2 const headerEnd = body.indexOf(CRLF2, at) if (headerEnd < 0) throw new Error('Malformed multipart part.') const headers = body.subarray(at, headerEnd).toString('utf8') const start = headerEnd + CRLF2.length // The part ends at the next CRLF + delimiter, so binary content can hold anything else. const next = body.indexOf(Buffer.concat([Buffer.from('\r\n'), delimiter]), start) if (next < 0) throw new Error('The multipart body is not terminated.') const data = body.subarray(start, next) const disposition = /^content-disposition:\s*form-data;(.*)$/im.exec(headers)?.[1] ?? '' const name = /\bname="([^"]*)"/i.exec(disposition)?.[1] const filename = /\bfilename="([^"]*)"/i.exec(disposition)?.[1] if (name === undefined) throw new Error('A multipart part has no name.') if (filename === undefined) result.fields[name] = data.toString('utf8') else { const type = /^content-type:\s*(.+)$/im.exec(headers)?.[1].trim() ?? 'application/octet-stream' result.files.push({ field: name, filename, type, data: Buffer.from(data) }) } at = next + 2 } }