From d9071339443a1da3fbb4c65fa19b4f65328da2f3 Mon Sep 17 00:00:00 2001 From: Marcos Casagrande Date: Mon, 8 Jun 2020 18:08:26 +0200 Subject: [PATCH] fix(cli/web/fetch): multipart/form-data request body support for binary files (#5886) --- cli/js/web/fetch.ts | 45 +++---------------- cli/js/web/fetch/multipart.ts | 81 +++++++++++++++++++++++++++++++++ cli/tests/unit/fetch_test.ts | 84 +++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 38 deletions(-) diff --git a/cli/js/web/fetch.ts b/cli/js/web/fetch.ts index 47ed1a7d17..045d1afcd2 100644 --- a/cli/js/web/fetch.ts +++ b/cli/js/web/fetch.ts @@ -2,15 +2,15 @@ import { notImplemented } from "../util.ts"; import { isTypedArray } from "./util.ts"; import * as domTypes from "./dom_types.d.ts"; -import { TextDecoder, TextEncoder } from "./text_encoding.ts"; +import { TextEncoder } from "./text_encoding.ts"; import { DenoBlob, bytesSymbol as blobBytesSymbol } from "./blob.ts"; import { read } from "../ops/io.ts"; import { close } from "../ops/resources.ts"; import { fetch as opFetch, FetchResponse } from "../ops/fetch.ts"; import * as Body from "./body.ts"; -import { DomFileImpl } from "./dom_file.ts"; import { getHeaderValueParams } from "./util.ts"; import { ReadableStreamImpl } from "./streams/readable_stream.ts"; +import { MultipartBuilder } from "./fetch/multipart.ts"; const NULL_BODY_STATUS = [101, 204, 205, 304]; const REDIRECT_STATUS = [301, 302, 303, 307, 308]; @@ -232,45 +232,14 @@ export async function fetch( body = init.body[blobBytesSymbol]; contentType = init.body.type; } else if (init.body instanceof FormData) { - let boundary = ""; + let boundary; if (headers.has("content-type")) { const params = getHeaderValueParams("content-type"); - if (params.has("boundary")) { - boundary = params.get("boundary")!; - } + boundary = params.get("boundary")!; } - if (!boundary) { - boundary = - "----------" + - Array.from(Array(32)) - .map(() => Math.random().toString(36)[2] || 0) - .join(""); - } - - let payload = ""; - for (const [fieldName, fieldValue] of init.body.entries()) { - let part = `\r\n--${boundary}\r\n`; - part += `Content-Disposition: form-data; name=\"${fieldName}\"`; - if (fieldValue instanceof DomFileImpl) { - part += `; filename=\"${fieldValue.name}\"`; - } - part += "\r\n"; - if (fieldValue instanceof DomFileImpl) { - part += `Content-Type: ${ - fieldValue.type || "application/octet-stream" - }\r\n`; - } - part += "\r\n"; - if (fieldValue instanceof DomFileImpl) { - part += new TextDecoder().decode(fieldValue[blobBytesSymbol]); - } else { - part += fieldValue; - } - payload += part; - } - payload += `\r\n--${boundary}--`; - body = new TextEncoder().encode(payload); - contentType = "multipart/form-data; boundary=" + boundary; + const multipartBuilder = new MultipartBuilder(init.body, boundary); + body = multipartBuilder.getBody(); + contentType = multipartBuilder.getContentType(); } else { // TODO: ReadableStream notImplemented(); diff --git a/cli/js/web/fetch/multipart.ts b/cli/js/web/fetch/multipart.ts index 792f9b5ee1..654d4a0ea9 100644 --- a/cli/js/web/fetch/multipart.ts +++ b/cli/js/web/fetch/multipart.ts @@ -1,5 +1,8 @@ // Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. +import { Buffer } from "../../buffer.ts"; +import { bytesSymbol } from "../blob.ts"; +import { DomFileImpl } from "../dom_file.ts"; import { DenoBlob } from "../blob.ts"; import { TextEncoder, TextDecoder } from "../text_encoding.ts"; import { getHeaderValueParams } from "../util.ts"; @@ -14,6 +17,84 @@ interface MultipartHeaders { disposition: Map; } +export class MultipartBuilder { + readonly boundary: string; + readonly formData: FormData; + readonly writer: Buffer; + constructor(formData: FormData, boundary?: string) { + this.boundary = boundary ?? this.#createBoundary(); + this.formData = formData; + this.writer = new Buffer(); + } + + getContentType(): string { + return `multipart/form-data; boundary=${this.boundary}`; + } + + getBody(): Uint8Array { + for (const [fieldName, fieldValue] of this.formData.entries()) { + if (fieldValue instanceof DomFileImpl) { + this.#writeFile(fieldName, fieldValue); + } else this.#writeField(fieldName, fieldValue as string); + } + + this.writer.writeSync(encoder.encode(`\r\n--${this.boundary}--`)); + + return this.writer.bytes(); + } + + #createBoundary = (): string => { + return ( + "----------" + + Array.from(Array(32)) + .map(() => Math.random().toString(36)[2] || 0) + .join("") + ); + }; + + #writeHeaders = (headers: string[][]): void => { + let buf = this.writer.empty() ? "" : "\r\n"; + + buf += `--${this.boundary}\r\n`; + for (const [key, value] of headers) { + buf += `${key}: ${value}\r\n`; + } + buf += `\r\n`; + + this.writer.write(encoder.encode(buf)); + }; + + #writeFileHeaders = ( + field: string, + filename: string, + type?: string + ): void => { + const headers = [ + [ + "Content-Disposition", + `form-data; name="${field}"; filename="${filename}"`, + ], + ["Content-Type", type || "application/octet-stream"], + ]; + return this.#writeHeaders(headers); + }; + + #writeFieldHeaders = (field: string): void => { + const headers = [["Content-Disposition", `form-data; name="${field}"`]]; + return this.#writeHeaders(headers); + }; + + #writeField = (field: string, value: string): void => { + this.#writeFieldHeaders(field); + this.writer.writeSync(encoder.encode(value)); + }; + + #writeFile = (field: string, value: DomFileImpl): void => { + this.#writeFileHeaders(field, value.name, value.type); + this.writer.writeSync(value[bytesSymbol]); + }; +} + export class MultipartParser { readonly boundary: string; readonly boundaryChars: Uint8Array; diff --git a/cli/tests/unit/fetch_test.ts b/cli/tests/unit/fetch_test.ts index 5544eee24d..98ff427377 100644 --- a/cli/tests/unit/fetch_test.ts +++ b/cli/tests/unit/fetch_test.ts @@ -267,6 +267,60 @@ unitTest( } ); +unitTest( + { perms: { net: true } }, + async function fetchInitFormDataMultipleFilesBody(): Promise { + const files = [ + { + // prettier-ignore + content: new Uint8Array([137,80,78,71,13,10,26,10, 137, 1, 25]), + type: "image/png", + name: "image", + fileName: "some-image.png", + }, + { + // prettier-ignore + content: new Uint8Array([108,2,0,0,145,22,162,61,157,227,166,77,138,75,180,56,119,188,177,183]), + name: "file", + fileName: "file.bin", + expectedType: "application/octet-stream", + }, + { + content: new TextEncoder().encode("deno land"), + type: "text/plain", + name: "text", + fileName: "deno.txt", + }, + ]; + const form = new FormData(); + form.append("field", "value"); + for (const file of files) { + form.append( + file.name, + new Blob([file.content], { type: file.type }), + file.fileName + ); + } + const response = await fetch("http://localhost:4545/echo_server", { + method: "POST", + body: form, + }); + const resultForm = await response.formData(); + assertEquals(form.get("field"), resultForm.get("field")); + for (const file of files) { + const inputFile = form.get(file.name) as File; + const resultFile = resultForm.get(file.name) as File; + assertEquals(inputFile.size, resultFile.size); + assertEquals(inputFile.name, resultFile.name); + assertEquals(file.expectedType || file.type, resultFile.type); + assertEquals( + new Uint8Array(await resultFile.arrayBuffer()), + file.content + ); + } + } +); + unitTest( { perms: { net: true }, @@ -427,6 +481,36 @@ unitTest( } ); +unitTest( + { perms: { net: true } }, + async function fetchInitFormDataTextFileBody(): Promise { + const fileContent = "deno land"; + const form = new FormData(); + form.append("field", "value"); + form.append( + "file", + new Blob([new TextEncoder().encode(fileContent)], { + type: "text/plain", + }), + "deno.txt" + ); + const response = await fetch("http://localhost:4545/echo_server", { + method: "POST", + body: form, + }); + const resultForm = await response.formData(); + assertEquals(form.get("field"), resultForm.get("field")); + + const file = form.get("file") as File; + const resultFile = resultForm.get("file") as File; + + assertEquals(file.size, resultFile.size); + assertEquals(file.name, resultFile.name); + assertEquals(file.type, resultFile.type); + assertEquals(await file.text(), await resultFile.text()); + } +); + unitTest({ perms: { net: true } }, async function fetchUserAgent(): Promise< void > {