diff --git a/backend/__tests__/services/downloadFilenameService.test.js b/backend/__tests__/services/downloadFilenameService.test.js new file mode 100644 index 00000000..bfbda6f4 --- /dev/null +++ b/backend/__tests__/services/downloadFilenameService.test.js @@ -0,0 +1,76 @@ +/** + * Pure-logic tests for the #493 download-filename helpers that don't depend + * on the DB (those are covered by the route integration suite). + */ + +const { + pickRawDownloadName, + getZipEntryNames, +} = require('../../src/services/downloadFilenameService'); + +describe('pickRawDownloadName', () => { + it('returns the storage filename when the toggle is off', () => { + expect( + pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' }, false) + ).toBe('slug_001.jpg'); + }); + + it('returns original_filename when the toggle is on', () => { + expect( + pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' }, true) + ).toBe('DSC_1.jpg'); + }); + + it('falls back to storage filename when original_filename is missing', () => { + expect( + pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: null }, true) + ).toBe('slug_001.jpg'); + }); + + it('produces a stable last-resort name when both are missing', () => { + expect(pickRawDownloadName({ id: 42 }, true)).toBe('photo-42.jpg'); + }); +}); + +describe('getZipEntryNames', () => { + it('uses original filenames with deterministic suffixes on collision', () => { + const photos = [ + { id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1234.jpg' }, + { id: 2, filename: 'slug_002.jpg', original_filename: 'DSC_1234.jpg' }, + { id: 3, filename: 'slug_003.jpg', original_filename: 'DSC_1235.jpg' }, + ]; + expect(getZipEntryNames(photos, true)).toEqual([ + 'DSC_1234.jpg', + 'DSC_1234_1.jpg', + 'DSC_1235.jpg', + ]); + }); + + it('falls back to storage filename per-photo when original is missing', () => { + const photos = [ + { id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' }, + { id: 2, filename: 'slug_002.jpg', original_filename: null }, + ]; + expect(getZipEntryNames(photos, true)).toEqual([ + 'DSC_1.jpg', + 'slug_002.jpg', + ]); + }); + + it('returns storage filenames when the toggle is off, dedup still applies', () => { + const photos = [ + { id: 1, filename: 'a.jpg', original_filename: 'DSC_1.jpg' }, + { id: 2, filename: 'a.jpg', original_filename: 'DSC_2.jpg' }, + ]; + expect(getZipEntryNames(photos, false)).toEqual(['a.jpg', 'a_1.jpg']); + }); + + it('sanitizes path-traversal attempts that sneak into original_filename', () => { + const photos = [ + { id: 1, filename: 'slug_001.jpg', original_filename: '../etc/passwd' }, + ]; + const [name] = getZipEntryNames(photos, true); + expect(name).not.toContain('..'); + expect(name).not.toContain('/'); + }); +}); diff --git a/backend/__tests__/utils/filenameSanitizer.test.js b/backend/__tests__/utils/filenameSanitizer.test.js new file mode 100644 index 00000000..e3093e01 Binary files /dev/null and b/backend/__tests__/utils/filenameSanitizer.test.js differ diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 3d665dfc..4f48016a 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -7,7 +7,11 @@ const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const { ensureThumbnail } = require('../services/imageProcessor'); const { isVideoMimeType } = require('../services/videoProcessor'); -const { generatePhotoFilename } = require('../utils/filenameSanitizer'); +const { generatePhotoFilename, buildContentDisposition } = require('../utils/filenameSanitizer'); +const { + getUseOriginalFilenames, + pickRawDownloadName, +} = require('../services/downloadFilenameService'); const { escapeLikePattern } = require('../utils/sqlSecurity'); const { validateUploadedFiles } = require('../middleware/uploadValidation'); const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings'); @@ -911,6 +915,11 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p const storage = getStorage(); const storageKey = resolvePhotoStorageKey(event, photo); + // #493: respect the original-filenames toggle for admin downloads too. + const useOriginal = await getUseOriginalFilenames(); + const downloadName = pickRawDownloadName(photo, useOriginal); + const contentDisposition = buildContentDisposition(downloadName); + if (storageKey) { const stat = await storage.stat(storageKey); if (!stat) { @@ -919,7 +928,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p res.set({ 'Content-Type': photo.mime_type || 'application/octet-stream', 'Content-Length': stat.size, - 'Content-Disposition': `attachment; filename="${photo.filename}"`, + 'Content-Disposition': contentDisposition, }); const stream = await storage.get(storageKey); stream.pipe(res); @@ -933,7 +942,11 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p } catch (error) { return res.status(404).json({ error: 'Photo file not found' }); } - res.download(filePath, photo.filename); + res.set({ + 'Content-Type': photo.mime_type || 'application/octet-stream', + 'Content-Disposition': contentDisposition, + }); + res.sendFile(filePath); } catch (error) { console.error('Error downloading photo:', error); res.status(500).json({ error: 'Failed to download photo' }); diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index bc9dc5ec..8d20e1e7 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -799,6 +799,19 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) { clearShareLinkSettingsCache(); } + // Toggling the original-filenames setting (#493) requires busting the + // per-event pre-generated zips so the next download-all rebuilds with the + // new entry names. Single-photo downloads pick up the change as soon as + // the in-memory cache TTL in downloadFilenameService expires (cleared + // here for immediacy). + if (Object.prototype.hasOwnProperty.call(settings, 'general_use_original_filenames_for_downloads')) { + try { + require('../services/downloadFilenameService').clearCache(); + require('../services/downloadZipService').invalidateAll(); + } catch (e) { + console.warn('Failed to invalidate download caches after filename setting change:', e.message); + } + } // Log activity await db('activity_logs').insert({ diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 6cb83802..d954c2c3 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -15,6 +15,12 @@ const { handleAsync } = require('../utils/routeHelpers'); const { NotFoundError } = require('../utils/errors'); const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor'); const downloadZipService = require('../services/downloadZipService'); +const { + getUseOriginalFilenames, + pickRawDownloadName, + getZipEntryNames, +} = require('../services/downloadFilenameService'); +const { buildContentDisposition } = require('../utils/filenameSanitizer'); const { getStorage } = require('../services/storage'); const fs = require('fs'); @@ -632,6 +638,13 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1; const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled; + // #493: if the admin enabled "use original filenames", surface the + // pre-rename camera filename in Content-Disposition. Storage path is + // unchanged — only the user-visible download name is swapped. + const useOriginal = await getUseOriginalFilenames(); + const downloadName = pickRawDownloadName(photo, useOriginal); + const contentDisposition = buildContentDisposition(downloadName); + if (shouldApplyWatermark) { // Apply watermark and send // Use event watermark text if available, otherwise fall back to global settings @@ -644,14 +657,21 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => res.set({ 'Content-Type': photo.mime_type || 'image/jpeg', - 'Content-Disposition': `attachment; filename="${photo.filename}"`, + 'Content-Disposition': contentDisposition, 'Content-Length': watermarkedBuffer.length }); res.send(watermarkedBuffer); } else { - // Send original file - res.download(filePath, photo.filename, (downloadError) => { + // res.download() builds Content-Disposition itself but doesn't emit the + // RFC 5987 filename* parameter, so unicode camera filenames would lose + // their bytes on download. Set the header explicitly and stream the + // file with res.sendFile-equivalent semantics. + res.set({ + 'Content-Type': photo.mime_type || 'image/jpeg', + 'Content-Disposition': contentDisposition, + }); + res.sendFile(filePath, (downloadError) => { if (downloadError) { logger.error('Error streaming gallery download', { slug: req.params.slug, @@ -773,14 +793,20 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => { // Add photos to archive — managed photos via storage backend, external via local path. const { resolvePhotoStorageKey } = require('../services/photoResolver'); const storage = getStorage(); - for (const photo of photos) { + // #493: resolve a unique display filename per photo up-front so collisions + // get a deterministic `_1` suffix before the entries hit the archive. + const useOriginalBulk = await getUseOriginalFilenames(); + const bulkEntryNames = getZipEntryNames(photos, useOriginalBulk); + for (let i = 0; i < photos.length; i += 1) { + const photo = photos[i]; const storageKey = resolvePhotoStorageKey(req.event, photo); + const entryName = bulkEntryNames[i]; let archiveName; if (hasMultipleTypes) { const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages'; - archiveName = path.join(folderName, photo.filename); + archiveName = path.join(folderName, entryName); } else { - archiveName = photo.filename; + archiveName = entryName; } try { @@ -901,8 +927,12 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) => const { resolvePhotoStorageKey: resolveSelectedKey } = require('../services/photoResolver'); const { withLocalCopy: withSelectedLocalCopy } = require('../services/imageProcessor'); const selectedStorage = getStorage(); - for (const photo of photos) { - const name = photo.filename || `photo-${photo.id}.jpg`; + // #493: same display-name resolution as bulk download, with dedup. + const useOriginalSelected = await getUseOriginalFilenames(); + const selectedEntryNames = getZipEntryNames(photos, useOriginalSelected); + for (let i = 0; i < photos.length; i += 1) { + const photo = photos[i]; + const name = selectedEntryNames[i] || `photo-${photo.id}.jpg`; const storageKey = resolveSelectedKey(req.event, photo); try { if (shouldApplyWatermark && effectiveSettings) { diff --git a/backend/src/services/archiveService.js b/backend/src/services/archiveService.js index c90e9b02..9c3ef978 100644 --- a/backend/src/services/archiveService.js +++ b/backend/src/services/archiveService.js @@ -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 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 }); } diff --git a/backend/src/services/downloadFilenameService.js b/backend/src/services/downloadFilenameService.js new file mode 100644 index 00000000..b3952f71 --- /dev/null +++ b/backend/src/services/downloadFilenameService.js @@ -0,0 +1,117 @@ +/** + * Download filename resolution for the + * `general_use_original_filenames_for_downloads` setting (#493). + * + * Two responsibilities: + * - Cache the boolean setting so per-download reads don't hit the DB. + * - Map photos → display filenames (sanitized + dedup'd for zip entries). + * + * Storage paths are NOT touched: callers still locate files via + * `resolvePhotoStorageKey` / `resolvePhotoFilePath`. Only the user-visible + * download/zip-entry name changes when the setting is on. + */ + +const { db } = require('../database/db'); +const logger = require('../utils/logger'); +const { + sanitizeForContentDisposition, + sanitizeForZipEntry, + uniquifyZipNames, +} = require('../utils/filenameSanitizer'); + +const SETTING_KEY = 'general_use_original_filenames_for_downloads'; +const CACHE_TTL_MS = 60_000; + +let cached = null; // boolean | null +let cachedAt = 0; + +function clearCache() { + cached = null; + cachedAt = 0; +} + +/** + * Read the toggle. Cached for CACHE_TTL_MS to keep per-download reads cheap. + * Falls back to `false` (current behaviour) on any error. + */ +async function getUseOriginalFilenames() { + const now = Date.now(); + if (cached !== null && now - cachedAt < CACHE_TTL_MS) { + return cached; + } + + try { + const row = await db('app_settings') + .where('setting_key', SETTING_KEY) + .first(); + + let value = false; + if (row && row.setting_value !== null && row.setting_value !== undefined) { + const raw = row.setting_value; + if (typeof raw === 'boolean') { + value = raw; + } else if (typeof raw === 'string') { + // setting_value is JSON-stringified on write (see adminSettings PUT /general). + try { + value = JSON.parse(raw) === true; + } catch { + value = raw === 'true'; + } + } else { + value = Boolean(raw); + } + } + + cached = value; + cachedAt = now; + return value; + } catch (err) { + logger.warn('downloadFilenameService.getUseOriginalFilenames error', { error: err.message }); + return cached === null ? false : cached; + } +} + +/** + * Pick the raw (unsanitised) filename to use for a single photo, given the + * toggle state. Falls back to `photo.filename` whenever the original is missing + * (legacy uploads before migration 062, or external-mode rows where it was + * never populated). + */ +function pickRawDownloadName(photo, useOriginal) { + if (useOriginal && photo && photo.original_filename) { + return photo.original_filename; + } + return (photo && photo.filename) || `photo-${photo && photo.id}.jpg`; +} + +/** + * Header-safe filename for `Content-Disposition`. Pair with + * `buildContentDisposition()` from filenameSanitizer when the caller wants + * RFC 5987 unicode support; this helper returns only the ASCII fallback for + * routes that already construct the header by hand. + */ +function getDownloadFilenameForHeader(photo, useOriginal) { + return sanitizeForContentDisposition(pickRawDownloadName(photo, useOriginal)); +} + +/** + * Build a list of unique, zip-safe entry names for an ordered list of photos. + * + * @param {Array} photos – photos in zip order + * @param {boolean} useOriginal – toggle state + * @returns {string[]} – same length as `photos`, with `_1` / `_2` suffixes on + * any duplicates (deterministic across runs because order is preserved) + */ +function getZipEntryNames(photos, useOriginal) { + const raw = photos.map((p) => sanitizeForZipEntry(pickRawDownloadName(p, useOriginal))); + return uniquifyZipNames(raw); +} + +module.exports = { + SETTING_KEY, + clearCache, + getUseOriginalFilenames, + pickRawDownloadName, + getDownloadFilenameForHeader, + getZipEntryNames, +}; diff --git a/backend/src/services/downloadZipService.js b/backend/src/services/downloadZipService.js index f4b704ab..c8891023 100644 --- a/backend/src/services/downloadZipService.js +++ b/backend/src/services/downloadZipService.js @@ -23,6 +23,7 @@ const { db } = require('../database/db'); const watermarkService = require('./watermarkService'); const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver'); const { getStorage } = require('./storage'); +const { getUseOriginalFilenames, getZipEntryNames } = require('./downloadFilenameService'); const logger = require('../utils/logger'); const DEBOUNCE_MS = 5000; @@ -134,6 +135,11 @@ class DownloadZipService { tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-zipbuild-')); const tmpPath = path.join(tmpDir, `${crypto.randomBytes(4).toString('hex')}-all.zip`); + // #493: resolve display filenames (with collision suffix) before the + // streaming starts so the loop just indexes the precomputed array. + const useOriginal = await getUseOriginalFilenames(); + const entryNames = getZipEntryNames(photos, useOriginal); + // Build zip — level 0 (store only) since photos are already compressed await new Promise((resolve, reject) => { const output = fs.createWriteStream(tmpPath); @@ -147,19 +153,21 @@ class DownloadZipService { const hasMultipleTypes = uniqueTypes > 1; const addPhotos = async () => { - for (const photo of photos) { + for (let i = 0; i < photos.length; i += 1) { + const photo = photos[i]; // Check if build was invalidated if (this.versions.get(eventId) !== version) { archive.abort(); return reject(new Error('Build invalidated')); } + const entryName = entryNames[i]; let archiveName; if (hasMultipleTypes) { const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages'; - archiveName = path.join(folderName, photo.filename); + archiveName = path.join(folderName, entryName); } else { - archiveName = photo.filename; + archiveName = entryName; } // External-mode photos still live on local disk; managed photos go diff --git a/backend/src/utils/filenameSanitizer.js b/backend/src/utils/filenameSanitizer.js index f93232a3..3074e40e 100644 --- a/backend/src/utils/filenameSanitizer.js +++ b/backend/src/utils/filenameSanitizer.js @@ -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, }; \ No newline at end of file diff --git a/frontend/src/features/settings/hooks/useSettingsState.ts b/frontend/src/features/settings/hooks/useSettingsState.ts index 911d36cc..a1ad55c7 100644 --- a/frontend/src/features/settings/hooks/useSettingsState.ts +++ b/frontend/src/features/settings/hooks/useSettingsState.ts @@ -20,6 +20,7 @@ export interface GeneralSettings { enable_registration: boolean; maintenance_mode: boolean; short_gallery_urls: boolean; + use_original_filenames_for_downloads: boolean; default_language: string; date_format: { format: string; locale: string }; } @@ -94,6 +95,7 @@ export function useSettingsState() { enable_registration: false, maintenance_mode: false, short_gallery_urls: false, + use_original_filenames_for_downloads: false, default_language: 'en', date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' } }); @@ -179,6 +181,10 @@ export function useSettingsState() { enable_registration: toBoolean(settings.general_enable_registration, false), maintenance_mode: toBoolean(settings.general_maintenance_mode, false), short_gallery_urls: toBoolean(settings.general_short_gallery_urls, false), + use_original_filenames_for_downloads: toBoolean( + settings.general_use_original_filenames_for_downloads, + false + ), default_language: settings.general_default_language || 'en', date_format: settings.general_date_format ? (typeof settings.general_date_format === 'string' diff --git a/frontend/src/features/settings/tabs/GeneralTab.tsx b/frontend/src/features/settings/tabs/GeneralTab.tsx index d5e6b4d3..8c7bd491 100644 --- a/frontend/src/features/settings/tabs/GeneralTab.tsx +++ b/frontend/src/features/settings/tabs/GeneralTab.tsx @@ -229,6 +229,21 @@ export const GeneralTab: React.FC = ({ {t('settings.general.enableShortGalleryUrlsHelp')}

