diff --git a/apps/api/src/routes/batch.ts b/apps/api/src/routes/batch.ts index 70d5e734..72f6102c 100644 --- a/apps/api/src/routes/batch.ts +++ b/apps/api/src/routes/batch.ts @@ -262,7 +262,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise { "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 } }); diff --git a/apps/api/src/routes/pipeline.ts b/apps/api/src/routes/pipeline.ts index 658fd9b9..77044335 100644 --- a/apps/api/src/routes/pipeline.ts +++ b/apps/api/src/routes/pipeline.ts @@ -731,7 +731,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise = {}; 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 } diff --git a/apps/web/src/hooks/use-tool-processor.ts b/apps/web/src/hooks/use-tool-processor.ts index cf90b675..b8a0be84 100644 --- a/apps/web/src/hooks/use-tool-processor.ts +++ b/apps/web/src/hooks/use-tool-processor.ts @@ -384,7 +384,9 @@ export function useToolProcessor(toolId: string) { const entries = useFileStore.getState().entries; let fileResults: Record = {}; 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 } diff --git a/tests/integration/adversarial-comprehensive.test.ts b/tests/integration/adversarial-comprehensive.test.ts index 800ec1bf..7debb582 100644 --- a/tests/integration/adversarial-comprehensive.test.ts +++ b/tests/integration/adversarial-comprehensive.test.ts @@ -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); }); diff --git a/tests/integration/adversarial-extended.test.ts b/tests/integration/adversarial-extended.test.ts index d0cf7074..1ef1b3a2 100644 --- a/tests/integration/adversarial-extended.test.ts +++ b/tests/integration/adversarial-extended.test.ts @@ -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); diff --git a/tests/integration/adversarial-matrix.test.ts b/tests/integration/adversarial-matrix.test.ts index 0ec5ceb8..fc76585d 100644 --- a/tests/integration/adversarial-matrix.test.ts +++ b/tests/integration/adversarial-matrix.test.ts @@ -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); diff --git a/tests/integration/adversarial-security.test.ts b/tests/integration/adversarial-security.test.ts index 63c0022b..9fad3316 100644 --- a/tests/integration/adversarial-security.test.ts +++ b/tests/integration/adversarial-security.test.ts @@ -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 diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts index 9cd3eef8..fa7b1b87 100644 --- a/tests/integration/api.test.ts +++ b/tests/integration/api.test.ts @@ -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"); diff --git a/tests/integration/batch.test.ts b/tests/integration/batch.test.ts index d559ce6c..eb61f070 100644 --- a/tests/integration/batch.test.ts +++ b/tests/integration/batch.test.ts @@ -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).not.toMatch(/[€-￿]/); + 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).not.toMatch(/[€-￿]/); + 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"); diff --git a/tests/integration/pipeline-edge-cases.test.ts b/tests/integration/pipeline-edge-cases.test.ts index 6a6f7306..7ac67390 100644 --- a/tests/integration/pipeline-edge-cases.test.ts +++ b/tests/integration/pipeline-edge-cases.test.ts @@ -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).not.toMatch(/[-￿]/); + 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([ { diff --git a/tests/integration/svg-to-raster.test.ts b/tests/integration/svg-to-raster.test.ts index a50650d0..3b4485e2 100644 --- a/tests/integration/svg-to-raster.test.ts +++ b/tests/integration/svg-to-raster.test.ts @@ -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); });