From 542d7e9ca63835bf830ba0a8ed9c838da7177224 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:06:37 +0800 Subject: [PATCH] fix: preserve UTF-8 filenames for exports --- server/server.go | 5 ++++- web/src/lib/api.ts | 25 ++++++++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/server/server.go b/server/server.go index 8a22522..efc4375 100644 --- a/server/server.go +++ b/server/server.go @@ -2332,7 +2332,10 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) { stamp := now.Format("20060102-150405") setDownload := func(contentType, filename string) { w.Header().Set("Content-Type", contentType) - w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + // Keep an ASCII fallback for older clients and provide the real UTF-8 + // name through RFC 5987. Browsers otherwise commonly decode Chinese + // names using the platform legacy code page. + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, sanitizeFilename(filename), url.PathEscape(filename))) } switch format { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 5b6e5d5..cf82b7b 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -119,6 +119,24 @@ import type { SystemMetrics, } from "@/lib/types"; +/** + * Read a download name from Content-Disposition. RFC 5987's filename* is + * preferred because it preserves UTF-8 names; older filename remains the + * fallback for clients that do not understand it. + */ +export function downloadFilename(contentDisposition: string, fallback: string): string { + const encoded = contentDisposition.match(/(?:^|;)\s*filename\*\s*=\s*UTF-8''([^;]+)/i)?.[1]; + if (encoded) { + try { + return decodeURIComponent(encoded.replace(/^"|"$/g, "")); + } catch { + // Fall through to the legacy token when a malformed header is received. + } + } + const legacy = contentDisposition.match(/(?:^|;)\s*filename\s*=\s*(?:"([^"]*)"|([^;\s]+))/i); + return (legacy?.[1] ?? legacy?.[2] ?? fallback).trim() || fallback; +} + function getToken(): string | null { if (typeof window === "undefined") return null; return localStorage.getItem("artex_token"); @@ -534,6 +552,7 @@ export const api = { }, workspaceDownload: async (path: string) => { let blob: Blob; + let contentDisposition = ""; if (MOCK) { blob = new Blob([`(demo)${path} 的下载内容示例。`], { type: "text/plain" }); } else { @@ -542,12 +561,13 @@ export const api = { headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) }, }); if (!r.ok) throw new Error(`下载失败: ${r.status}`); + contentDisposition = r.headers.get("Content-Disposition") ?? ""; blob = await r.blob(); } const objUrl = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = objUrl; - a.download = path.split("/").pop() || "download"; + a.download = downloadFilename(contentDisposition, path.split("/").pop() || "download"); document.body.appendChild(a); a.click(); a.remove(); @@ -703,8 +723,7 @@ export const api = { const blob = await r.blob(); // 文件名优先取后端 Content-Disposition,取不到则用默认名。 const disp = r.headers.get("Content-Disposition") ?? ""; - const m = disp.match(/filename="?([^"]+)"?/); - const filename = m?.[1] ?? `findings-export`; + const filename = downloadFilename(disp, `findings-export`); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url;