diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 5c1d0d1404..0ec792e871 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -126,6 +126,7 @@ import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-wo import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { createPaseoWorktreeWorkflow } from "./worktree-session.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; +import { formatAttachmentContentDisposition } from "./file-download/content-disposition.js"; import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js"; import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js"; import type { RequestedSpeechProviders } from "./speech/speech-types.js"; @@ -836,8 +837,8 @@ export async function createPaseoDaemon( return; } - res.attachment(entry.fileName); res.setHeader("Content-Type", entry.mimeType); + res.setHeader("Content-Disposition", formatAttachmentContentDisposition(entry.fileName)); res.setHeader("Content-Length", fileStats.size.toString()); const stream = fileHandle.createReadStream(); diff --git a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts index 685d056a27..ac138a8c01 100644 --- a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts @@ -26,6 +26,10 @@ describe("daemon E2E", () => { describe("file download tokens", () => { test.each([ { fileName: "download.txt", disposition: 'attachment; filename="download.txt"' }, + { + fileName: "café.txt", + disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", + }, { fileName: "中文报告 (最终版).txt", disposition: diff --git a/packages/server/src/server/file-download/content-disposition.test.ts b/packages/server/src/server/file-download/content-disposition.test.ts new file mode 100644 index 0000000000..46748c21ef --- /dev/null +++ b/packages/server/src/server/file-download/content-disposition.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; +import { formatAttachmentContentDisposition } from "./content-disposition.js"; + +describe("formatAttachmentContentDisposition", () => { + test.each([ + { fileName: "report.txt", disposition: 'attachment; filename="report.txt"' }, + { + fileName: "café.txt", + disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", + }, + { + fileName: "Résumé Ångström.pdf", + disposition: + "attachment; filename=\"R?sum? ?ngstr?m.pdf\"; filename*=UTF-8''R%C3%A9sum%C3%A9%20%C3%85ngstr%C3%B6m.pdf", + }, + { + fileName: "中文报告 (最终版).txt", + disposition: + "attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt", + }, + { + fileName: "report-📄.txt", + disposition: + "attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt", + }, + { + fileName: 'say "hi" \\ bye.txt', + disposition: 'attachment; filename="say \\"hi\\" \\\\ bye.txt"', + }, + { + fileName: "100%25 done.txt", + disposition: "attachment; filename=\"100%25 done.txt\"; filename*=UTF-8''100%2525%20done.txt", + }, + { + fileName: "l'été (1)*.txt", + disposition: + "attachment; filename=\"l'?t? (1)*.txt\"; filename*=UTF-8''l%27%C3%A9t%C3%A9%20%281%29%2A.txt", + }, + ])("$fileName", ({ fileName, disposition }) => { + expect(formatAttachmentContentDisposition(fileName)).toBe(disposition); + }); +}); diff --git a/packages/server/src/server/file-download/content-disposition.ts b/packages/server/src/server/file-download/content-disposition.ts new file mode 100644 index 0000000000..1ad7997b7c --- /dev/null +++ b/packages/server/src/server/file-download/content-disposition.ts @@ -0,0 +1,32 @@ +const NON_PRINTABLE_ASCII = /[^\x20-\x7e]/g; +const PERCENT_ESCAPE = /%[0-9A-Fa-f]{2}/; +const QUOTED_STRING_SPECIALS = /[\\"]/g; +const NON_ATTR_CHARS_LEFT_BY_URI_ENCODING = /['()*]/g; + +/** + * Content-Disposition for a file download (RFC 6266). + * + * Any name that is not plain ASCII also gets `filename*=UTF-8''…`, because + * browsers do not read raw Latin-1 bytes in `filename` and fall back to a name + * taken from the URL. A name with a percent escape gets it too, so browsers do + * not decode the escape in `filename`. + */ +export function formatAttachmentContentDisposition(fileName: string): string { + const asciiFileName = fileName.replace(NON_PRINTABLE_ASCII, "?"); + const disposition = `attachment; filename=${quote(asciiFileName)}`; + if (asciiFileName === fileName && !PERCENT_ESCAPE.test(fileName)) { + return disposition; + } + return `${disposition}; filename*=UTF-8''${encodeExtendedValue(fileName)}`; +} + +function quote(value: string): string { + return `"${value.replace(QUOTED_STRING_SPECIALS, "\\$&")}"`; +} + +function encodeExtendedValue(value: string): string { + return encodeURIComponent(value).replace( + NON_ATTR_CHARS_LEFT_BY_URI_ENCODING, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); +}