Files
3dverse-testtask/vendor/deno.land/x/oak@v12.1.0/multipart.ts
T

513 lines
16 KiB
TypeScript

// Copyright 2018-2022 the oak authors. All rights reserved. MIT license.
import { BufReader, ReadLineResult } from "./buf_reader.ts";
import { getFilename } from "./content_disposition.ts";
import { equals, errors, extension, writeAll } from "./deps.ts";
import { readHeaders, toParamRegExp, unquote } from "./headers.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 = 1_048_576; // 1mb
const DEFAULT_MAX_FILE_SIZE = 10_485_760; // 10mb
const DEFAULT_MAX_SIZE = 0; // all files written to disc
const NAME_PARAM_REGEX = toParamRegExp("name", "i");
/** When reading a body in full via `.read()` from a {@linkcode FormDataReader}
* this is what is what the value is resolved, providing a split between any
* fields, and multi-part files that were provided. */
export interface FormDataBody {
/** A record of form parts where the key was the `name` of the part and the
* value was the value of the part. This record does not include any files
* that were part of the form data.
*
* *Note*: Duplicate names are not included in this record, if there are
* duplicates, the last value will be the value that is set here. If there
* is a possibility of duplicate values, use the `.stream()` method on
* {@linkcode FormDataReader} to iterate over the values. */
fields: Record<string, string>;
/** An array of any files that were part of the form data. */
files?: FormDataFile[];
}
/** A representation of a file that has been read from a form data body. Based
* on the {@linkcode FormDataReadOptions} that were passed when reading will
* determine if files are written to disk or not and how they are written to
* disk. When written to disk, the extension of the file will be determined by
* the content type, with the `.filename` property containing the full path to
* the file.
*
* The original filename as part of the form data is available in
* `originalName`, but for security and stability reasons, it is not used to
* determine the name of the file on disk. If further processing or renaming
* is required, the implementor should do that processing. */
export interface FormDataFile {
/** When the file has not been written out to disc, the contents of the file
* as a {@linkcode Uint8Array}. */
content?: Uint8Array;
/** The content type of the form data file. */
contentType: string;
/** When the file has been written out to disc, the full path to the file. */
filename?: string;
/** The `name` that was assigned to the form data file. */
name: string;
/** The `filename` that was provided in the form data file. */
originalName: string;
}
/** Options which impact how the form data is decoded for a
* {@linkcode FormDataReader}. All these options have sensible defaults for
* most applications, but can be modified for different use cases. Many of these
* options can have an impact on the stability of a server, especially if there
* is someone attempting a denial of service attack on your server, so be
* careful when changing the defaults. */
export interface FormDataReadOptions {
/** The size of the buffer to read from the request body at a single time.
* This defaults to 1mb. */
bufferSize?: number;
/** A mapping of custom media types that are supported, mapped to their
* extension when determining the extension for a file. The key should be an
* all lowercase media type with the value being the extension (without an
* initial period), to be used when decoding the file.
*
* ### Example
*
* Form data that is sent with content having a type of `text/vdn.custom` will
* be decoded and assigned a filename ending with `.txt`:
*
* ```ts
* import { Application } from "https://deno.land/x/oak/mod.ts";
*
* const app = new Application();
* app.use(async (ctx) => {
* const body = ctx.request.body();
* if (body.type === "form-data") {
* const formatData = await body.value.read({
* customContentTypes: {
* "text/vnd.custom": "txt"
* }
* });
* console.log(formData);
* }
* });
* ```
*/
customContentTypes?: Record<string, string>;
/** The maximum file size (in bytes) that can be handled. This defaults to
* 10MB when not specified. This is to try to avoid DOS attacks where
* someone would continue to try to send a "file" continuously until a host
* limit was reached crashing the server or the host. Also see `maxSize`. */
maxFileSize?: number;
/** The maximum size (in bytes) of a file to hold in memory, and not write
* to disk. This defaults to `0`, so that all multipart form files are
* written to disk. When set to a positive integer, if the form data file is
* smaller, it will be retained in memory and available in the `.content`
* property of the `FormDataFile` object. If the file exceeds the `maxSize`
* it will be written to disk and the `.filename` property will contain the
* full path to the output file. */
maxSize?: number;
/** When writing form data files to disk, the output path. This will default
* to a temporary path generated by `Deno.makeTempDir()`. */
outPath?: string;
/** When a form data file is written to disk, it will be generated with a
* random filename and have an extension based off the content type for the
* file. `prefix` can be specified though to prepend to the file name. */
prefix?: string;
}
interface PartsOptions {
body: BufReader;
customContentTypes?: Record<string, string>;
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 equals(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 errors.BadRequest(
"Unable to find multi-part boundary.",
);
}
/** Yield up individual parts by reading the body and parsing out the ford
* data values. */
async function* parts(
{
body,
customContentTypes = {},
final,
part,
maxFileSize,
maxSize,
outPath,
prefix,
}: PartsOptions,
): AsyncIterableIterator<[string, string | FormDataFile]> {
async function getFile(contentType: string): Promise<[string, Deno.FsFile]> {
const ext = customContentTypes[contentType.toLowerCase()] ??
extension(contentType);
if (!ext) {
throw new errors.BadRequest(
`The form contained content type "${contentType}" which is not supported by the server.`,
);
}
if (!outPath) {
outPath = await Deno.makeTempDir();
}
const filename = `${outPath}/${await 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 errors.BadRequest(
"Form data part missing content-disposition header",
);
}
if (!contentDisposition.match(/^form-data;/i)) {
throw new errors.BadRequest(
`Unexpected content-disposition header: "${contentDisposition}"`,
);
}
const matches = NAME_PARAM_REGEX.exec(contentDisposition);
if (!matches) {
throw new errors.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.FsFile | 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 errors.BadRequest("Unexpected EOF reached");
}
const { bytes } = readResult;
const strippedBytes = stripEol(bytes);
if (isEqual(strippedBytes, part) || isEqual(strippedBytes, final)) {
if (file) {
// remove extra 2 bytes ([CR, LF]) from result file
const bytesDiff = bytes.length - strippedBytes.length;
if (bytesDiff) {
const originalBytesSize = await file.seek(
-bytesDiff,
Deno.SeekMode.Current,
);
await file.truncate(originalBytesSize);
}
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 errors.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 writeAll(file, buf);
buf = undefined;
} else {
buf = append(buf, bytes);
}
}
if (file) {
await writeAll(file, bytes);
}
}
} else {
const lines: string[] = [];
while (true) {
const readResult = await body.readLine();
if (!readResult) {
throw new errors.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));
}
}
}
}
/** An interface which provides an interface to access the fields of a
* `multipart/form-data` body.
*
* Normally an instance of this is accessed when handling a request body, and
* dealing with decoding it. There are options that can be set when attempting
* to read a multi-part body (see: {@linkcode FormDataReadOptions}).
*
* If you `.read()` the value, then a promise is provided of type
* {@linkcode FormDataBody}. If you use the `.stream()` property, it is an async
* iterator which yields up a tuple of with the first element being a
*
* ### Examples
*
* Using `.read()`:
*
* ```ts
* import { Application } from "https://deno.land/x/oak/mod.ts";
*
* const app = new Application();
*
* app.use(async (ctx) => {
* const body = ctx.request.body();
* if (body.type === "form-data") {
* const value = body.value;
* const formData = await value.read();
* // the form data is fully available
* }
* });
* ```
*
* Using `.stream()`:
*
* ```ts
* import { Application } from "https://deno.land/x/oak/mod.ts";
*
* const app = new Application();
*
* app.use(async (ctx) => {
* const body = ctx.request.body();
* if (body.type === "form-data") {
* const value = body.value;
* for await (const [name, value] of value.stream()) {
* // asynchronously iterate each part of the body
* }
* }
* });
* ```
*/
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 errors.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;
}
/** Reads the multipart body of the response and resolves with an object which
* contains fields and files that were part of the response.
*
* *Note*: this method handles multiple files with the same `name` attribute
* in the request, but by design it does not handle multiple fields that share
* the same `name`. If you expect the request body to contain multiple form
* data fields with the same name, it is better to use the `.stream()` method
* which will iterate over each form data field individually. */
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,
customContentTypes,
} = 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,
customContentTypes,
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;
}
/** Returns an iterator which will asynchronously yield each part of the form
* data. The yielded value is a tuple, where the first element is the name
* of the part and the second element is a `string` or a `FormDataFile`
* object. */
async *stream(
options: FormDataReadOptions = {},
): AsyncIterableIterator<[name: string, value: string | FormDataFile]> {
if (this.#reading) {
throw new Error("Body is already being read.");
}
this.#reading = true;
const {
outPath,
customContentTypes,
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,
customContentTypes,
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;
}
}
}
[Symbol.for("Deno.customInspect")](inspect: (value: unknown) => string) {
return `${this.constructor.name} ${inspect({})}`;
}
[Symbol.for("nodejs.util.inspect.custom")](
depth: number,
// deno-lint-ignore no-explicit-any
options: any,
inspect: (value: unknown, options?: unknown) => string,
) {
if (depth < 0) {
return options.stylize(`[${this.constructor.name}]`, "special");
}
const newOptions = Object.assign({}, options, {
depth: options.depth === null ? null : options.depth - 1,
});
return `${options.stylize(this.constructor.name, "special")} ${
inspect({}, newOptions)
}`;
}
}