From aaaf83805c160efba88dc73ee39bb0105f2a0fb8 Mon Sep 17 00:00:00 2001 From: m4 Date: Sat, 8 Aug 2026 11:17:34 +0800 Subject: [PATCH] feat(webui): fileResponseHeaders with render-mode for html preview Co-Authored-By: Claude Opus 4.7 --- src/lib/server/workspaceFileHeaders.test.ts | 79 ++++++++++++++++++++ src/lib/server/workspaceFileHeaders.ts | 83 +++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 src/lib/server/workspaceFileHeaders.test.ts create mode 100644 src/lib/server/workspaceFileHeaders.ts diff --git a/src/lib/server/workspaceFileHeaders.test.ts b/src/lib/server/workspaceFileHeaders.test.ts new file mode 100644 index 0000000..7098726 --- /dev/null +++ b/src/lib/server/workspaceFileHeaders.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { fileResponseHeaders } from "./workspaceFileHeaders"; + +vi.mock("server-only", () => ({})); + +describe("fileResponseHeaders", () => { + it("renders html as text/html with an allow-scripts sandbox in render mode", () => { + const h = fileResponseHeaders("html", "report.html", { render: true }); + expect(h.contentType).toBe("text/html; charset=utf-8"); + expect(h.csp).toBe("sandbox allow-scripts"); + expect(h.disposition).toMatch(/^inline;/); + expect(h.disposition).toContain('filename="report.html"'); + }); + + it("ignores render for non-html extensions", () => { + const h = fileResponseHeaders("md", "notes.md", { render: true }); + expect(h.contentType).toBe("text/markdown; charset=utf-8"); + expect(h.csp).toBe("sandbox"); + }); + + it("lets download take precedence over render for html", () => { + const h = fileResponseHeaders("html", "report.html", { + render: true, + download: true, + }); + expect(h.contentType).toBe("text/plain; charset=utf-8"); + expect(h.csp).toBe("sandbox"); + expect(h.disposition).toMatch(/^attachment;/); + }); + + it("keeps plain html (no render) as text/plain with a full sandbox", () => { + const h = fileResponseHeaders("html", "report.html", {}); + expect(h.contentType).toBe("text/plain; charset=utf-8"); + expect(h.csp).toBe("sandbox"); + expect(h.disposition).toMatch(/^inline;/); + }); + + it("serves css and js with executable MIME types so render-mode pages can use them", () => { + expect(fileResponseHeaders("css", "a.css", {}).contentType).toBe( + "text/css; charset=utf-8" + ); + expect(fileResponseHeaders("js", "a.js", {}).contentType).toBe( + "text/javascript; charset=utf-8" + ); + }); + + it("keeps images and pdf inline with their MIME types", () => { + expect(fileResponseHeaders("png", "a.png", {}).contentType).toBe("image/png"); + expect(fileResponseHeaders("pdf", "a.pdf", {}).contentType).toBe("application/pdf"); + expect(fileResponseHeaders("png", "a.png", {}).disposition).toMatch(/^inline;/); + }); + + it("forces unknown extensions to attachment octet-stream", () => { + const h = fileResponseHeaders("bin", "a.bin", {}); + expect(h.contentType).toBe("application/octet-stream"); + expect(h.disposition).toMatch(/^attachment;/); + }); + + it("forces download=1 to attachment for inline types", () => { + const h = fileResponseHeaders("png", "a.png", { download: true }); + expect(h.disposition).toMatch(/^attachment;/); + }); + + it("keeps ts/tsx as plain text (never executed)", () => { + expect(fileResponseHeaders("ts", "a.ts", {}).contentType).toBe( + "text/plain; charset=utf-8" + ); + expect(fileResponseHeaders("tsx", "a.tsx", {}).contentType).toBe( + "text/plain; charset=utf-8" + ); + }); + + it("encodes non-ASCII filenames per RFC 6266", () => { + const h = fileResponseHeaders("html", "报告.html", { render: true }); + expect(h.disposition).toContain('filename="__.html"'); + expect(h.disposition).toContain("filename*=UTF-8''"); + expect(h.disposition).toContain("%E6%8A%A5%E5%91%8A"); + }); +}); diff --git a/src/lib/server/workspaceFileHeaders.ts b/src/lib/server/workspaceFileHeaders.ts new file mode 100644 index 0000000..5175ef0 --- /dev/null +++ b/src/lib/server/workspaceFileHeaders.ts @@ -0,0 +1,83 @@ +import "server-only"; + +/** RFC 6266 Content-Disposition value with both an ASCII fallback and a UTF-8 + * `filename*` so non-ASCII names (e.g. Chinese) download with their real name + * instead of percent-encoded gibberish. */ +function contentDisposition(fileName: string, asAttachment: boolean): string { + const ascii = fileName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); + const encoded = encodeURIComponent(fileName).replace( + /['()*]/g, + (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase() + ); + return `${ + asAttachment ? "attachment" : "inline" + }; filename="${ascii}"; filename*=UTF-8''${encoded}`; +} + +// Map common research-output extensions to a Content-Type. Anything unlisted is +// served as a download (octet-stream) so the browser never tries to execute it. +const CONTENT_TYPES: Record = { + txt: "text/plain; charset=utf-8", + md: "text/markdown; charset=utf-8", + log: "text/plain; charset=utf-8", + csv: "text/csv; charset=utf-8", + tsv: "text/tab-separated-values; charset=utf-8", + json: "application/json; charset=utf-8", + py: "text/plain; charset=utf-8", + ts: "text/plain; charset=utf-8", + tsx: "text/plain; charset=utf-8", + sh: "text/plain; charset=utf-8", + yaml: "text/plain; charset=utf-8", + yml: "text/plain; charset=utf-8", + toml: "text/plain; charset=utf-8", + tex: "text/plain; charset=utf-8", + bib: "text/plain; charset=utf-8", + html: "text/plain; charset=utf-8", // never text/html without render=1 + xml: "text/plain; charset=utf-8", + // css/js use executable MIME types so pages loaded with ?render=1 can apply + // their same-directory assets (nosniff blocks text/plain). Directly opening + // them stays safe: the CSP sandbox forbids script execution there. + css: "text/css; charset=utf-8", + js: "text/javascript; charset=utf-8", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + bmp: "image/bmp", + pdf: "application/pdf", +}; + +export interface FileResponseHeaders { + contentType: string; + disposition: string; + csp: string; +} + +export function fileResponseHeaders( + ext: string, + fileName: string, + opts: { download?: boolean; render?: boolean } +): FileResponseHeaders { + const download = opts.download === true; + // render=1 is honored only for html and never overrides an explicit download. + if (ext === "html" && opts.render === true && !download) { + return { + contentType: "text/html; charset=utf-8", + disposition: contentDisposition(fileName, false), + // allow-scripts for interactive reports (plotly/echarts); no + // allow-same-origin, so scripts can't reach the app's cookies or DOM. + csp: "sandbox allow-scripts", + }; + } + const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream"; + const asAttachment = download || contentType === "application/octet-stream"; + return { + contentType, + disposition: contentDisposition(fileName, asAttachment), + // Workspace files are agent/user-controlled. `sandbox` neutralizes scripts + // in an inline SVG/HTML opened directly (XSS). + csp: "sandbox", + }; +}