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
+43 -4
View File
@@ -9,6 +9,12 @@ const { queueEmail, getSupportEmail } = require('./emailProcessor');
const logger = require('../utils/logger');
const feedbackService = require('./feedbackService');
const { getStorage } = require('./storage');
const { resolvePhotoStorageKey } = require('./photoResolver');
const { getUseOriginalFilenames } = require('./downloadFilenameService');
const {
sanitizeForZipEntry,
uniquifyZipNames,
} = require('../utils/filenameSanitizer');
async function archiveEvent(event) {
const storage = getStorage();
@@ -54,6 +60,40 @@ async function archiveEvent(event) {
// the zip directly from the storage backend.
const photoEntries = await storage.list(eventPrefix);
// #493: optionally rename zip entries to use original camera filenames.
// Build a Map<storage_key, original_filename> from the photos table so we
// can swap the basename of each entry while keeping the folder structure
// (e.g. `individual/DSC_1234.jpg` instead of `individual/slug_001.jpg`).
const useOriginal = await getUseOriginalFilenames();
const originalsByKey = new Map();
if (useOriginal) {
const photoRows = await db('photos').where('event_id', event.id).select('*');
for (const photoRow of photoRows) {
if (!photoRow.original_filename) continue;
try {
const key = resolvePhotoStorageKey(event, photoRow);
if (key) originalsByKey.set(key, photoRow.original_filename);
} catch {
// External-mode rows have no managed key; skip silently.
}
}
}
// Compute (subfolder, displayName) up front so collisions across the
// whole zip can be resolved deterministically with `_N` suffixes.
const photoNames = photoEntries.map((entry) => {
const rel = entry.key.startsWith(`${eventPrefix}/`)
? entry.key.slice(eventPrefix.length + 1)
: entry.key;
if (!useOriginal) return rel;
const originalBase = originalsByKey.get(entry.key);
if (!originalBase) return rel;
const sep = rel.lastIndexOf('/');
const folder = sep >= 0 ? rel.slice(0, sep + 1) : '';
return `${folder}${sanitizeForZipEntry(originalBase)}`;
});
const dedupedNames = uniquifyZipNames(photoNames);
let totalBytes = 0;
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(tmpArchive);
@@ -67,10 +107,9 @@ async function archiveEvent(event) {
archive.pipe(output);
const append = async () => {
for (const entry of photoEntries) {
const nameInZip = entry.key.startsWith(`${eventPrefix}/`)
? entry.key.slice(eventPrefix.length + 1)
: entry.key;
for (let i = 0; i < photoEntries.length; i += 1) {
const entry = photoEntries[i];
const nameInZip = dedupedNames[i];
const stream = await storage.get(entry.key);
archive.append(stream, { name: nameInZip });
}