mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
fix(server): keep accented names on downloaded files (#6281)
A file name that fits in Latin-1, like café.txt, went out as a raw byte in Content-Disposition filename= with no filename* parameter, and Chromium saved the file under a name taken from the URL (download.txt). Build the header so every non-ASCII name carries filename*=UTF-8''… with an ASCII fallback. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()}`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user