|
@@ -118,6 +118,57 @@ function resolvePdfPathOrThrow({ root, branch, year, month, day, filename }) {
|
|
|
return absPath;
|
|
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
|
|
* GET /api/files/:branch/:year/:month/:day/:filename
|
|
|
*
|
|
*
|
|
@@ -183,8 +234,7 @@ export const GET = withErrorHandling(
|
|
|
const download = (searchParams.get("download") || "").toLowerCase();
|
|
const download = (searchParams.get("download") || "").toLowerCase();
|
|
|
const asAttachment = download === "1" || download === "true";
|
|
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 nodeStream = fs.createReadStream(absPath);
|
|
|
const webStream = Readable.toWeb(nodeStream);
|
|
const webStream = Readable.toWeb(nodeStream);
|