mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Download files whose names have non-ASCII characters (#6278)
* fix(server): encode Unicode download filenames in Content-Disposition * test(server): cover Korean download filenames * test(server): validate download token and agent ID formats * fix(server): set the download header through Express instead of a new dependency The download route already runs on an Express response, and res.attachment() builds Content-Disposition with the same content-disposition encoder that Express ships. Use it and drop the direct content-disposition dependency and its type package. The header values the download tests assert are unchanged. Co-authored-by: jasonhnd <3293256+jasonhnd@users.noreply.github.com> --------- Co-authored-by: Dillon <dillonzq@outlook.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: jasonhnd <3293256+jasonhnd@users.noreply.github.com>
This commit is contained in:
co-authored by
jasonhnd
Dillon
parent
a162e1075f
commit
1e00f452ea
@@ -836,9 +836,8 @@ export async function createPaseoDaemon(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeFileName = entry.fileName.replace(/["\r\n]/g, "_");
|
res.attachment(entry.fileName);
|
||||||
res.setHeader("Content-Type", entry.mimeType);
|
res.setHeader("Content-Type", entry.mimeType);
|
||||||
res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`);
|
|
||||||
res.setHeader("Content-Length", fileStats.size.toString());
|
res.setHeader("Content-Length", fileStats.size.toString());
|
||||||
|
|
||||||
const stream = fileHandle.createReadStream();
|
const stream = fileHandle.createReadStream();
|
||||||
|
|||||||
@@ -24,42 +24,73 @@ describe("daemon E2E", () => {
|
|||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
describe("file download tokens", () => {
|
describe("file download tokens", () => {
|
||||||
test("issues token over WS and downloads via HTTP", async () => {
|
test.each([
|
||||||
const cwd = tmpCwd();
|
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
|
||||||
const filePath = path.join(cwd, "download.txt");
|
{
|
||||||
const fileContents = "download test payload";
|
fileName: "中文报告 (最终版).txt",
|
||||||
writeFileSync(filePath, fileContents, "utf-8");
|
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: "報告書.md",
|
||||||
|
disposition:
|
||||||
|
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: "보고서.txt",
|
||||||
|
disposition:
|
||||||
|
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: "report-📄.txt",
|
||||||
|
disposition:
|
||||||
|
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"issues token over WS and downloads $fileName via HTTP",
|
||||||
|
async ({ fileName, disposition }) => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
try {
|
||||||
|
const filePath = path.join(cwd, fileName);
|
||||||
|
const fileContents = "download test payload";
|
||||||
|
writeFileSync(filePath, fileContents, "utf-8");
|
||||||
|
|
||||||
const agent = await ctx.client.createAgent({
|
const agent = await ctx.client.createAgent({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
model: CODEX_TEST_MODEL,
|
model: CODEX_TEST_MODEL,
|
||||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||||
cwd,
|
cwd,
|
||||||
title: "Download Token Test Agent",
|
title: "Download Token Test Agent",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(agent.id).toBeTruthy();
|
expect(agent.id).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||||
|
);
|
||||||
|
|
||||||
const tokenResponse = await ctx.client.requestDownloadToken(cwd, "download.txt");
|
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
|
||||||
|
|
||||||
expect(tokenResponse.error).toBeNull();
|
expect(tokenResponse.error).toBeNull();
|
||||||
expect(tokenResponse.token).toBeTruthy();
|
expect(tokenResponse.token).toMatch(
|
||||||
expect(tokenResponse.fileName).toBe("download.txt");
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||||
|
);
|
||||||
|
expect(tokenResponse.fileName).toBe(fileName);
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
|
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType);
|
expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType);
|
||||||
const disposition = response.headers.get("content-disposition") ?? "";
|
expect(response.headers.get("content-disposition")).toBe(disposition);
|
||||||
expect(disposition).toContain("download.txt");
|
|
||||||
|
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
expect(body).toBe(fileContents);
|
expect(body).toBe(fileContents);
|
||||||
|
} finally {
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}, 60000);
|
}
|
||||||
|
},
|
||||||
|
60000,
|
||||||
|
);
|
||||||
|
|
||||||
test("rejects invalid token", async () => {
|
test("rejects invalid token", async () => {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
|
|||||||
Reference in New Issue
Block a user