Merge pull request #498 from the-luap/feat/lightbox-preview-tier-492
feat(downloads): preserve original camera filenames on download (opt-in) (#493)
This commit is contained in:
@@ -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('/');
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
@@ -7,7 +7,11 @@ const { adminAuth } = require('../middleware/auth');
|
|||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { ensureThumbnail } = require('../services/imageProcessor');
|
const { ensureThumbnail } = require('../services/imageProcessor');
|
||||||
const { isVideoMimeType } = require('../services/videoProcessor');
|
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 { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||||
const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings');
|
const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings');
|
||||||
@@ -911,6 +915,11 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
|
|||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
const storageKey = resolvePhotoStorageKey(event, photo);
|
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) {
|
if (storageKey) {
|
||||||
const stat = await storage.stat(storageKey);
|
const stat = await storage.stat(storageKey);
|
||||||
if (!stat) {
|
if (!stat) {
|
||||||
@@ -919,7 +928,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
|
|||||||
res.set({
|
res.set({
|
||||||
'Content-Type': photo.mime_type || 'application/octet-stream',
|
'Content-Type': photo.mime_type || 'application/octet-stream',
|
||||||
'Content-Length': stat.size,
|
'Content-Length': stat.size,
|
||||||
'Content-Disposition': `attachment; filename="${photo.filename}"`,
|
'Content-Disposition': contentDisposition,
|
||||||
});
|
});
|
||||||
const stream = await storage.get(storageKey);
|
const stream = await storage.get(storageKey);
|
||||||
stream.pipe(res);
|
stream.pipe(res);
|
||||||
@@ -933,7 +942,11 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(404).json({ error: 'Photo file not found' });
|
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) {
|
} catch (error) {
|
||||||
console.error('Error downloading photo:', error);
|
console.error('Error downloading photo:', error);
|
||||||
res.status(500).json({ error: 'Failed to download photo' });
|
res.status(500).json({ error: 'Failed to download photo' });
|
||||||
|
|||||||
@@ -799,6 +799,19 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
|
|||||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
|
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
|
||||||
clearShareLinkSettingsCache();
|
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
|
// Log activity
|
||||||
await db('activity_logs').insert({
|
await db('activity_logs').insert({
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ const { handleAsync } = require('../utils/routeHelpers');
|
|||||||
const { NotFoundError } = require('../utils/errors');
|
const { NotFoundError } = require('../utils/errors');
|
||||||
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
|
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
|
||||||
const downloadZipService = require('../services/downloadZipService');
|
const downloadZipService = require('../services/downloadZipService');
|
||||||
|
const {
|
||||||
|
getUseOriginalFilenames,
|
||||||
|
pickRawDownloadName,
|
||||||
|
getZipEntryNames,
|
||||||
|
} = require('../services/downloadFilenameService');
|
||||||
|
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||||
const { getStorage } = require('../services/storage');
|
const { getStorage } = require('../services/storage');
|
||||||
const fs = require('fs');
|
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 eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
|
||||||
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
|
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) {
|
if (shouldApplyWatermark) {
|
||||||
// Apply watermark and send
|
// Apply watermark and send
|
||||||
// Use event watermark text if available, otherwise fall back to global settings
|
// 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({
|
res.set({
|
||||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||||
'Content-Disposition': `attachment; filename="${photo.filename}"`,
|
'Content-Disposition': contentDisposition,
|
||||||
'Content-Length': watermarkedBuffer.length
|
'Content-Length': watermarkedBuffer.length
|
||||||
});
|
});
|
||||||
|
|
||||||
res.send(watermarkedBuffer);
|
res.send(watermarkedBuffer);
|
||||||
} else {
|
} else {
|
||||||
// Send original file
|
// res.download() builds Content-Disposition itself but doesn't emit the
|
||||||
res.download(filePath, photo.filename, (downloadError) => {
|
// 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) {
|
if (downloadError) {
|
||||||
logger.error('Error streaming gallery download', {
|
logger.error('Error streaming gallery download', {
|
||||||
slug: req.params.slug,
|
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.
|
// Add photos to archive — managed photos via storage backend, external via local path.
|
||||||
const { resolvePhotoStorageKey } = require('../services/photoResolver');
|
const { resolvePhotoStorageKey } = require('../services/photoResolver');
|
||||||
const storage = getStorage();
|
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 storageKey = resolvePhotoStorageKey(req.event, photo);
|
||||||
|
const entryName = bulkEntryNames[i];
|
||||||
let archiveName;
|
let archiveName;
|
||||||
if (hasMultipleTypes) {
|
if (hasMultipleTypes) {
|
||||||
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
|
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
|
||||||
archiveName = path.join(folderName, photo.filename);
|
archiveName = path.join(folderName, entryName);
|
||||||
} else {
|
} else {
|
||||||
archiveName = photo.filename;
|
archiveName = entryName;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -901,8 +927,12 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
|
|||||||
const { resolvePhotoStorageKey: resolveSelectedKey } = require('../services/photoResolver');
|
const { resolvePhotoStorageKey: resolveSelectedKey } = require('../services/photoResolver');
|
||||||
const { withLocalCopy: withSelectedLocalCopy } = require('../services/imageProcessor');
|
const { withLocalCopy: withSelectedLocalCopy } = require('../services/imageProcessor');
|
||||||
const selectedStorage = getStorage();
|
const selectedStorage = getStorage();
|
||||||
for (const photo of photos) {
|
// #493: same display-name resolution as bulk download, with dedup.
|
||||||
const name = photo.filename || `photo-${photo.id}.jpg`;
|
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);
|
const storageKey = resolveSelectedKey(req.event, photo);
|
||||||
try {
|
try {
|
||||||
if (shouldApplyWatermark && effectiveSettings) {
|
if (shouldApplyWatermark && effectiveSettings) {
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ const { queueEmail, getSupportEmail } = require('./emailProcessor');
|
|||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const feedbackService = require('./feedbackService');
|
const feedbackService = require('./feedbackService');
|
||||||
const { getStorage } = require('./storage');
|
const { getStorage } = require('./storage');
|
||||||
|
const { resolvePhotoStorageKey } = require('./photoResolver');
|
||||||
|
const { getUseOriginalFilenames } = require('./downloadFilenameService');
|
||||||
|
const {
|
||||||
|
sanitizeForZipEntry,
|
||||||
|
uniquifyZipNames,
|
||||||
|
} = require('../utils/filenameSanitizer');
|
||||||
|
|
||||||
async function archiveEvent(event) {
|
async function archiveEvent(event) {
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
@@ -54,6 +60,40 @@ async function archiveEvent(event) {
|
|||||||
// the zip directly from the storage backend.
|
// the zip directly from the storage backend.
|
||||||
const photoEntries = await storage.list(eventPrefix);
|
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;
|
let totalBytes = 0;
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const output = fs.createWriteStream(tmpArchive);
|
const output = fs.createWriteStream(tmpArchive);
|
||||||
@@ -67,10 +107,9 @@ async function archiveEvent(event) {
|
|||||||
archive.pipe(output);
|
archive.pipe(output);
|
||||||
|
|
||||||
const append = async () => {
|
const append = async () => {
|
||||||
for (const entry of photoEntries) {
|
for (let i = 0; i < photoEntries.length; i += 1) {
|
||||||
const nameInZip = entry.key.startsWith(`${eventPrefix}/`)
|
const entry = photoEntries[i];
|
||||||
? entry.key.slice(eventPrefix.length + 1)
|
const nameInZip = dedupedNames[i];
|
||||||
: entry.key;
|
|
||||||
const stream = await storage.get(entry.key);
|
const stream = await storage.get(entry.key);
|
||||||
archive.append(stream, { name: nameInZip });
|
archive.append(stream, { name: nameInZip });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -23,6 +23,7 @@ const { db } = require('../database/db');
|
|||||||
const watermarkService = require('./watermarkService');
|
const watermarkService = require('./watermarkService');
|
||||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
||||||
const { getStorage } = require('./storage');
|
const { getStorage } = require('./storage');
|
||||||
|
const { getUseOriginalFilenames, getZipEntryNames } = require('./downloadFilenameService');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
const DEBOUNCE_MS = 5000;
|
const DEBOUNCE_MS = 5000;
|
||||||
@@ -134,6 +135,11 @@ class DownloadZipService {
|
|||||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-zipbuild-'));
|
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-zipbuild-'));
|
||||||
const tmpPath = path.join(tmpDir, `${crypto.randomBytes(4).toString('hex')}-all.zip`);
|
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
|
// Build zip — level 0 (store only) since photos are already compressed
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const output = fs.createWriteStream(tmpPath);
|
const output = fs.createWriteStream(tmpPath);
|
||||||
@@ -147,19 +153,21 @@ class DownloadZipService {
|
|||||||
const hasMultipleTypes = uniqueTypes > 1;
|
const hasMultipleTypes = uniqueTypes > 1;
|
||||||
|
|
||||||
const addPhotos = async () => {
|
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
|
// Check if build was invalidated
|
||||||
if (this.versions.get(eventId) !== version) {
|
if (this.versions.get(eventId) !== version) {
|
||||||
archive.abort();
|
archive.abort();
|
||||||
return reject(new Error('Build invalidated'));
|
return reject(new Error('Build invalidated'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const entryName = entryNames[i];
|
||||||
let archiveName;
|
let archiveName;
|
||||||
if (hasMultipleTypes) {
|
if (hasMultipleTypes) {
|
||||||
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
|
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
|
||||||
archiveName = path.join(folderName, photo.filename);
|
archiveName = path.join(folderName, entryName);
|
||||||
} else {
|
} else {
|
||||||
archiveName = photo.filename;
|
archiveName = entryName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// External-mode photos still live on local disk; managed photos go
|
// External-mode photos still live on local disk; managed photos go
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize a string to be used as a filename component
|
* Sanitize a string to be used as a filename component
|
||||||
* @param {string} str - The string to sanitize
|
* @param {string} str - The string to sanitize
|
||||||
@@ -51,7 +53,125 @@ function generatePhotoFilename(eventName, categoryName, counter, extension) {
|
|||||||
return `${sanitizedEvent}_${sanitizedCategory}_${paddedCounter}${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 = {
|
module.exports = {
|
||||||
sanitizeFilename,
|
sanitizeFilename,
|
||||||
generatePhotoFilename
|
generatePhotoFilename,
|
||||||
|
sanitizeForContentDisposition,
|
||||||
|
buildContentDisposition,
|
||||||
|
sanitizeForZipEntry,
|
||||||
|
uniquifyZipNames,
|
||||||
};
|
};
|
||||||
@@ -20,6 +20,7 @@ export interface GeneralSettings {
|
|||||||
enable_registration: boolean;
|
enable_registration: boolean;
|
||||||
maintenance_mode: boolean;
|
maintenance_mode: boolean;
|
||||||
short_gallery_urls: boolean;
|
short_gallery_urls: boolean;
|
||||||
|
use_original_filenames_for_downloads: boolean;
|
||||||
default_language: string;
|
default_language: string;
|
||||||
date_format: { format: string; locale: string };
|
date_format: { format: string; locale: string };
|
||||||
}
|
}
|
||||||
@@ -94,6 +95,7 @@ export function useSettingsState() {
|
|||||||
enable_registration: false,
|
enable_registration: false,
|
||||||
maintenance_mode: false,
|
maintenance_mode: false,
|
||||||
short_gallery_urls: false,
|
short_gallery_urls: false,
|
||||||
|
use_original_filenames_for_downloads: false,
|
||||||
default_language: 'en',
|
default_language: 'en',
|
||||||
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
|
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
|
||||||
});
|
});
|
||||||
@@ -179,6 +181,10 @@ export function useSettingsState() {
|
|||||||
enable_registration: toBoolean(settings.general_enable_registration, false),
|
enable_registration: toBoolean(settings.general_enable_registration, false),
|
||||||
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
|
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
|
||||||
short_gallery_urls: toBoolean(settings.general_short_gallery_urls, 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',
|
default_language: settings.general_default_language || 'en',
|
||||||
date_format: settings.general_date_format
|
date_format: settings.general_date_format
|
||||||
? (typeof settings.general_date_format === 'string'
|
? (typeof settings.general_date_format === 'string'
|
||||||
|
|||||||
@@ -229,6 +229,21 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
{t('settings.general.enableShortGalleryUrlsHelp')}
|
{t('settings.general.enableShortGalleryUrlsHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={generalSettings.use_original_filenames_for_downloads}
|
||||||
|
onChange={(e) => setGeneralSettings(prev => ({ ...prev, use_original_filenames_for_downloads: e.target.checked }))}
|
||||||
|
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.general.useOriginalFilenames')}</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 ml-6 mt-1">
|
||||||
|
{t('settings.general.useOriginalFilenamesHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1038,6 +1038,8 @@
|
|||||||
"enableRegistration": "Selbstregistrierung für Admins erlauben",
|
"enableRegistration": "Selbstregistrierung für Admins erlauben",
|
||||||
"enableShortGalleryUrls": "Kurze Galerie-Links verwenden",
|
"enableShortGalleryUrls": "Kurze Galerie-Links verwenden",
|
||||||
"enableShortGalleryUrlsHelp": "Entfernt den Veranstaltungs-Slug aus neuen Freigabelinks und lässt bestehende Links weiterhin funktionieren.",
|
"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",
|
"maintenanceMode": "Wartungsmodus aktivieren",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"defaultLanguageHelp": "Sprache, die Gästen vor der Anmeldung angezeigt wird",
|
"defaultLanguageHelp": "Sprache, die Gästen vor der Anmeldung angezeigt wird",
|
||||||
|
|||||||
@@ -677,6 +677,8 @@
|
|||||||
"enableRegistration": "Allow self-registration for admins",
|
"enableRegistration": "Allow self-registration for admins",
|
||||||
"enableShortGalleryUrls": "Use short gallery URLs",
|
"enableShortGalleryUrls": "Use short gallery URLs",
|
||||||
"enableShortGalleryUrlsHelp": "Removes the event slug from new share links while keeping existing links working.",
|
"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",
|
"maintenanceMode": "Enable maintenance mode",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"defaultLanguageHelp": "Language shown to guests before login",
|
"defaultLanguageHelp": "Language shown to guests before login",
|
||||||
|
|||||||
Reference in New Issue
Block a user