From df936e308deeb3e40f695d9155bdb2fb4d9e4412 Mon Sep 17 00:00:00 2001 From: caaeco Date: Sun, 21 Jun 2026 16:45:03 +0800 Subject: [PATCH] fix: encode international download filenames --- .../get-attachment-content-disposition.ts | 21 +++++++++++++++++++ lib/utils/resolve-file-proxy.ts | 8 +++++-- routes/files/download.ts | 10 ++++----- routes/files/download/[[...file_path]].ts | 10 ++++----- tests/routes/files.test.ts | 19 +++++++++++++++++ ...get-attachment-content-disposition.test.ts | 14 +++++++++++++ 6 files changed, 68 insertions(+), 14 deletions(-) create mode 100644 lib/utils/get-attachment-content-disposition.ts create mode 100644 tests/utils/get-attachment-content-disposition.test.ts diff --git a/lib/utils/get-attachment-content-disposition.ts b/lib/utils/get-attachment-content-disposition.ts new file mode 100644 index 0000000..4ec9d07 --- /dev/null +++ b/lib/utils/get-attachment-content-disposition.ts @@ -0,0 +1,21 @@ +const encodeRfc5987Value = (value: string): string => + encodeURIComponent(value).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ) + +export const getAttachmentContentDisposition = (fileName: string): string => { + const asciiFallback = + fileName + .replace(/[^\x20-\x7e]/g, "_") + .replace(/["\\]/g, "_") + .replace(/[\r\n]/g, "_") || "file" + + const fallback = `attachment; filename="${asciiFallback}"` + + if (asciiFallback === fileName) { + return fallback + } + + return `${fallback}; filename*=UTF-8''${encodeRfc5987Value(fileName)}` +} diff --git a/lib/utils/resolve-file-proxy.ts b/lib/utils/resolve-file-proxy.ts index 3145b4e..24da8d9 100644 --- a/lib/utils/resolve-file-proxy.ts +++ b/lib/utils/resolve-file-proxy.ts @@ -2,6 +2,7 @@ import type { FileProxy } from "../db/schema" import { readFile } from "node:fs/promises" import { join } from "node:path" import { normalizePath } from "./normalize-path" +import { getAttachmentContentDisposition } from "./get-attachment-content-disposition" export async function resolveFileProxy( proxy: FileProxy, @@ -36,7 +37,7 @@ async function resolveDiskProxy( return new Response(content, { headers: { "Content-Type": contentType, - "Content-Disposition": `attachment; filename="${fileName}"`, + "Content-Disposition": getAttachmentContentDisposition(fileName), "Content-Length": content.byteLength.toString(), }, }) @@ -79,7 +80,10 @@ async function resolveHttpProxy( headers.set("Content-Length", contentLength) } const fileName = relativePath.split("/").pop() || "file" - headers.set("Content-Disposition", `attachment; filename="${fileName}"`) + headers.set( + "Content-Disposition", + getAttachmentContentDisposition(fileName), + ) return new Response(response.body, { status: response.status, diff --git a/routes/files/download.ts b/routes/files/download.ts index 4bb47d3..4a7fdb9 100644 --- a/routes/files/download.ts +++ b/routes/files/download.ts @@ -5,6 +5,7 @@ import { uint8ArrayToArrayBuffer, } from "lib/utils/decode-base64" import { resolveFileProxy } from "lib/utils/resolve-file-proxy" +import { getAttachmentContentDisposition } from "lib/utils/get-attachment-content-disposition" export default withRouteSpec({ methods: ["GET"], @@ -28,15 +29,14 @@ export default withRouteSpec({ } const isText = file.text_content !== undefined + const fileName = file.file_path.split("/").pop() || "file" if (!isText && file.binary_content_b64) { const binaryBody = decodeBase64ToUint8Array(file.binary_content_b64) const responseBody = uint8ArrayToArrayBuffer(binaryBody) return new Response(responseBody, { headers: { "Content-Type": "application/octet-stream", - "Content-Disposition": `attachment; filename="${file.file_path - .split("/") - .pop()}"`, + "Content-Disposition": getAttachmentContentDisposition(fileName), "Content-Length": binaryBody.byteLength.toString(), }, }) @@ -45,9 +45,7 @@ export default withRouteSpec({ return new Response(file.text_content!, { headers: { "Content-Type": "text/plain", - "Content-Disposition": `attachment; filename="${file.file_path - .split("/") - .pop()}"`, + "Content-Disposition": getAttachmentContentDisposition(fileName), }, }) }) diff --git a/routes/files/download/[[...file_path]].ts b/routes/files/download/[[...file_path]].ts index 3e8fa02..af35b45 100644 --- a/routes/files/download/[[...file_path]].ts +++ b/routes/files/download/[[...file_path]].ts @@ -5,6 +5,7 @@ import { uint8ArrayToArrayBuffer, } from "lib/utils/decode-base64" import { resolveFileProxy } from "lib/utils/resolve-file-proxy" +import { getAttachmentContentDisposition } from "lib/utils/get-attachment-content-disposition" export default withRouteSpec({ methods: ["GET"], @@ -29,15 +30,14 @@ export default withRouteSpec({ } const isText = file.text_content !== undefined + const fileName = file.file_path.split("/").pop() || "file" if (!isText && file.binary_content_b64) { const binaryBody = decodeBase64ToUint8Array(file.binary_content_b64) const responseBody = uint8ArrayToArrayBuffer(binaryBody) return new Response(responseBody, { headers: { "Content-Type": "application/octet-stream", - "Content-Disposition": `attachment; filename="${file.file_path - .split("/") - .pop()}"`, + "Content-Disposition": getAttachmentContentDisposition(fileName), "Content-Length": binaryBody.byteLength.toString(), }, }) @@ -46,9 +46,7 @@ export default withRouteSpec({ return new Response(file.text_content!, { headers: { "Content-Type": "text/plain", - "Content-Disposition": `attachment; filename="${file.file_path - .split("/") - .pop()}"`, + "Content-Disposition": getAttachmentContentDisposition(fileName), }, }) }) diff --git a/tests/routes/files.test.ts b/tests/routes/files.test.ts index 196c354..6b0bffa 100644 --- a/tests/routes/files.test.ts +++ b/tests/routes/files.test.ts @@ -112,6 +112,25 @@ test("file download operations2", async () => { }) }) +test("file downloads encode non-ASCII filenames", async () => { + const { axios } = await getTestServer() + const filePath = '/reports/报价 "final".txt' + + await axios.post("/files/upsert", { + file_path: filePath, + text_content: "International filename", + }) + + const response = await axios.get("/files/download", { + params: { file_path: filePath }, + }) + + expect(response.status).toBe(200) + expect(response.headers.get("content-disposition")).toBe( + "attachment; filename=\"__ _final_.txt\"; filename*=UTF-8''%E6%8A%A5%E4%BB%B7%20%22final%22.txt", + ) +}) + test("file delete operations", async () => { const { axios } = await getTestServer() diff --git a/tests/utils/get-attachment-content-disposition.test.ts b/tests/utils/get-attachment-content-disposition.test.ts new file mode 100644 index 0000000..142320d --- /dev/null +++ b/tests/utils/get-attachment-content-disposition.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test" +import { getAttachmentContentDisposition } from "lib/utils/get-attachment-content-disposition" + +test("uses a simple filename for safe ASCII names", () => { + expect(getAttachmentContentDisposition("report.txt")).toBe( + 'attachment; filename="report.txt"', + ) +}) + +test("adds a UTF-8 filename for non-ASCII and unsafe characters", () => { + expect(getAttachmentContentDisposition('报价 "final".txt')).toBe( + "attachment; filename=\"__ _final_.txt\"; filename*=UTF-8''%E6%8A%A5%E4%BB%B7%20%22final%22.txt", + ) +})