fix: percent-encode X-File-Results header for non-ASCII filenames (#133)

Closes #133
This commit is contained in:
SnapOtter
2026-05-13 11:07:12 +08:00
14 changed files with 133 additions and 22 deletions
+1 -1
View File
@@ -4470,7 +4470,7 @@ paths:
X-File-Results:
schema:
type: string
description: JSON mapping of input index to output filename
description: Percent-encoded JSON mapping of input index to output filename (decode with decodeURIComponent before JSON.parse)
content:
application/zip:
schema:
+1 -1
View File
@@ -262,7 +262,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
"Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
"X-Job-Id": jobId,
"X-File-Results": JSON.stringify(fileResultsMap),
"X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)),
});
const archive = archiver("zip", { zlib: { level: 5 } });
+1 -1
View File
@@ -731,7 +731,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
"Content-Disposition": `attachment; filename="pipeline-batch-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
"X-Job-Id": jobId,
"X-File-Results": JSON.stringify(fileResultsMap),
"X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)),
});
const archive = archiver("zip", { zlib: { level: 5 } });
+1 -1
View File
@@ -320,7 +320,7 @@ export function registerSvgToRaster(app: FastifyInstance) {
"Content-Disposition": `attachment; filename="batch-svg-to-raster-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
"X-Job-Id": jobId,
"X-File-Results": JSON.stringify(fileResultsMap),
"X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)),
});
const archive = archiver("zip", { zlib: { level: 5 } });
+3 -1
View File
@@ -330,7 +330,9 @@ export function usePipelineProcessor() {
const entries = useFileStore.getState().entries;
let fileResults: Record<string, string> = {};
try {
fileResults = JSON.parse(response.headers.get("X-File-Results") ?? "{}");
fileResults = JSON.parse(
decodeURIComponent(response.headers.get("X-File-Results") ?? "%7B%7D"),
);
} catch {
// Malformed header - fall back to empty mapping, all entries marked failed
}
+3 -1
View File
@@ -384,7 +384,9 @@ export function useToolProcessor(toolId: string) {
const entries = useFileStore.getState().entries;
let fileResults: Record<string, string> = {};
try {
fileResults = JSON.parse(response.headers.get("X-File-Results") ?? "{}");
fileResults = JSON.parse(
decodeURIComponent(response.headers.get("X-File-Results") ?? "%7B%7D"),
);
} catch {
// Malformed header - fall back to empty mapping, all entries marked failed
}
@@ -1104,7 +1104,7 @@ describe("Batch with mixed image formats (PNG + JPEG + WebP)", () => {
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(Object.keys(fileResults).length).toBe(3);
});
@@ -649,7 +649,7 @@ describe("Batch edge cases — extended", () => {
// Should succeed and deduplicate filenames in the ZIP
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
// All three entries should have unique names
const names = Object.values(fileResults);
expect(new Set(names).size).toBe(3);
@@ -1524,7 +1524,7 @@ describe("Batch limits — boundary tests", () => {
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(Object.keys(fileResults).length).toBe(1);
});
@@ -1546,7 +1546,7 @@ describe("Batch limits — boundary tests", () => {
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(Object.keys(fileResults).length).toBe(10);
}, 120_000);
+2 -2
View File
@@ -1035,7 +1035,7 @@ describe("Batch -- all identical files", () => {
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(Object.keys(fileResults).length).toBe(5);
});
@@ -1050,7 +1050,7 @@ describe("Batch -- all identical files", () => {
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
const names = Object.values(fileResults);
// All three entries should have unique names in the ZIP
expect(new Set(names).size).toBe(3);
@@ -611,7 +611,9 @@ describe("Concurrent requests -- data integrity verification", () => {
// Batch request must succeed
expect(batchRes.statusCode).toBe(200);
expect(batchRes.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(batchRes.headers["x-file-results"] as string);
const fileResults = JSON.parse(
decodeURIComponent(batchRes.headers["x-file-results"] as string),
);
expect(Object.keys(fileResults).length).toBe(3);
// The single request's job ID must not appear in the batch results
+3 -3
View File
@@ -3102,7 +3102,7 @@ describe("Batch processing", () => {
});
expect(res.statusCode).toBe(200);
expect(res.headers["x-file-results"]).toBeDefined();
const parsed = JSON.parse(res.headers["x-file-results"] as string);
const parsed = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(parsed["0"]).toBeDefined();
expect(parsed["1"]).toBeDefined();
});
@@ -3189,7 +3189,7 @@ describe("Batch processing", () => {
const fileResults = res.headers["x-file-results"];
expect(fileResults).toBeDefined();
const parsed = JSON.parse(fileResults as string);
const parsed = JSON.parse(decodeURIComponent(fileResults as string));
expect(parsed["0"]).toBeDefined();
expect(parsed["1"]).toBeDefined();
expect(typeof parsed["0"]).toBe("string");
@@ -3215,7 +3215,7 @@ describe("Batch processing", () => {
});
expect(res.statusCode).toBe(200);
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
const names = [fileResults["0"], fileResults["1"], fileResults["2"]];
expect(names[0]).toContain("aaa");
expect(names[1]).toContain("bbb");
+82 -4
View File
@@ -130,12 +130,90 @@ describe("X-File-Results header", () => {
});
expect(res.statusCode).toBe(200);
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(fileResults).toBeDefined();
// Should have entries for index 0 and 1
expect(fileResults["0"]).toBeDefined();
expect(fileResults["1"]).toBeDefined();
});
it("encodes non-ASCII filenames in header without ERR_INVALID_CHAR", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "图片测试.png", contentType: "image/png", content: PNG },
{ name: "file", filename: "テスト.jpg", contentType: "image/jpeg", content: JPG },
{ name: "settings", content: JSON.stringify({ width: 80 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize/batch",
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
body,
});
expect(res.statusCode).toBe(200);
const raw = res.headers["x-file-results"] as string;
expect(raw).toMatch(/^[\x20-\x7E]+$/);
const fileResults = JSON.parse(decodeURIComponent(raw));
expect(fileResults["0"]).toContain("图片测试");
expect(fileResults["1"]).toContain("テスト");
});
it("handles mixed ASCII and non-ASCII filenames", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "normal.png", contentType: "image/png", content: PNG },
{ name: "file", filename: "élève-photo.jpg", contentType: "image/jpeg", content: JPG },
{ name: "file", filename: "📷-snap.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({ width: 80 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize/batch",
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
body,
});
expect(res.statusCode).toBe(200);
const raw = res.headers["x-file-results"] as string;
expect(raw).toMatch(/^[\x20-\x7E]+$/);
const fileResults = JSON.parse(decodeURIComponent(raw));
expect(fileResults["0"]).toContain("normal");
expect(fileResults["1"]).toContain("élève");
expect(fileResults["2"]).toContain("📷");
});
it("round-trips filenames with special URI characters", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "file with spaces & (parens).png",
contentType: "image/png",
content: PNG,
},
{ name: "settings", content: JSON.stringify({ width: 80 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize/batch",
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
body,
});
expect(res.statusCode).toBe(200);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(fileResults["0"]).toBeDefined();
});
});
// ── ClientJobId passthrough ─────────────────────────────────────
@@ -363,7 +441,7 @@ describe("Exotic format batch processing", () => {
it(`processes ${ext.toUpperCase()} through batch compress`, async () => {
const res = await batchCompress(ext, mime);
expect(res.statusCode).toBe(200);
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(fileResults["0"]).toBeDefined();
});
}
@@ -408,7 +486,7 @@ describe("Exotic format batch processing", () => {
});
expect(res.statusCode).toBe(200);
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(fileResults["0"]).toBeDefined();
expect(fileResults["1"]).toBeDefined();
expect(fileResults["2"]).toBeDefined();
@@ -439,7 +517,7 @@ describe("Batch preserves upload order", () => {
});
expect(res.statusCode).toBe(200);
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
// Index 0 should be derived from alpha, 1 from beta, 2 from gamma
expect(fileResults["0"]).toContain("alpha");
@@ -426,6 +426,33 @@ describe("Pipeline batch execution", () => {
expect(res.headers["x-file-results"]).toBeDefined();
});
it("handles non-ASCII filenames without ERR_INVALID_CHAR (#133)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "图片.png", contentType: "image/png", content: PNG_200x150 },
{ name: "file", filename: "写真テスト.png", contentType: "image/png", content: PNG_200x150 },
{
name: "pipeline",
content: JSON.stringify({
steps: [{ toolId: "resize", settings: { width: 50 } }],
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/batch",
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
body,
});
expect(res.statusCode).toBe(200);
const raw = res.headers["x-file-results"] as string;
expect(raw).toMatch(/^[\x20-\x7E]+$/);
const fileResults = JSON.parse(decodeURIComponent(raw));
expect(fileResults["0"]).toContain("图片");
expect(fileResults["1"]).toContain("写真テスト");
});
it("rejects pipeline batch with no files", async () => {
const { body, contentType } = createMultipartPayload([
{
+2 -2
View File
@@ -854,7 +854,7 @@ describe("svg-to-raster", () => {
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
// X-File-Results should contain deduplicated names
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
const filenames = Object.values(fileResults) as string[];
// Filenames should be unique
expect(new Set(filenames).size).toBe(filenames.length);
@@ -1036,7 +1036,7 @@ describe("svg-to-raster", () => {
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
const fileResults = JSON.parse(res.headers["x-file-results"] as string);
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
expect(Object.keys(fileResults).length).toBe(5);
});