feat(downloads): preserve original camera filenames on download (opt-in) (#493)

New Settings → General toggle `Use original filenames on download` (off by
default). When on, single-photo downloads, bulk/selection zips, and per-event
archive zips surface `photos.original_filename` instead of the sanitized
storage filename. Storage paths are unchanged.

- Content-Disposition uses RFC 5987 (`filename=` ASCII + `filename*=UTF-8''…`)
  so unicode camera filenames survive while header-injection bytes are stripped.
- Zip entries are deduplicated with a deterministic `_1` / `_2` suffix on
  collision (folder structure preserved in archive zips).
- Pre-generated download-all zips and the in-memory setting cache are
  invalidated when the toggle flips so the next download rebuilds with the
  new names.
- Falls back to the storage filename whenever `original_filename` is null
  (legacy uploads predating migration 062).
This commit is contained in:
Paul Nothaft
2026-05-14 23:11:00 +02:00
parent 61f1d13210
commit 7eeef2ba98
13 changed files with 460 additions and 19 deletions
+121 -1
View File
@@ -1,3 +1,5 @@
const path = require('path');
/**
* Sanitize a string to be used as a filename component
* @param {string} str - The string to sanitize
@@ -51,7 +53,125 @@ function generatePhotoFilename(eventName, categoryName, counter, extension) {
return `${sanitizedEvent}_${sanitizedCategory}_${paddedCounter}${extension}`;
}
/**
* Strip characters that are unsafe inside a Content-Disposition `filename="..."`
* token: CR/LF/NUL (header injection), backslashes, double-quotes, and other
* control bytes. Returns an ASCII-only fallback name (non-ASCII bytes are
* dropped — pair with `buildContentDisposition()` which also emits a
* RFC 5987 `filename*=UTF-8''…` parameter so modern clients see unicode).
*
* Path separators are stripped so an `original_filename` like `../../etc/passwd`
* can never be coaxed into a directory write on a client that honours paths.
*/
function sanitizeForContentDisposition(name) {
if (!name) return 'download';
let sanitized = String(name)
// Header-breaking bytes
.replace(/[\r\n\0]/g, '')
// Other ASCII control characters (0x010x1F, 0x7F)
// eslint-disable-next-line no-control-regex
.replace(/[\x01-\x1F\x7F]/g, '')
// Path separators and quote chars that would close the quoted-string
.replace(/[/\\"]/g, '_')
.trim();
// Strip any non-ASCII for the legacy `filename=` token. The `filename*=`
// parameter carries the unicode form.
// eslint-disable-next-line no-control-regex
sanitized = sanitized.replace(/[^\x20-\x7E]/g, '_');
// Collapse runs of underscores introduced by replacement.
sanitized = sanitized.replace(/_{2,}/g, '_').replace(/^[_.]+|_+$/g, '');
return sanitized || 'download';
}
/**
* Build a full `Content-Disposition` header value with both an ASCII
* fallback (`filename="…"`) and an RFC 5987 unicode form
* (`filename*=UTF-8''…`). This is what RFC 6266 §4 recommends for any
* filename that may contain non-ASCII bytes (which `photos.original_filename`
* can, since it's the raw `multer.file.originalname`).
*/
function buildContentDisposition(name, disposition = 'attachment') {
const safeName = name ? String(name) : 'download';
const asciiFallback = sanitizeForContentDisposition(safeName);
// RFC 5987: percent-encode every byte that isn't an attr-char. encodeURIComponent
// is a superset of attr-char (it encodes `*'%` etc.) — close enough and
// browser-compatible.
const encoded = encodeURIComponent(safeName).replace(/['()]/g, escape);
return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
}
/**
* Sanitize a string for use as a zip-entry name. Preserves spaces,
* parentheses, and unicode (modern zip readers handle UTF-8 entry names),
* but strips path-traversal sequences and platform-reserved characters so
* extracting the zip can never escape its target directory.
*/
function sanitizeForZipEntry(name) {
if (!name) return 'download';
let sanitized = String(name)
// Header-breaking bytes (shouldn't appear in zip but cheap defence)
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1F\x7F]/g, '')
// Normalise path separators to underscore so `evil/../passwd` becomes
// `evil_.._passwd` instead of an actual subpath.
.replace(/[/\\]/g, '_')
// Strip leading dots so `..` can't become an upward reference.
.replace(/^\.+/, '')
.trim();
return sanitized || 'download';
}
/**
* Deterministically rename duplicate names by appending `_1`, `_2`, … before
* the extension. Input order is preserved; the first occurrence keeps its
* original name. Used when a bulk-download zip is built with original camera
* filenames and two photos in the same event happen to share one (e.g. same
* camera body across two shoot days).
*
* @param {string[]} names
* @returns {string[]} new array of the same length, with collisions resolved
*/
function uniquifyZipNames(names) {
const seen = new Map();
const out = new Array(names.length);
for (let i = 0; i < names.length; i += 1) {
const original = names[i] || 'download';
if (!seen.has(original)) {
seen.set(original, 0);
out[i] = original;
continue;
}
// Find the next free `_N` suffix. We bump the stored counter so the
// next collision picks the *next* number instead of starting from 1 again.
let n = seen.get(original) + 1;
const ext = path.extname(original);
const stem = ext ? original.slice(0, -ext.length) : original;
let candidate;
do {
candidate = `${stem}_${n}${ext}`;
n += 1;
} while (seen.has(candidate));
seen.set(original, n - 1);
seen.set(candidate, 0);
out[i] = candidate;
}
return out;
}
module.exports = {
sanitizeFilename,
generatePhotoFilename
generatePhotoFilename,
sanitizeForContentDisposition,
buildContentDisposition,
sanitizeForZipEntry,
uniquifyZipNames,
};