import { BufReader, ReadLineResult } from "./buf_reader.ts";import { getFilename } from "./content_disposition.ts";import { equal, extension } from "./deps.ts";import { readHeaders, toParamRegExp, unquote } from "./headers.ts";import { httpErrors } from "./httpError.ts";import { getRandomFilename, skipLWSPChar, stripEol } from "./util.ts";
const decoder = new TextDecoder();const encoder = new TextEncoder();
const BOUNDARY_PARAM_REGEX = toParamRegExp("boundary", "i");const DEFAULT_BUFFER_SIZE = 1048576; const DEFAULT_MAX_FILE_SIZE = 10485760; const DEFAULT_MAX_SIZE = 0; const NAME_PARAM_REGEX = toParamRegExp("name", "i");
export interface FormDataBody { fields: Record<string, string>;
files?: FormDataFile[];}
export type FormDataFile = { content?: Uint8Array;
contentType: string;
filename?: string;
name: string;
originalName: string;};
export interface FormDataReadOptions { bufferSize?: number;
maxFileSize?: number;
maxSize?: number;
outPath?: string;
prefix?: string;}
interface PartsOptions { body: BufReader; final: Uint8Array; maxFileSize: number; maxSize: number; outPath?: string; part: Uint8Array; prefix?: string;}
function append(a: Uint8Array, b: Uint8Array): Uint8Array { const ab = new Uint8Array(a.length + b.length); ab.set(a, 0); ab.set(b, a.length); return ab;}
function isEqual(a: Uint8Array, b: Uint8Array): boolean { return equal(skipLWSPChar(a), b);}
async function readToStartOrEnd( body: BufReader, start: Uint8Array, end: Uint8Array,): Promise<boolean> { let lineResult: ReadLineResult | null; while ((lineResult = await body.readLine())) { if (isEqual(lineResult.bytes, start)) { return true; } if (isEqual(lineResult.bytes, end)) { return false; } } throw new httpErrors.BadRequest( "Unable to find multi-part boundary.", );}
async function* parts( { body, final, part, maxFileSize, maxSize, outPath, prefix }: PartsOptions,): AsyncIterableIterator<[string, string | FormDataFile]> { async function getFile(contentType: string): Promise<[string, Deno.File]> { const ext = extension(contentType); if (!ext) { throw new httpErrors.BadRequest(`Invalid media type for part: ${ext}`); } if (!outPath) { outPath = await Deno.makeTempDir(); } const filename = `${outPath}/${getRandomFilename(prefix, ext)}`; const file = await Deno.open(filename, { write: true, createNew: true }); return [filename, file]; }
while (true) { const headers = await readHeaders(body); const contentType = headers["content-type"]; const contentDisposition = headers["content-disposition"]; if (!contentDisposition) { throw new httpErrors.BadRequest( "Form data part missing content-disposition header", ); } if (!contentDisposition.match(/^form-data;/i)) { throw new httpErrors.BadRequest( `Unexpected content-disposition header: "${contentDisposition}"`, ); } const matches = NAME_PARAM_REGEX.exec(contentDisposition); if (!matches) { throw new httpErrors.BadRequest( `Unable to determine name of form body part`, ); } let [, name] = matches; name = unquote(name); if (contentType) { const originalName = getFilename(contentDisposition); let byteLength = 0; let file: Deno.File | undefined; let filename: string | undefined; let buf: Uint8Array | undefined; if (maxSize) { buf = new Uint8Array(); } else { const result = await getFile(contentType); filename = result[0]; file = result[1]; } while (true) { const readResult = await body.readLine(false); if (!readResult) { throw new httpErrors.BadRequest("Unexpected EOF reached"); } let { bytes } = readResult; const strippedBytes = stripEol(bytes); if (isEqual(strippedBytes, part) || isEqual(strippedBytes, final)) { if (file) { file.close(); } yield [ name, { content: buf, contentType, name, filename, originalName, } as FormDataFile, ]; if (isEqual(strippedBytes, final)) { return; } break; } byteLength += bytes.byteLength; if (byteLength > maxFileSize) { if (file) { file.close(); } throw new httpErrors.RequestEntityTooLarge( `File size exceeds limit of ${maxFileSize} bytes.`, ); } if (buf) { if (byteLength > maxSize) { const result = await getFile(contentType); filename = result[0]; file = result[1]; await Deno.writeAll(file, buf); buf = undefined; } else { buf = append(buf, bytes); } } if (file) { await Deno.writeAll(file, bytes); } } } else { const lines: string[] = []; while (true) { const readResult = await body.readLine(); if (!readResult) { throw new httpErrors.BadRequest("Unexpected EOF reached"); } const { bytes } = readResult; if (isEqual(bytes, part) || isEqual(bytes, final)) { yield [name, lines.join("\n")]; if (isEqual(bytes, final)) { return; } break; } lines.push(decoder.decode(bytes)); } } }}
export class FormDataReader { #body: Deno.Reader; #boundaryFinal: Uint8Array; #boundaryPart: Uint8Array; #reading = false;
constructor(contentType: string, body: Deno.Reader) { const matches = contentType.match(BOUNDARY_PARAM_REGEX); if (!matches) { throw new httpErrors.BadRequest( `Content type "${contentType}" does not contain a valid boundary.`, ); } let [, boundary] = matches; boundary = unquote(boundary); this.#boundaryPart = encoder.encode(`--${boundary}`); this.#boundaryFinal = encoder.encode(`--${boundary}--`); this.#body = body; }
async read(options: FormDataReadOptions = {}): Promise<FormDataBody> { if (this.#reading) { throw new Error("Body is already being read."); } this.#reading = true; const { outPath, maxFileSize = DEFAULT_MAX_FILE_SIZE, maxSize = DEFAULT_MAX_SIZE, bufferSize = DEFAULT_BUFFER_SIZE, } = options; const body = new BufReader(this.#body, bufferSize); const result: FormDataBody = { fields: {} }; if ( !(await readToStartOrEnd(body, this.#boundaryPart, this.#boundaryFinal)) ) { return result; } try { for await ( const part of parts({ body, part: this.#boundaryPart, final: this.#boundaryFinal, maxFileSize, maxSize, outPath, }) ) { const [key, value] = part; if (typeof value === "string") { result.fields[key] = value; } else { if (!result.files) { result.files = []; } result.files.push(value); } } } catch (err) { if (err instanceof Deno.errors.PermissionDenied) { console.error(err.stack ? err.stack : `${err.name}: ${err.message}`); } else { throw err; } } return result; }
async *stream( options: FormDataReadOptions = {}, ): AsyncIterableIterator<[string, string | FormDataFile]> { if (this.#reading) { throw new Error("Body is already being read."); } this.#reading = true; const { outPath, maxFileSize = DEFAULT_MAX_FILE_SIZE, maxSize = DEFAULT_MAX_SIZE, bufferSize = 32000, } = options; const body = new BufReader(this.#body, bufferSize); if ( !(await readToStartOrEnd(body, this.#boundaryPart, this.#boundaryFinal)) ) { return; } try { for await ( const part of parts({ body, part: this.#boundaryPart, final: this.#boundaryFinal, maxFileSize, maxSize, outPath, }) ) { yield part; } } catch (err) { if (err instanceof Deno.errors.PermissionDenied) { console.error(err.stack ? err.stack : `${err.name}: ${err.message}`); } else { throw err; } } }}