feat(webui): fileResponseHeaders with render-mode for html preview

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-08-08 11:17:34 +08:00
parent ef39d6f532
commit aaaf83805c
2 changed files with 162 additions and 0 deletions
@@ -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");
});
});
+83
View File
@@ -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<string, string> = {
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",
};
}