+ +
+ +

+ {t('settings.general.useOriginalFilenamesHelp')} +

+
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 9300607f..63925399 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1038,6 +1038,8 @@ "enableRegistration": "Selbstregistrierung für Admins erlauben", "enableShortGalleryUrls": "Kurze Galerie-Links verwenden", "enableShortGalleryUrlsHelp": "Entfernt den Veranstaltungs-Slug aus neuen Freigabelinks und lässt bestehende Links weiterhin funktionieren.", + "useOriginalFilenames": "Originale Dateinamen beim Download verwenden", + "useOriginalFilenamesHelp": "Wenn aktiviert, verwenden Einzel- und ZIP-Downloads den Original-Dateinamen der Kamera (z. B. DSC_1234.jpg) statt des umbenannten Namens. Der Speicher bleibt unverändert; Duplikate innerhalb einer Veranstaltung erhalten ein numerisches Suffix.", "maintenanceMode": "Wartungsmodus aktivieren", "language": "Sprache", "defaultLanguageHelp": "Sprache, die Gästen vor der Anmeldung angezeigt wird", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 16ee3bf0..e5948b34 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -677,6 +677,8 @@ "enableRegistration": "Allow self-registration for admins", "enableShortGalleryUrls": "Use short gallery URLs", "enableShortGalleryUrlsHelp": "Removes the event slug from new share links while keeping existing links working.", + "useOriginalFilenames": "Use original filenames on download", + "useOriginalFilenamesHelp": "When on, single-photo and ZIP downloads use the original camera filename (e.g. DSC_1234.jpg) instead of the sanitized name. Storage is unchanged; duplicates in the same event get a numeric suffix.", "maintenanceMode": "Enable maintenance mode", "language": "Language", "defaultLanguageHelp": "Language shown to guests before login",