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 to support non-ASCII filenames
The X-File-Results header contained raw JSON with non-ASCII characters from filenames (Chinese, Japanese, etc.), violating RFC 7230. Node.js threw ERR_INVALID_CHAR on writeHead(). Fixed by wrapping the JSON in encodeURIComponent() on the backend and decodeURIComponent() on the frontend, ensuring only ASCII goes into the header while preserving the original filenames after decoding. Closes #133
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user