mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: percent-encode X-File-Results header for non-ASCII filenames (#133)
Closes #133
This commit is contained in:
@@ -4470,7 +4470,7 @@ paths:
|
|||||||
X-File-Results:
|
X-File-Results:
|
||||||
schema:
|
schema:
|
||||||
type: string
|
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:
|
content:
|
||||||
application/zip:
|
application/zip:
|
||||||
schema:
|
schema:
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
"Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`,
|
"Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`,
|
||||||
"Transfer-Encoding": "chunked",
|
"Transfer-Encoding": "chunked",
|
||||||
"X-Job-Id": jobId,
|
"X-Job-Id": jobId,
|
||||||
"X-File-Results": JSON.stringify(fileResultsMap),
|
"X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||||
|
|||||||
@@ -731,7 +731,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
"Content-Disposition": `attachment; filename="pipeline-batch-${jobId.slice(0, 8)}.zip"`,
|
"Content-Disposition": `attachment; filename="pipeline-batch-${jobId.slice(0, 8)}.zip"`,
|
||||||
"Transfer-Encoding": "chunked",
|
"Transfer-Encoding": "chunked",
|
||||||
"X-Job-Id": jobId,
|
"X-Job-Id": jobId,
|
||||||
"X-File-Results": JSON.stringify(fileResultsMap),
|
"X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
|||||||
"Content-Disposition": `attachment; filename="batch-svg-to-raster-${jobId.slice(0, 8)}.zip"`,
|
"Content-Disposition": `attachment; filename="batch-svg-to-raster-${jobId.slice(0, 8)}.zip"`,
|
||||||
"Transfer-Encoding": "chunked",
|
"Transfer-Encoding": "chunked",
|
||||||
"X-Job-Id": jobId,
|
"X-Job-Id": jobId,
|
||||||
"X-File-Results": JSON.stringify(fileResultsMap),
|
"X-File-Results": encodeURIComponent(JSON.stringify(fileResultsMap)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||||
|
|||||||
@@ -330,7 +330,9 @@ export function usePipelineProcessor() {
|
|||||||
const entries = useFileStore.getState().entries;
|
const entries = useFileStore.getState().entries;
|
||||||
let fileResults: Record<string, string> = {};
|
let fileResults: Record<string, string> = {};
|
||||||
try {
|
try {
|
||||||
fileResults = JSON.parse(response.headers.get("X-File-Results") ?? "{}");
|
fileResults = JSON.parse(
|
||||||
|
decodeURIComponent(response.headers.get("X-File-Results") ?? "%7B%7D"),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Malformed header - fall back to empty mapping, all entries marked failed
|
// Malformed header - fall back to empty mapping, all entries marked failed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,7 +384,9 @@ export function useToolProcessor(toolId: string) {
|
|||||||
const entries = useFileStore.getState().entries;
|
const entries = useFileStore.getState().entries;
|
||||||
let fileResults: Record<string, string> = {};
|
let fileResults: Record<string, string> = {};
|
||||||
try {
|
try {
|
||||||
fileResults = JSON.parse(response.headers.get("X-File-Results") ?? "{}");
|
fileResults = JSON.parse(
|
||||||
|
decodeURIComponent(response.headers.get("X-File-Results") ?? "%7B%7D"),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Malformed header - fall back to empty mapping, all entries marked failed
|
// 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.statusCode).toBe(200);
|
||||||
expect(res.headers["content-type"]).toBe("application/zip");
|
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);
|
expect(Object.keys(fileResults).length).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -649,7 +649,7 @@ describe("Batch edge cases — extended", () => {
|
|||||||
// Should succeed and deduplicate filenames in the ZIP
|
// Should succeed and deduplicate filenames in the ZIP
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.headers["content-type"]).toBe("application/zip");
|
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
|
// All three entries should have unique names
|
||||||
const names = Object.values(fileResults);
|
const names = Object.values(fileResults);
|
||||||
expect(new Set(names).size).toBe(3);
|
expect(new Set(names).size).toBe(3);
|
||||||
@@ -1524,7 +1524,7 @@ describe("Batch limits — boundary tests", () => {
|
|||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.headers["content-type"]).toBe("application/zip");
|
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);
|
expect(Object.keys(fileResults).length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1546,7 +1546,7 @@ describe("Batch limits — boundary tests", () => {
|
|||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.headers["content-type"]).toBe("application/zip");
|
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);
|
expect(Object.keys(fileResults).length).toBe(10);
|
||||||
}, 120_000);
|
}, 120_000);
|
||||||
|
|
||||||
|
|||||||
@@ -1035,7 +1035,7 @@ describe("Batch -- all identical files", () => {
|
|||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.headers["content-type"]).toBe("application/zip");
|
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);
|
expect(Object.keys(fileResults).length).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1050,7 +1050,7 @@ describe("Batch -- all identical files", () => {
|
|||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.headers["content-type"]).toBe("application/zip");
|
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);
|
const names = Object.values(fileResults);
|
||||||
// All three entries should have unique names in the ZIP
|
// All three entries should have unique names in the ZIP
|
||||||
expect(new Set(names).size).toBe(3);
|
expect(new Set(names).size).toBe(3);
|
||||||
|
|||||||
@@ -611,7 +611,9 @@ describe("Concurrent requests -- data integrity verification", () => {
|
|||||||
// Batch request must succeed
|
// Batch request must succeed
|
||||||
expect(batchRes.statusCode).toBe(200);
|
expect(batchRes.statusCode).toBe(200);
|
||||||
expect(batchRes.headers["content-type"]).toBe("application/zip");
|
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);
|
expect(Object.keys(fileResults).length).toBe(3);
|
||||||
|
|
||||||
// The single request's job ID must not appear in the batch results
|
// The single request's job ID must not appear in the batch results
|
||||||
|
|||||||
@@ -3102,7 +3102,7 @@ describe("Batch processing", () => {
|
|||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.headers["x-file-results"]).toBeDefined();
|
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["0"]).toBeDefined();
|
||||||
expect(parsed["1"]).toBeDefined();
|
expect(parsed["1"]).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -3189,7 +3189,7 @@ describe("Batch processing", () => {
|
|||||||
|
|
||||||
const fileResults = res.headers["x-file-results"];
|
const fileResults = res.headers["x-file-results"];
|
||||||
expect(fileResults).toBeDefined();
|
expect(fileResults).toBeDefined();
|
||||||
const parsed = JSON.parse(fileResults as string);
|
const parsed = JSON.parse(decodeURIComponent(fileResults as string));
|
||||||
expect(parsed["0"]).toBeDefined();
|
expect(parsed["0"]).toBeDefined();
|
||||||
expect(parsed["1"]).toBeDefined();
|
expect(parsed["1"]).toBeDefined();
|
||||||
expect(typeof parsed["0"]).toBe("string");
|
expect(typeof parsed["0"]).toBe("string");
|
||||||
@@ -3215,7 +3215,7 @@ describe("Batch processing", () => {
|
|||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(200);
|
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"]];
|
const names = [fileResults["0"], fileResults["1"], fileResults["2"]];
|
||||||
expect(names[0]).toContain("aaa");
|
expect(names[0]).toContain("aaa");
|
||||||
expect(names[1]).toContain("bbb");
|
expect(names[1]).toContain("bbb");
|
||||||
|
|||||||
@@ -130,12 +130,90 @@ describe("X-File-Results header", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
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();
|
expect(fileResults).toBeDefined();
|
||||||
// Should have entries for index 0 and 1
|
// Should have entries for index 0 and 1
|
||||||
expect(fileResults["0"]).toBeDefined();
|
expect(fileResults["0"]).toBeDefined();
|
||||||
expect(fileResults["1"]).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 ─────────────────────────────────────
|
// ── ClientJobId passthrough ─────────────────────────────────────
|
||||||
@@ -363,7 +441,7 @@ describe("Exotic format batch processing", () => {
|
|||||||
it(`processes ${ext.toUpperCase()} through batch compress`, async () => {
|
it(`processes ${ext.toUpperCase()} through batch compress`, async () => {
|
||||||
const res = await batchCompress(ext, mime);
|
const res = await batchCompress(ext, mime);
|
||||||
expect(res.statusCode).toBe(200);
|
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["0"]).toBeDefined();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -408,7 +486,7 @@ describe("Exotic format batch processing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
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["0"]).toBeDefined();
|
||||||
expect(fileResults["1"]).toBeDefined();
|
expect(fileResults["1"]).toBeDefined();
|
||||||
expect(fileResults["2"]).toBeDefined();
|
expect(fileResults["2"]).toBeDefined();
|
||||||
@@ -439,7 +517,7 @@ describe("Batch preserves upload order", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
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
|
// Index 0 should be derived from alpha, 1 from beta, 2 from gamma
|
||||||
expect(fileResults["0"]).toContain("alpha");
|
expect(fileResults["0"]).toContain("alpha");
|
||||||
|
|||||||
@@ -426,6 +426,33 @@ describe("Pipeline batch execution", () => {
|
|||||||
expect(res.headers["x-file-results"]).toBeDefined();
|
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 () => {
|
it("rejects pipeline batch with no files", async () => {
|
||||||
const { body, contentType } = createMultipartPayload([
|
const { body, contentType } = createMultipartPayload([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -854,7 +854,7 @@ describe("svg-to-raster", () => {
|
|||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.headers["content-type"]).toBe("application/zip");
|
expect(res.headers["content-type"]).toBe("application/zip");
|
||||||
// X-File-Results should contain deduplicated names
|
// 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[];
|
const filenames = Object.values(fileResults) as string[];
|
||||||
// Filenames should be unique
|
// Filenames should be unique
|
||||||
expect(new Set(filenames).size).toBe(filenames.length);
|
expect(new Set(filenames).size).toBe(filenames.length);
|
||||||
@@ -1036,7 +1036,7 @@ describe("svg-to-raster", () => {
|
|||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.headers["content-type"]).toBe("application/zip");
|
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);
|
expect(Object.keys(fileResults).length).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user