Pārlūkot izejas kodu

RHL-015 feat(api): enhance Content-Disposition handling for PDF downloads with Unicode support

Code_Uwe 4 nedēļas atpakaļ
vecāks
revīzija
64139c1417

+ 52 - 2
app/api/files/[branch]/[year]/[month]/[day]/[filename]/route.js

@@ -118,6 +118,57 @@ function resolvePdfPathOrThrow({ root, branch, year, month, day, filename }) {
 	return absPath;
 }
 
+/**
+ * Content-Disposition helper (Unicode-safe).
+ *
+ * Problem:
+ * - Node's Web Response headers require ByteString-compatible values.
+ * - Unicode characters (e.g. "€") in `filename="..."` can crash the response creation.
+ *
+ * Solution:
+ * - Provide an ASCII fallback via `filename="..."`.
+ * - Provide the real UTF-8 name via RFC 5987: `filename*=UTF-8''...`.
+ */
+function stripDiacritics(input) {
+	return String(input)
+		.normalize("NFKD")
+		.replace(/[\u0300-\u036f]/g, "");
+}
+
+function toAsciiFallbackFilename(filename) {
+	// Keep it predictable and safe for headers: ASCII only.
+	// We also keep the .pdf extension if possible.
+	const raw = stripDiacritics(filename);
+
+	const ascii = raw
+		.replace(/[^\x20-\x7E]/g, "_") // replace non-ASCII with underscore
+		.replace(/\s+/g, " ") // collapse whitespace
+		.replace(/_+/g, "_") // collapse underscores
+		.trim();
+
+	if (!ascii) return "download.pdf";
+	if (!ascii.toLowerCase().endsWith(".pdf")) return `${ascii}.pdf`;
+
+	return ascii;
+}
+
+function encodeRFC5987ValueChars(str) {
+	// RFC 5987 encoding for header parameters:
+	// Use percent-encoded UTF-8 bytes and additionally encode a few chars that
+	// encodeURIComponent leaves as-is but can be problematic in headers.
+	return encodeURIComponent(str)
+		.replace(/['()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
+		.replace(/\*/g, "%2A");
+}
+
+function buildContentDisposition(filename, asAttachment) {
+	const type = asAttachment ? "attachment" : "inline";
+	const fallback = toAsciiFallbackFilename(filename);
+	const encoded = encodeRFC5987ValueChars(filename);
+
+	return `${type}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
+}
+
 /**
  * GET /api/files/:branch/:year/:month/:day/:filename
  *
@@ -183,8 +234,7 @@ export const GET = withErrorHandling(
 		const download = (searchParams.get("download") || "").toLowerCase();
 		const asAttachment = download === "1" || download === "true";
 
-		const dispositionType = asAttachment ? "attachment" : "inline";
-		const contentDisposition = `${dispositionType}; filename="${filename}"`;
+		const contentDisposition = buildContentDisposition(filename, asAttachment);
 
 		const nodeStream = fs.createReadStream(absPath);
 		const webStream = Readable.toWeb(nodeStream);

+ 45 - 5
app/api/files/[branch]/[year]/[month]/[day]/[filename]/route.test.js

@@ -216,9 +216,11 @@ describe("GET /api/files/[branch]/[year]/[month]/[day]/[filename]", () => {
 		expect(res.status).toBe(200);
 		expect(res.headers.get("Content-Type")).toBe("application/pdf");
 		expect(res.headers.get("Cache-Control")).toBe("no-store");
-		expect(res.headers.get("Content-Disposition")).toBe(
-			'inline; filename="test.pdf"'
-		);
+
+		const cd = res.headers.get("Content-Disposition") || "";
+		expect(cd).toContain("inline;");
+		expect(cd).toContain('filename="test.pdf"');
+		expect(cd).toContain("filename*=UTF-8''test.pdf");
 
 		const buf = await res.arrayBuffer();
 		expect(Buffer.from(buf).toString("utf8")).toBe("dummy-pdf-content");
@@ -239,8 +241,46 @@ describe("GET /api/files/[branch]/[year]/[month]/[day]/[filename]", () => {
 		);
 
 		expect(res.status).toBe(200);
-		expect(res.headers.get("Content-Disposition")).toBe(
-			'attachment; filename="test.pdf"'
+
+		const cd = res.headers.get("Content-Disposition") || "";
+		expect(cd).toContain("attachment;");
+		expect(cd).toContain('filename="test.pdf"');
+		expect(cd).toContain("filename*=UTF-8''test.pdf");
+	});
+
+	it("supports unicode filenames in Content-Disposition (no ByteString crash)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const unicodeFilename = "Euro €.pdf";
+		const encoded = encodeURIComponent(unicodeFilename);
+
+		const dir = path.join(tmpRoot, "NL01", "2024", "10", "23");
+		await fs.writeFile(path.join(dir, unicodeFilename), "dummy-euro");
+
+		const res = await GET(
+			new Request(`http://localhost/api/files/NL01/2024/10/23/${encoded}`),
+			{
+				params: Promise.resolve({
+					...paramsOk,
+					filename: unicodeFilename,
+				}),
+			}
 		);
+
+		expect(res.status).toBe(200);
+
+		const cd = res.headers.get("Content-Disposition") || "";
+		expect(cd).toContain("inline;");
+		// ASCII fallback replaces the Euro sign with underscore.
+		expect(cd).toContain('filename="Euro _.pdf"');
+		// RFC 5987 preserves the real name via percent-encoded UTF-8.
+		expect(cd).toContain("filename*=UTF-8''Euro%20%E2%82%AC.pdf");
+
+		const buf = await res.arrayBuffer();
+		expect(Buffer.from(buf).toString("utf8")).toBe("dummy-euro");
 	});
 });