From ac03e722e57c68ca9447fc6050bd79481a1e91d6 Mon Sep 17 00:00:00 2001 From: botre Date: Thu, 16 Jul 2026 11:29:24 +0200 Subject: [PATCH] Render multipart/form-data bodies as a parsed JSON view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File uploads and multi-field forms were previously shown as raw, boundary-delimited text, making them hard to inspect at a glance. Parse them client-side into an ordered array of parts instead — {name, value} for fields, {name, filename, contentType, size} for files (content itself is never shown). Falls back to the existing raw display for anything that doesn't parse as well-formed multipart. Split renderBody/htmlEscape out of index.js into render-body.js alongside the new parsing logic, since that's the piece actually growing. --- e2e/tests/endpoint-screen.spec.ts | 66 ++++++++++++++ public/index.js | 33 ------- public/render-body.js | 143 ++++++++++++++++++++++++++++++ src/views/layouts/main.html | 1 + 4 files changed, 210 insertions(+), 33 deletions(-) create mode 100644 public/render-body.js diff --git a/e2e/tests/endpoint-screen.spec.ts b/e2e/tests/endpoint-screen.spec.ts index 365fb1e..8121a19 100644 --- a/e2e/tests/endpoint-screen.spec.ts +++ b/e2e/tests/endpoint-screen.spec.ts @@ -149,6 +149,72 @@ test.describe("Endpoint screen", () => { expect(tagCount).toBeGreaterThan(0); }); + test("renders multipart/form-data fields as a parsed JSON array", async ({ + page, + request, + }) => { + await request.post(endpointUrl, { + multipart: { firstName: "Ada", role: "engineer" }, + }); + const body = page.locator('[data-test="request-body"]').first(); + const text = await body.locator("pre").innerText(); + expect(text).toContain('"name": "firstName"'); + expect(text).toContain('"value": "Ada"'); + expect(text).toContain('"name": "role"'); + expect(text).toContain('"value": "engineer"'); + const tokenCount = await body.locator("pre span.hljs-string").count(); + expect(tokenCount).toBeGreaterThan(0); + }); + + test("renders multipart file parts as metadata only, never file content", async ({ + page, + request, + }) => { + await request.post(endpointUrl, { + multipart: { + avatar: { + name: "avatar.png", + mimeType: "image/png", + buffer: Buffer.from("fake-png-bytes"), + }, + }, + }); + const body = page.locator('[data-test="request-body"]').first(); + const text = await body.locator("pre").innerText(); + expect(text).toContain('"filename": "avatar.png"'); + expect(text).toContain('"contentType": "image/png"'); + expect(text).toMatch(/"size":\s*\d+/); + expect(text).not.toContain("fake-png-bytes"); + }); + + test("keeps repeated multipart field names as separate array entries", async ({ + page, + request, + }) => { + const formData = new FormData(); + formData.append("tag", "red"); + formData.append("tag", "blue"); + await request.post(endpointUrl, { multipart: formData }); + const body = page.locator('[data-test="request-body"]').first(); + const text = await body.locator("pre").innerText(); + const tagValues = [ + ...text.matchAll(/"name": "tag",\s*\n\s*"value": "(\w+)"/g), + ].map((m) => m[1]); + expect(tagValues).toEqual(["red", "blue"]); + }); + + test("falls back to raw display when a multipart body has no boundary", async ({ + page, + request, + }) => { + await post(request, endpointUrl, { + data: "not-actually-parseable-multipart", + headers: { "Content-Type": "multipart/form-data" }, + }); + const body = page.locator('[data-test="request-body"]').first(); + await expect(body).toContainText("not-actually-parseable-multipart"); + }); + test("body copy button writes raw body to clipboard", async ({ page, request, diff --git a/public/index.js b/public/index.js index 40e39c7..24d0a3d 100644 --- a/public/index.js +++ b/public/index.js @@ -24,39 +24,6 @@ window.formatTimeAgo = function (date) { return ""; }; -window.htmlEscape = function (s) { - return String(s) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -}; - -window.renderBody = function (body, headers) { - if (body == null || body === "") return ""; - // Best-effort JSON pretty + highlight. - try { - const pretty = JSON.stringify(JSON.parse(body), null, 2); - if (window.hljs && window.hljs.getLanguage("json")) { - return window.hljs.highlight(pretty, { language: "json" }).value; - } - return window.htmlEscape(pretty); - } catch (_) { - // not JSON - } - // Heuristic: looks like XML/HTML if it starts with '<' - const trimmed = body.trimStart(); - if ( - trimmed.startsWith("<") && - window.hljs && - window.hljs.getLanguage("xml") - ) { - return window.hljs.highlight(body, { language: "xml" }).value; - } - return window.htmlEscape(body); -}; - /* Clipboard helper */ window.copyToClipboard = async function (text) { diff --git a/public/render-body.js b/public/render-body.js new file mode 100644 index 0000000..b86187e --- /dev/null +++ b/public/render-body.js @@ -0,0 +1,143 @@ +/* Body rendering: best-effort content-type-aware display for a captured + request body (JSON pretty-print, multipart/form-data part list, XML + highlighting, or escaped raw text). Loaded on every page. */ + +function htmlEscape(s) { + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function highlightPrettyJSON(value) { + const pretty = JSON.stringify(value, null, 2); + if (window.hljs && window.hljs.getLanguage("json")) { + return window.hljs.highlight(pretty, { language: "json" }).value; + } + return htmlEscape(pretty); +} + +/* Case-insensitive lookup into a headers object whose values are either a + scalar string or a string[] (see the flattening in application.go). */ +function headerValue(headers, name) { + if (!headers) return undefined; + const key = Object.keys(headers).find( + (k) => k.toLowerCase() === name.toLowerCase(), + ); + if (key === undefined) return undefined; + const value = headers[key]; + return Array.isArray(value) ? value[0] : value; +} + +/* Extracts the `boundary` parameter from a multipart/form-data Content-Type + header value (quoted or unquoted). Returns null if the header isn't + multipart/form-data or carries no boundary. */ +function multipartBoundary(contentType) { + if (!contentType) return null; + const [mime, ...params] = contentType.split(";").map((s) => s.trim()); + if (mime.toLowerCase() !== "multipart/form-data") return null; + for (const param of params) { + const eq = param.indexOf("="); + if (eq <= 0) continue; + if (param.slice(0, eq).trim().toLowerCase() !== "boundary") continue; + let value = param.slice(eq + 1).trim(); + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1); + } + return value || null; + } + return null; +} + +/* Best-effort parse of a raw multipart/form-data body into an ordered array + of parts: {name, value} for fields, {name, filename, contentType, size} + for file parts (the file content itself is never shown). Returns null on + anything that doesn't look like well-formed multipart, so the caller can + fall through to the next rendering strategy. */ +function parseMultipart(body, boundary) { + const segments = body.split("--" + boundary); + // segments[0] is the preamble before the first delimiter, and the last + // segment is the epilogue after the closing "--boundary--"; both are + // discarded rather than treated as parts. + if (segments.length < 3) return null; + + const parts = []; + for (let i = 1; i < segments.length - 1; i++) { + let segment = segments[i]; + if (segment.startsWith("\r\n")) segment = segment.slice(2); + else if (segment.startsWith("\n")) segment = segment.slice(1); + if (segment.endsWith("\r\n")) segment = segment.slice(0, -2); + else if (segment.endsWith("\n")) segment = segment.slice(0, -1); + + const headerEnd = segment.search(/\r?\n\r?\n/); + if (headerEnd === -1) return null; + const headerLines = segment.slice(0, headerEnd).split(/\r?\n/); + const contentMatch = segment + .slice(headerEnd) + .match(/^\r?\n\r?\n([\s\S]*)$/); + const content = contentMatch ? contentMatch[1] : ""; + + const disposition = headerLines.find((line) => + /^content-disposition\s*:/i.test(line), + ); + if (!disposition) return null; + + const nameMatch = disposition.match(/name="([^"]*)"/i); + if (!nameMatch) return null; + const name = nameMatch[1]; + + const filenameMatch = disposition.match(/filename="([^"]*)"/i); + if (!filenameMatch) { + parts.push({ name, value: content }); + continue; + } + + const contentTypeLine = headerLines.find((line) => + /^content-type\s*:/i.test(line), + ); + const contentType = contentTypeLine + ? contentTypeLine.split(":").slice(1).join(":").trim() + : "application/octet-stream"; + parts.push({ + name, + filename: filenameMatch[1], + contentType, + // Byte length of the captured body, which httphq stores as a Go + // string — encoding/json replaces invalid UTF-8 with U+FFFD on + // marshal, so for genuinely binary uploads this can differ from the + // true original file size. See database.Request.Body. + size: new TextEncoder().encode(content).length, + }); + } + return parts; +} + +window.renderBody = function (body, headers) { + if (body == null || body === "") return ""; + + const boundary = multipartBoundary(headerValue(headers, "Content-Type")); + if (boundary) { + const parts = parseMultipart(body, boundary); + if (parts !== null) return highlightPrettyJSON(parts); + // Malformed multipart body — fall through to the generic paths below. + } + + // Best-effort JSON pretty + highlight. + try { + return highlightPrettyJSON(JSON.parse(body)); + } catch (_) { + // not JSON + } + // Heuristic: looks like XML/HTML if it starts with '<' + const trimmed = body.trimStart(); + if ( + trimmed.startsWith("<") && + window.hljs && + window.hljs.getLanguage("xml") + ) { + return window.hljs.highlight(body, { language: "xml" }).value; + } + return htmlEscape(body); +}; diff --git a/src/views/layouts/main.html b/src/views/layouts/main.html index 793e325..23f2307 100644 --- a/src/views/layouts/main.html +++ b/src/views/layouts/main.html @@ -36,6 +36,7 @@ loads, because Alpine v3 schedules its first alpine:init dispatch via queueMicrotask at the end of its own script execution. --> +