diff options
| author | Yusuke Sakurai <kerokerokerop@gmail.com> | 2019-02-11 08:49:48 +0900 |
|---|---|---|
| committer | Ryan Dahl <ry@tinyclouds.org> | 2019-02-10 18:49:48 -0500 |
| commit | 33f62789cde407059abba0a7ac18b2145c648ea7 (patch) | |
| tree | 454d487f232a61f6c57e2ebdad66e49bf939e4d6 /multipart/multipart.ts | |
| parent | ed20bda6ec324b8143c6210024647d2692232c26 (diff) | |
feat: multipart, etc.. (denoland/deno_std#180)
Original: https://github.com/denoland/deno_std/commit/fda9c98d055091fa886fa444ebd1adcd2ecd21bc
Diffstat (limited to 'multipart/multipart.ts')
| -rw-r--r-- | multipart/multipart.ts | 492 |
1 files changed, 492 insertions, 0 deletions
diff --git a/multipart/multipart.ts b/multipart/multipart.ts new file mode 100644 index 000000000..f0caa2160 --- /dev/null +++ b/multipart/multipart.ts @@ -0,0 +1,492 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. + +import { Buffer, Closer, copy, Reader, ReadResult, remove, Writer } from "deno"; + +import { FormFile } from "./formfile.ts"; +import { + bytesFindIndex, + bytesFindLastIndex, + bytesHasPrefix, + bytesEqual +} from "../bytes/bytes.ts"; +import { copyN } from "../io/ioutil.ts"; +import { MultiReader } from "../io/readers.ts"; +import { tempFile } from "../io/util.ts"; +import { BufReader, BufState, BufWriter } from "../io/bufio.ts"; +import { TextProtoReader } from "../textproto/mod.ts"; +import { encoder } from "../strings/strings.ts"; +import * as path from "../fs/path.ts"; + +function randomBoundary() { + let boundary = "--------------------------"; + for (let i = 0; i < 24; i++) { + boundary += Math.floor(Math.random() * 10).toString(16); + } + return boundary; +} + +/** Reader for parsing multipart/form-data */ +export class MultipartReader { + readonly newLine = encoder.encode("\r\n"); + readonly newLineDashBoundary = encoder.encode(`\r\n--${this.boundary}`); + readonly dashBoundaryDash = encoder.encode(`--${this.boundary}--`); + readonly dashBoundary = encoder.encode(`--${this.boundary}`); + readonly bufReader: BufReader; + + constructor(private reader: Reader, private boundary: string) { + this.bufReader = new BufReader(reader); + } + + /** Read all form data from stream. + * If total size of stored data in memory exceed maxMemory, + * overflowed file data will be written to temporal files. + * String field values are never written to files */ + async readForm( + maxMemory: number + ): Promise<{ [key: string]: string | FormFile }> { + const result = Object.create(null); + let maxValueBytes = maxMemory + (10 << 20); + const buf = new Buffer(new Uint8Array(maxValueBytes)); + for (;;) { + const p = await this.nextPart(); + if (!p) { + break; + } + if (p.formName === "") { + continue; + } + buf.reset(); + if (!p.fileName) { + // value + const n = await copyN(buf, p, maxValueBytes); + maxValueBytes -= n; + if (maxValueBytes < 0) { + throw new RangeError("message too large"); + } + const value = buf.toString(); + result[p.formName] = value; + continue; + } + // file + let formFile: FormFile; + const n = await copy(buf, p); + if (n > maxMemory) { + // too big, write to disk and flush buffer + const ext = path.extname(p.fileName); + const { file, filepath } = await tempFile(".", { + prefix: "multipart-", + postfix: ext + }); + try { + const size = await copyN( + file, + new MultiReader(buf, p), + maxValueBytes + ); + file.close(); + formFile = { + filename: p.fileName, + type: p.headers.get("content-type"), + tempfile: filepath, + size + }; + } catch (e) { + await remove(filepath); + } + } else { + formFile = { + filename: p.fileName, + type: p.headers.get("content-type"), + content: buf.bytes(), + size: buf.bytes().byteLength + }; + maxMemory -= n; + maxValueBytes -= n; + } + result[p.formName] = formFile; + } + return result; + } + + private currentPart: PartReader; + private partsRead: number; + + private async nextPart(): Promise<PartReader> { + if (this.currentPart) { + this.currentPart.close(); + } + if (bytesEqual(this.dashBoundary, encoder.encode("--"))) { + throw new Error("boundary is empty"); + } + let expectNewPart = false; + for (;;) { + const [line, state] = await this.bufReader.readSlice("\n".charCodeAt(0)); + if (state === "EOF" && this.isFinalBoundary(line)) { + break; + } + if (state) { + throw new Error("aa" + state.toString()); + } + if (this.isBoundaryDelimiterLine(line)) { + this.partsRead++; + const r = new TextProtoReader(this.bufReader); + const [headers, state] = await r.readMIMEHeader(); + if (state) { + throw state; + } + const np = new PartReader(this, headers); + this.currentPart = np; + return np; + } + if (this.isFinalBoundary(line)) { + break; + } + if (expectNewPart) { + throw new Error(`expecting a new Part; got line ${line}`); + } + if (this.partsRead === 0) { + continue; + } + if (bytesEqual(line, this.newLine)) { + expectNewPart = true; + continue; + } + throw new Error(`unexpected line in next(): ${line}`); + } + } + + private isFinalBoundary(line: Uint8Array) { + if (!bytesHasPrefix(line, this.dashBoundaryDash)) { + return false; + } + let rest = line.slice(this.dashBoundaryDash.length, line.length); + return rest.length === 0 || bytesEqual(skipLWSPChar(rest), this.newLine); + } + + private isBoundaryDelimiterLine(line: Uint8Array) { + if (!bytesHasPrefix(line, this.dashBoundary)) { + return false; + } + const rest = line.slice(this.dashBoundary.length); + return bytesEqual(skipLWSPChar(rest), this.newLine); + } +} + +function skipLWSPChar(u: Uint8Array): Uint8Array { + const ret = new Uint8Array(u.length); + const sp = " ".charCodeAt(0); + const ht = "\t".charCodeAt(0); + let j = 0; + for (let i = 0; i < u.length; i++) { + if (u[i] === sp || u[i] === ht) continue; + ret[j++] = u[i]; + } + return ret.slice(0, j); +} + +let i = 0; + +class PartReader implements Reader, Closer { + n: number = 0; + total: number = 0; + bufState: BufState = null; + index = i++; + + constructor(private mr: MultipartReader, public readonly headers: Headers) {} + + async read(p: Uint8Array): Promise<ReadResult> { + const br = this.mr.bufReader; + const returnResult = (nread: number, bufState: BufState): ReadResult => { + if (bufState && bufState !== "EOF") { + throw bufState; + } + return { nread, eof: bufState === "EOF" }; + }; + if (this.n === 0 && !this.bufState) { + const [peek] = await br.peek(br.buffered()); + const [n, state] = scanUntilBoundary( + peek, + this.mr.dashBoundary, + this.mr.newLineDashBoundary, + this.total, + this.bufState + ); + this.n = n; + this.bufState = state; + if (this.n === 0 && !this.bufState) { + const [_, state] = await br.peek(peek.length + 1); + this.bufState = state; + if (this.bufState === "EOF") { + this.bufState = new RangeError("unexpected eof"); + } + } + } + if (this.n === 0) { + return returnResult(0, this.bufState); + } + + let n = 0; + if (p.byteLength > this.n) { + n = this.n; + } + const buf = p.slice(0, n); + const [nread] = await this.mr.bufReader.readFull(buf); + p.set(buf); + this.total += nread; + this.n -= nread; + if (this.n === 0) { + return returnResult(n, this.bufState); + } + return returnResult(n, null); + } + + close(): void {} + + private contentDisposition: string; + private contentDispositionParams: { [key: string]: string }; + + private getContentDispositionParams() { + if (this.contentDispositionParams) return this.contentDispositionParams; + const cd = this.headers.get("content-disposition"); + const params = {}; + const comps = cd.split(";"); + this.contentDisposition = comps[0]; + comps + .slice(1) + .map(v => v.trim()) + .map(kv => { + const [k, v] = kv.split("="); + if (v) { + const s = v.charAt(0); + const e = v.charAt(v.length - 1); + if ((s === e && s === '"') || s === "'") { + params[k] = v.substr(1, v.length - 2); + } else { + params[k] = v; + } + } + }); + return (this.contentDispositionParams = params); + } + + get fileName(): string { + return this.getContentDispositionParams()["filename"]; + } + + get formName(): string { + const p = this.getContentDispositionParams(); + if (this.contentDisposition === "form-data") { + return p["name"]; + } + return ""; + } +} + +export function scanUntilBoundary( + buf: Uint8Array, + dashBoundary: Uint8Array, + newLineDashBoundary: Uint8Array, + total: number, + state: BufState +): [number, BufState] { + if (total === 0) { + if (bytesHasPrefix(buf, dashBoundary)) { + switch (matchAfterPrefix(buf, dashBoundary, state)) { + case -1: + return [dashBoundary.length, null]; + case 0: + return [0, null]; + case 1: + return [0, "EOF"]; + } + if (bytesHasPrefix(dashBoundary, buf)) { + return [0, state]; + } + } + } + const i = bytesFindIndex(buf, newLineDashBoundary); + if (i >= 0) { + switch (matchAfterPrefix(buf.slice(i), newLineDashBoundary, state)) { + case -1: + return [i + newLineDashBoundary.length, null]; + case 0: + return [i, null]; + case 1: + return [i, "EOF"]; + } + } + if (bytesHasPrefix(newLineDashBoundary, buf)) { + return [0, state]; + } + const j = bytesFindLastIndex(buf, newLineDashBoundary.slice(0, 1)); + if (j >= 0 && bytesHasPrefix(newLineDashBoundary, buf.slice(j))) { + return [j, null]; + } + return [buf.length, state]; +} + +export function matchAfterPrefix( + a: Uint8Array, + prefix: Uint8Array, + bufState: BufState +): number { + if (a.length === prefix.length) { + if (bufState) { + return 1; + } + return 0; + } + const c = a[prefix.length]; + if ( + c === " ".charCodeAt(0) || + c === "\t".charCodeAt(0) || + c === "\r".charCodeAt(0) || + c === "\n".charCodeAt(0) || + c === "-".charCodeAt(0) + ) { + return 1; + } + return -1; +} + +class PartWriter implements Writer { + closed = false; + private readonly partHeader: string; + private headersWritten: boolean = false; + + constructor( + private writer: Writer, + readonly boundary: string, + public headers: Headers, + isFirstBoundary: boolean + ) { + let buf = ""; + if (isFirstBoundary) { + buf += `--${boundary}\r\n`; + } else { + buf += `\r\n--${boundary}\r\n`; + } + for (const [key, value] of headers.entries()) { + buf += `${key}: ${value}\r\n`; + } + buf += `\r\n`; + this.partHeader = buf; + } + + close(): void { + this.closed = true; + } + + async write(p: Uint8Array): Promise<number> { + if (this.closed) { + throw new Error("part is closed"); + } + if (!this.headersWritten) { + await this.writer.write(encoder.encode(this.partHeader)); + this.headersWritten = true; + } + return this.writer.write(p); + } +} + +function checkBoundary(b: string) { + if (b.length < 1 || b.length > 70) { + throw new Error("invalid boundary length: " + b.length); + } + const end = b.length - 1; + for (let i = 0; i < end; i++) { + const c = b.charAt(i); + if (!c.match(/[a-zA-Z0-9'()+_,\-./:=?]/) || (c === " " && i !== end)) { + throw new Error("invalid boundary character: " + c); + } + } + return b; +} + +/** Writer for creating multipart/form-data */ +export class MultipartWriter { + private readonly _boundary: string; + + get boundary() { + return this._boundary; + } + + private lastPart: PartWriter; + private bufWriter: BufWriter; + private isClosed: boolean = false; + + constructor(private readonly writer: Writer, boundary?: string) { + if (boundary !== void 0) { + this._boundary = checkBoundary(boundary); + } else { + this._boundary = randomBoundary(); + } + this.bufWriter = new BufWriter(writer); + } + + formDataContentType(): string { + return `multipart/form-data; boundary=${this.boundary}`; + } + + private createPart(headers: Headers): Writer { + if (this.isClosed) { + throw new Error("multipart: writer is closed"); + } + if (this.lastPart) { + this.lastPart.close(); + } + const part = new PartWriter( + this.writer, + this.boundary, + headers, + !this.lastPart + ); + this.lastPart = part; + return part; + } + + createFormFile(field: string, filename: string): Writer { + const h = new Headers(); + h.set( + "Content-Disposition", + `form-data; name="${field}"; filename="${filename}"` + ); + h.set("Content-Type", "application/octet-stream"); + return this.createPart(h); + } + + createFormField(field: string): Writer { + const h = new Headers(); + h.set("Content-Disposition", `form-data; name="${field}"`); + h.set("Content-Type", "application/octet-stream"); + return this.createPart(h); + } + + async writeField(field: string, value: string) { + const f = await this.createFormField(field); + await f.write(encoder.encode(value)); + } + + async writeFile(field: string, filename: string, file: Reader) { + const f = await this.createFormFile(field, filename); + await copy(f, file); + } + + private flush(): Promise<BufState> { + return this.bufWriter.flush(); + } + + /** Close writer. No additional data can be writen to stream */ + async close() { + if (this.isClosed) { + throw new Error("multipart: writer is closed"); + } + if (this.lastPart) { + this.lastPart.close(); + this.lastPart = void 0; + } + await this.writer.write(encoder.encode(`\r\n--${this.boundary}--\r\n`)); + await this.flush(); + this.isClosed = true; + } +} |
