Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"eslint-config-unjs"
],
"rules": {
"unicorn/no-null": 0
"unicorn/no-null": "off",
"unicorn/number-literal-case": "off"
}
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ H3 has concept of compasable utilities that accept `event` (from `eventHandler((
- `setResponseStatus(event, status)`
- `getResponseStatus(event)`
- `getResponseStatusText(event)`
- `readMultipartFormData(event)`

πŸ‘‰ You can learn more about usage in [JSDocs Documentation](https://www.jsdocs.io/package/h3#package-functions).

Expand Down
19 changes: 18 additions & 1 deletion src/utils/body.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import destr from "destr";
import type { Encoding, HTTPMethod } from "../types";
import type { H3Event } from "../event";
import { assertMethod } from "./request";
import { parse as parseMultipartData } from "./multipart";
import { assertMethod, getRequestHeader } from "./request";

const RawBodySymbol = Symbol.for("h3RawBody");
const ParsedBodySymbol = Symbol.for("h3ParsedBody");
Expand Down Expand Up @@ -102,3 +103,19 @@ export async function readBody<T = any>(event: H3Event): Promise<T> {
(event.node.req as any)[ParsedBodySymbol] = json;
return json;
}

export async function readMultipartFormData(event: H3Event) {
const contentType = getRequestHeader(event, "content-type");
if (!contentType || !contentType.startsWith("multipart/form-data")) {
return;
}
const boundary = contentType.match(/boundary=([^;]*)(;|$)/i)?.[1];
if (!boundary) {
return;
}
const body = await readRawBody(event, false);
if (!body) {
return;
}
return parseMultipartData(body, boundary);
}
123 changes: 123 additions & 0 deletions src/utils/multipart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
Based on:
https://github.com/freesoftwarefactory/parse-multipart - MIT License (The Free Software Factory)
https://github.com/nachomazzara/parse-multipart-data - a44c95319d09fd7d7ba51e01512567c444b90e14 - Ignacio Mazzara
*/

export interface MultiPartData {
data: Buffer;
name?: string;
filename?: string;
type?: string;
}

enum ParsingState {
INIT,
READING_HEADERS,
READING_DATA,
READING_PART_SEPARATOR,
}

export function parse(
multipartBodyBuffer: Buffer,
boundary: string
): MultiPartData[] {
let lastline = "";
let state: ParsingState = ParsingState.INIT;
let buffer: number[] = [];
const allParts: MultiPartData[] = [];

let currentPartHeaders: [string, string][] = [];

for (let i = 0; i < multipartBodyBuffer.length; i++) {
const prevByte: number | null = i > 0 ? multipartBodyBuffer[i - 1] : null;
const currByte: number = multipartBodyBuffer[i];

// 0x0a => \n | 0x0d => \r
const newLineChar: boolean = currByte === 0x0a || currByte === 0x0d;
if (!newLineChar) {
lastline += String.fromCodePoint(currByte);
}

const newLineDetected: boolean = currByte === 0x0a && prevByte === 0x0d;
if (ParsingState.INIT === state && newLineDetected) {
// Searching for boundary
if ("--" + boundary === lastline) {
state = ParsingState.READING_HEADERS; // Found boundary. start reading headers
}
lastline = "";
} else if (ParsingState.READING_HEADERS === state && newLineDetected) {
// Parsing headers.
// Headers are separated by an empty line from the content.
// Stop reading headers when the line is empty
if (lastline.length > 0) {
const i = lastline.indexOf(":");
if (i > 0) {
const name = lastline.slice(0, i).toLowerCase();
const value = lastline.slice(i + 1).trim();
currentPartHeaders.push([name, value]);
}
} else {
// Found empty line.
// Reading headers is finished.
state = ParsingState.READING_DATA;
buffer = [];
}
lastline = "";
} else if (ParsingState.READING_DATA === state) {
// Parsing data
if (lastline.length > boundary.length + 4) {
lastline = ""; // Free memory
}
if ("--" + boundary === lastline) {
const j = buffer.length - lastline.length;
const part = buffer.slice(0, j - 1);
allParts.push(process(part, currentPartHeaders));
buffer = [];
currentPartHeaders = [];
lastline = "";
state = ParsingState.READING_PART_SEPARATOR;
} else {
buffer.push(currByte);
}
if (newLineDetected) {
lastline = "";
}
} else if (
ParsingState.READING_PART_SEPARATOR === state &&
newLineDetected
) {
state = ParsingState.READING_HEADERS;
}
}
return allParts;
}

function process(data: number[], headers: [string, string][]): MultiPartData {
const dataObj: Partial<MultiPartData> = {};

// Meta
const contentDispositionHeader =
headers.find((h) => h[0] === "content-disposition")?.[1] || "";
for (const i of contentDispositionHeader.split(";")) {
const s = i.split("=");
if (s.length !== 2) {
continue;
}
const key = (s[0] || "").trim();
if (key === "name" || key === "filename") {
dataObj[key] = (s[1] || "").trim().replace(/"/g, "");
}
}

// Type
const contentType = headers.find((h) => h[0] === "content-type")?.[1] || "";
if (contentType) {
dataObj.type = contentType;
}

// Data
dataObj.data = Buffer.from(data);

return dataObj as MultiPartData;
}
36 changes: 36 additions & 0 deletions test/body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
readRawBody,
readBody,
eventHandler,
readMultipartFormData,
} from "../src";

describe("", () => {
Expand Down Expand Up @@ -172,5 +173,40 @@ describe("", () => {

expect(result.text).toBe("200");
});

it("parses multipart form data", async () => {
app.use(
"/",
eventHandler(async (request) => {
const parts = (await readMultipartFormData(request)) || [];
return parts.map((part) => ({
...part,
data: part.data.toString("utf8"),
}));
})
);
const result = await request
.post("/api/test")
.set(
"content-type",
"multipart/form-data; boundary=---------------------------12537827810750053901680552518"
)
.send(
'-----------------------------12537827810750053901680552518\r\nContent-Disposition: form-data; name="baz"\r\n\r\nother\r\n-----------------------------12537827810750053901680552518\r\nContent-Disposition: form-data; name="bar"\r\n\r\nsomething\r\n-----------------------------12537827810750053901680552518--\r\n'
);

expect(result.body).toMatchInlineSnapshot(`
[
{
"data": "other",
"name": "baz",
},
{
"data": "something",
"name": "bar",
},
]
`);
});
});
});