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:
@@ -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 (0x01–0x1F, 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,
|
||||
};
|
||||
Reference in New Issue
Block a user