From e18afd3e6b0b5a4cdb4873fb227d1b1d2bf35f21 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 23 Apr 2026 16:49:31 +0200 Subject: [PATCH] feat: pre-zip download all and photo replacement by name (#312, #313) Pre-zip downloads: - Generate ZIP in background after photo mutations (upload/delete/watermark change) - Serve cached zip with Content-Length for instant downloads and native progress bar - Falls back to on-the-fly streaming when no cache exists yet - Frontend uses browser-native download when zip is ready (no blob buffering) - New downloadZipService with debounced regeneration and in-memory locking Photo replacement: - Admin upload form gets "Replace existing photos with same name" checkbox - Matches by original_filename (case-insensitive) within the same event - Preserves photo ID, position, feedback, category, and visibility - Updates file, thumbnail, dimensions, EXIF capture date on replacement - Ambiguous matches (multiple photos with same name) skip replacement with warning - New photoReplacementService with findReplacementCandidate and replacePhoto --- .../core/079_add_download_zip_cache.js | 26 ++ backend/src/routes/adminEvents.js | 7 + backend/src/routes/adminPhotos.js | 100 +++++- backend/src/routes/gallery.js | 46 ++- backend/src/services/downloadZipService.js | 290 ++++++++++++++++++ backend/src/services/fileWatcher.js | 25 +- .../src/services/photoReplacementService.js | 146 +++++++++ frontend/src/components/admin/PhotoUpload.tsx | 27 +- .../src/components/gallery/GalleryView.tsx | 2 +- frontend/src/hooks/useGallery.ts | 3 +- frontend/src/i18n/locales/de.json | 2 + frontend/src/i18n/locales/en.json | 2 + frontend/src/i18n/locales/nl.json | 2 + frontend/src/i18n/locales/pt.json | 2 + frontend/src/i18n/locales/ru.json | 2 + frontend/src/services/gallery.service.ts | 18 +- 16 files changed, 664 insertions(+), 36 deletions(-) create mode 100644 backend/migrations/core/079_add_download_zip_cache.js create mode 100644 backend/src/services/downloadZipService.js create mode 100644 backend/src/services/photoReplacementService.js diff --git a/backend/migrations/core/079_add_download_zip_cache.js b/backend/migrations/core/079_add_download_zip_cache.js new file mode 100644 index 00000000..4c9b4b49 --- /dev/null +++ b/backend/migrations/core/079_add_download_zip_cache.js @@ -0,0 +1,26 @@ +/** + * Add download ZIP cache columns to events table. + * + * Enables pre-generated ZIP files for "Download All" so guests get + * instant downloads with Content-Length instead of on-the-fly streaming. + */ + +exports.up = async function(knex) { + const hasZipPath = await knex.schema.hasColumn('events', 'download_zip_path'); + if (!hasZipPath) { + await knex.schema.alterTable('events', (table) => { + table.text('download_zip_path').nullable().defaultTo(null); + table.datetime('download_zip_generated_at').nullable().defaultTo(null); + }); + } +}; + +exports.down = async function(knex) { + const hasZipPath = await knex.schema.hasColumn('events', 'download_zip_path'); + if (hasZipPath) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('download_zip_path'); + table.dropColumn('download_zip_generated_at'); + }); + } +}; diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index b04bbc7e..a3c5085c 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -22,6 +22,7 @@ const eventTypeService = require('../services/eventTypeService'); const { validateFileType } = require('../utils/fileSecurityUtils'); const { requireEventOwnership } = require('../middleware/ownership'); const { getFrontendBaseUrl } = require('../utils/frontendUrl'); +const downloadZipService = require('../services/downloadZipService'); // Shared validator for hero_image_anchor – accepts legacy keywords or "X% Y%" focal point const validateHeroImageAnchor = (value) => { @@ -1049,6 +1050,12 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne { type: 'admin', id: req.admin.id, name: req.admin.username } ); + // Invalidate download zip if watermark settings changed + const changeKeys = Object.keys(req.body); + if (changeKeys.includes('watermark_downloads') || changeKeys.includes('watermark_text')) { + downloadZipService.invalidate(parseInt(id)); + } + res.json({ message: 'Event updated successfully' }); } catch (error) { console.error('Error updating event:', error); diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index bcd98076..e03dd8e0 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -14,6 +14,8 @@ const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploa const { processUploadedPhotos } = require('../services/photoProcessor'); const chunkedUpload = require('../services/chunkedUploadService'); const watermarkGeneratorService = require('../services/watermarkGeneratorService'); +const downloadZipService = require('../services/downloadZipService'); +const { findReplacementCandidate, replacePhoto } = require('../services/photoReplacementService'); const { requireEventOwnership } = require('../middleware/ownership'); const router = express.Router(); @@ -149,7 +151,8 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r }, validateUploadContent, validateUploadedFiles, async (req, res) => { try { const { eventId } = req.params; - const { category_id } = req.body; + const { category_id, replace_by_name } = req.body; + const replaceByName = replace_by_name === 'true' || replace_by_name === true; console.log('Upload request received for event:', eventId); console.log('Body:', req.body); @@ -172,14 +175,21 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r return res.status(404).json({ error: 'Event not found' }); } - // Enforce photo cap if set + // Enforce photo cap if set (replacements don't count as new) if (event.photo_cap && event.photo_cap > 0) { const existingPhotoCount = await db('photos') .where({ event_id: eventId }) .count('id as count') .first(); const currentCount = parseInt(existingPhotoCount.count) || 0; - const newFilesCount = (req.files && req.files.length) || 0; + let newFilesCount = (req.files && req.files.length) || 0; + // Subtract likely replacements from cap calculation + if (replaceByName && req.files) { + for (const file of req.files) { + const candidate = await findReplacementCandidate(parseInt(eventId), file.originalname); + if (candidate && !candidate.ambiguous) newFilesCount--; + } + } if (currentCount + newFilesCount > event.photo_cap) { // Clean up temp files if (req.tempUploadPath) { @@ -238,13 +248,51 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r await fs.mkdir(finalDestPath, { recursive: true }); const uploadedPhotos = []; + const replacedPhotos = []; + const skippedReplacements = []; const errors = []; - - // Process files in batches to optimize database operations + + // Handle replacements first if enabled + let filesToUpload = req.files; + if (replaceByName && req.files.length > 0) { + const newFiles = []; + for (const file of req.files) { + const candidate = await findReplacementCandidate(parseInt(eventId), file.originalname); + if (candidate && !candidate.ambiguous) { + // Replace existing photo + const result = await replacePhoto(candidate, file.path, { + originalFilename: file.originalname, + mimeType: file.mimetype, + event, + }); + if (result.success) { + replacedPhotos.push({ + id: result.photo.id, + filename: result.photo.filename, + original_filename: file.originalname, + previous_filename: result.previousFilename, + }); + } else { + errors.push({ filename: file.originalname, error: `Replacement failed: ${result.error}` }); + } + } else if (candidate && candidate.ambiguous) { + skippedReplacements.push({ + filename: file.originalname, + reason: `${candidate.count} photos share this name — uploaded as new`, + }); + newFiles.push(file); + } else { + newFiles.push(file); + } + } + filesToUpload = newFiles; + } + + // Process remaining new files in batches const BATCH_SIZE = 25; // Increased batch size for better performance with large uploads - - for (let i = 0; i < req.files.length; i += BATCH_SIZE) { - const batch = req.files.slice(i, i + BATCH_SIZE); + + for (let i = 0; i < filesToUpload.length; i += BATCH_SIZE) { + const batch = filesToUpload.slice(i, i + BATCH_SIZE); // Start a single transaction for the batch const trx = await db.transaction(); @@ -480,21 +528,36 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r // Log activity await logActivity('photos_uploaded', - { count: uploadedPhotos.length, eventName: event.event_name }, + { count: uploadedPhotos.length, replacedCount: replacedPhotos.length, eventName: event.event_name }, eventId, { type: 'admin', id: req.admin.id, name: req.admin.username } ); - + + // Log individual replacements for audit trail + for (const rp of replacedPhotos) { + await logActivity('photo_replaced', + { photoId: rp.id, originalFilename: rp.original_filename, previousFilename: rp.previous_filename, eventName: event.event_name }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + } + // Include any files that were invalid from the validation middleware const totalInvalidFiles = (req.invalidFiles || []).concat(errors); - + // Prepare response const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0); + const uploadMsg = uploadedPhotos.length > 0 ? `${uploadedPhotos.length} uploaded` : ''; + const replaceMsg = replacedPhotos.length > 0 ? `${replacedPhotos.length} replaced` : ''; + const parts = [uploadMsg, replaceMsg].filter(Boolean).join(', '); const response = { - message: `Successfully uploaded ${uploadedPhotos.length} photos`, + message: parts ? `Successfully ${parts}` : 'No photos processed', photos: uploadedPhotos, + replaced: replacedPhotos, + replacedCount: replacedPhotos.length, + skippedReplacements, totalFiles: totalAttempted, - successCount: uploadedPhotos.length, + successCount: uploadedPhotos.length + replacedPhotos.length, failureCount: totalInvalidFiles.length }; @@ -504,10 +567,15 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} photos. ${totalInvalidFiles.length} failed.`; } + // Invalidate download zip cache after successful upload or replacement + if (uploadedPhotos.length > 0 || replacedPhotos.length > 0) { + downloadZipService.invalidate(parseInt(eventId)); + } + res.json(response); } catch (error) { console.error('Error uploading photos:', error); - + // Clean up temp upload directory on error if (req.tempUploadPath) { try { @@ -576,7 +644,8 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos. eventId, { type: 'admin', id: req.admin.id, name: req.admin.username } ); - + + downloadZipService.invalidate(parseInt(eventId)); res.json({ message: 'Photo deleted successfully' }); } catch (error) { console.error('Error deleting photo:', error); @@ -713,6 +782,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos { type: 'admin', id: req.admin.id, name: req.admin.username } ); + downloadZipService.invalidate(parseInt(eventId)); res.json({ message: `${photos.length} photos deleted successfully` }); } catch (error) { console.error('Error bulk deleting photos:', error); diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 8c464591..62b95de5 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -14,6 +14,8 @@ const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = r const { handleAsync } = require('../utils/routeHelpers'); const { NotFoundError } = require('../utils/errors'); const { ensureThumbnail, ensureHeroImage } = require('../services/imageProcessor'); +const downloadZipService = require('../services/downloadZipService'); +const fs = require('fs'); // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); @@ -405,6 +407,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { hero_divider_style: req.event.hero_divider_style || 'wave', hero_image_anchor: req.event.hero_image_anchor || 'center', default_photo_sort: req.event.default_photo_sort || 'upload_date_desc', + download_zip_ready: !!(req.event.download_zip_path && req.event.download_zip_generated_at), ...protectionSettings }, categories: categories, @@ -622,32 +625,57 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => { if (req.event.allow_downloads === false) { return res.status(403).json({ error: 'Downloads are disabled for this gallery' }); } - + + // Try to serve pre-generated zip (instant download with Content-Length) + const zipInfo = await downloadZipService.getZipInfo(req.event.id); + if (zipInfo) { + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Length', zipInfo.size); + res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`); + const stream = fs.createReadStream(zipInfo.path); + stream.pipe(res); + + // Log bulk download + db('access_logs').insert({ + event_id: req.event.id, + ip_address: req.ip, + user_agent: req.headers['user-agent'], + action: 'download_all' + }).catch(() => {}); + return; + } + + // Fallback: on-the-fly streaming (existing behavior) + // Also trigger background zip generation for next time + downloadZipService.generateZip(req.event.id).catch(err => + logger.warn('Background zip generation failed', { eventId: req.event.id, error: err.message }) + ); + // Fetch photos const photos = await db('photos') .where('photos.event_id', req.event.id) .select('photos.*') .orderBy('photos.type', 'asc') .orderBy('photos.uploaded_at', 'desc'); - + if (photos.length === 0) { return res.status(404).json({ error: 'No photos found' }); } - + // Count unique types const uniqueTypes = new Set(photos.map(p => p.type)).size; const hasMultipleTypes = uniqueTypes > 1; - + res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`); - + const archive = archiver('zip', { zlib: { level: 5 } }); archive.on('error', (err) => { throw err; }); - + archive.pipe(res); - + // Get watermark settings - apply if global setting OR event-level setting is enabled const watermarkSettings = await watermarkService.getWatermarkSettings(); const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1; @@ -700,9 +728,9 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => { archive.file(filePath, { name: archiveName }); } } - + await archive.finalize(); - + // Log bulk download await db('access_logs').insert({ event_id: req.event.id, diff --git a/backend/src/services/downloadZipService.js b/backend/src/services/downloadZipService.js new file mode 100644 index 00000000..e176d56a --- /dev/null +++ b/backend/src/services/downloadZipService.js @@ -0,0 +1,290 @@ +/** + * DownloadZipService + * + * Pre-generates ZIP archives for "Download All" so guests get instant + * downloads with Content-Length instead of on-the-fly streaming that + * crashes mobile browsers. + * + * Pattern follows watermarkGeneratorService.js — singleton with + * in-memory locking and debounced background regeneration. + */ + +const fs = require('fs'); +const fsp = require('fs/promises'); +const path = require('path'); +const archiver = require('archiver'); +const { db } = require('../database/db'); +const watermarkService = require('./watermarkService'); +const { resolvePhotoFilePath } = require('./photoResolver'); +const { getStoragePath } = require('../config/storage'); +const logger = require('../utils/logger'); + +const DEBOUNCE_MS = 5000; + +class DownloadZipService { + constructor() { + this.activeBuilds = new Map(); // eventId -> { promise, version } + this.debounceTimers = new Map(); // eventId -> setTimeout handle + this.versions = new Map(); // eventId -> generation counter + } + + /** + * Absolute path to the cached zip for an event slug. + */ + getCachePath(slug) { + return path.join(getStoragePath(), 'events', 'active', slug, '.download-cache', 'all.zip'); + } + + /** + * Check if a valid cached zip exists. + * Returns { path, size, generatedAt } or null. + */ + async getZipInfo(eventId) { + try { + const event = await db('events') + .where({ id: eventId }) + .select('download_zip_path', 'download_zip_generated_at', 'slug') + .first(); + + if (!event || !event.download_zip_path) return null; + + const absPath = this.getCachePath(event.slug); + try { + const stat = await fsp.stat(absPath); + return { + path: absPath, + size: stat.size, + generatedAt: event.download_zip_generated_at, + }; + } catch { + // File gone — clear stale DB record + await db('events').where({ id: eventId }).update({ + download_zip_path: null, + download_zip_generated_at: null, + }); + return null; + } + } catch (err) { + logger.warn('downloadZipService.getZipInfo error', { eventId, error: err.message }); + return null; + } + } + + /** + * Generate the pre-zip for an event. Returns { success, path, size } or { success: false }. + * Concurrent calls for the same eventId share one in-flight build. + */ + async generateZip(eventId) { + // If already building, return the existing promise + const existing = this.activeBuilds.get(eventId); + if (existing) return existing.promise; + + const version = (this.versions.get(eventId) || 0) + 1; + this.versions.set(eventId, version); + + const promise = this._build(eventId, version); + this.activeBuilds.set(eventId, { promise, version }); + + try { + return await promise; + } finally { + // Only clear if this is still the active build + const current = this.activeBuilds.get(eventId); + if (current && current.version === version) { + this.activeBuilds.delete(eventId); + } + } + } + + async _build(eventId, version) { + try { + const event = await db('events').where({ id: eventId }).first(); + if (!event) return { success: false, error: 'Event not found' }; + + const photos = await db('photos') + .where({ event_id: eventId }) + .select('*') + .orderBy('type', 'asc') + .orderBy('uploaded_at', 'desc'); + + if (photos.length === 0) return { success: false, error: 'No photos' }; + + // Watermark logic (same as gallery.js download-all) + const watermarkSettings = await watermarkService.getWatermarkSettings(); + const eventWatermarkEnabled = event.watermark_downloads === true || event.watermark_downloads === 1; + const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled; + const effectiveSettings = shouldApplyWatermark ? { + ...watermarkSettings, + enabled: true, + text: event.watermark_text || watermarkSettings?.text || 'Protected', + } : null; + + const cacheDir = path.dirname(this.getCachePath(event.slug)); + await fsp.mkdir(cacheDir, { recursive: true }); + + const tmpPath = this.getCachePath(event.slug) + `.tmp.${Date.now()}`; + const finalPath = this.getCachePath(event.slug); + + // Build zip — level 0 (store only) since photos are already compressed + await new Promise((resolve, reject) => { + const output = fs.createWriteStream(tmpPath); + const archive = archiver('zip', { zlib: { level: 0 } }); + + output.on('close', resolve); + archive.on('error', reject); + archive.pipe(output); + + const uniqueTypes = new Set(photos.map(p => p.type)).size; + const hasMultipleTypes = uniqueTypes > 1; + + const addPhotos = async () => { + for (const photo of photos) { + // Check if build was invalidated + if (this.versions.get(eventId) !== version) { + archive.abort(); + return reject(new Error('Build invalidated')); + } + + let filePath; + try { + filePath = resolvePhotoFilePath(event, photo); + } catch { + continue; + } + + let archiveName; + if (hasMultipleTypes) { + const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages'; + archiveName = path.join(folderName, photo.filename); + } else { + archiveName = photo.filename; + } + + if (shouldApplyWatermark && effectiveSettings) { + try { + const buf = await watermarkService.applyWatermark(filePath, effectiveSettings); + archive.append(buf, { name: archiveName }); + } catch (err) { + logger.warn('Skipping watermark in pre-zip', { photoId: photo.id, error: err.message }); + } + } else { + archive.file(filePath, { name: archiveName }); + } + } + + archive.finalize(); + }; + + addPhotos().catch(reject); + }); + + // Check version again — another invalidation may have arrived + if (this.versions.get(eventId) !== version) { + await fsp.unlink(tmpPath).catch(() => {}); + return { success: false, error: 'Build invalidated' }; + } + + // Atomic rename + await fsp.rename(tmpPath, finalPath); + + const stat = await fsp.stat(finalPath); + + // Update DB + await db('events').where({ id: eventId }).update({ + download_zip_path: `events/active/${event.slug}/.download-cache/all.zip`, + download_zip_generated_at: new Date(), + }); + + logger.info('Pre-zip generated', { eventId, slug: event.slug, size: stat.size, photos: photos.length }); + return { success: true, path: finalPath, size: stat.size }; + } catch (err) { + if (err.message === 'Build invalidated') { + return { success: false, error: 'Build invalidated' }; + } + logger.error('downloadZipService._build error', { eventId, error: err.message }); + return { success: false, error: err.message }; + } + } + + /** + * Invalidate the cached zip for an event. + * Deletes the file, clears DB, debounces regeneration. + */ + invalidate(eventId) { + // Bump version to signal any in-flight build is stale + this.versions.set(eventId, (this.versions.get(eventId) || 0) + 1); + + // Cancel pending debounce + const timer = this.debounceTimers.get(eventId); + if (timer) clearTimeout(timer); + + // Fire-and-forget cleanup + this._cleanup(eventId).catch(err => + logger.warn('downloadZipService.invalidate cleanup error', { eventId, error: err.message }) + ); + + // Debounce regeneration + const newTimer = setTimeout(() => { + this.debounceTimers.delete(eventId); + this.generateZip(eventId).catch(err => + logger.warn('downloadZipService debounced regen error', { eventId, error: err.message }) + ); + }, DEBOUNCE_MS); + this.debounceTimers.set(eventId, newTimer); + } + + /** + * Invalidate all events (e.g., global watermark settings changed). + */ + async invalidateAll() { + try { + const events = await db('events') + .whereNotNull('download_zip_path') + .select('id'); + for (const event of events) { + this.invalidate(event.id); + } + } catch (err) { + logger.error('downloadZipService.invalidateAll error', { error: err.message }); + } + } + + /** + * Full cleanup — delete file and clear DB. Used on event deletion/archival. + */ + async cleanup(eventId) { + this.versions.set(eventId, (this.versions.get(eventId) || 0) + 1); + const timer = this.debounceTimers.get(eventId); + if (timer) { + clearTimeout(timer); + this.debounceTimers.delete(eventId); + } + await this._cleanup(eventId); + } + + async _cleanup(eventId) { + try { + const event = await db('events') + .where({ id: eventId }) + .select('slug', 'download_zip_path') + .first(); + + if (event && event.download_zip_path) { + const absPath = this.getCachePath(event.slug); + await fsp.unlink(absPath).catch(() => {}); + // Also try to remove the cache directory if empty + const cacheDir = path.dirname(absPath); + await fsp.rmdir(cacheDir).catch(() => {}); + } + + await db('events').where({ id: eventId }).update({ + download_zip_path: null, + download_zip_generated_at: null, + }); + } catch (err) { + logger.warn('downloadZipService._cleanup error', { eventId, error: err.message }); + } + } +} + +module.exports = new DownloadZipService(); diff --git a/backend/src/services/fileWatcher.js b/backend/src/services/fileWatcher.js index 4f073868..1205aaad 100644 --- a/backend/src/services/fileWatcher.js +++ b/backend/src/services/fileWatcher.js @@ -7,6 +7,7 @@ const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcesso const logger = require('../utils/logger'); const { isVideoMimeType } = require('./videoProcessor'); const mime = require('mime-types'); +const downloadZipService = require('./downloadZipService'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const WATCH_PATH = () => path.join(getStoragePath(), 'events/active'); @@ -81,11 +82,15 @@ async function processNewPhoto(filePath) { const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root const mimeType = detectedMime || (isVideo ? 'video/mp4' : 'image/jpeg'); - // Check if photo already exists + // Check if photo already exists (by filename or path, to handle replacements) const existingPhoto = await db('photos') - .where({ event_id: event.id, filename: path.basename(filePath) }) + .where({ event_id: event.id }) + .where(function() { + this.where('filename', path.basename(filePath)) + .orWhere('path', relativePath); + }) .first(); - + if (!existingPhoto) { // Add to database await db('photos').insert({ @@ -97,8 +102,9 @@ async function processNewPhoto(filePath) { size_bytes: stats.size, mime_type: mimeType }); - + logger.info(`Added new photo: ${relativePath}`); + downloadZipService.invalidate(event.id); } else { logger.debug(`Photo already exists: ${relativePath}`); } @@ -106,10 +112,17 @@ async function processNewPhoto(filePath) { async function removePhoto(filePath) { const relativePath = path.relative(WATCH_PATH(), filePath); - + + // Look up event before deleting to invalidate zip cache + const photo = await db('photos').where({ path: relativePath }).first(); + // Remove from database await db('photos').where({ path: relativePath }).delete(); - + + if (photo) { + downloadZipService.invalidate(photo.event_id); + } + logger.info(`Removed photo: ${relativePath}`); } diff --git a/backend/src/services/photoReplacementService.js b/backend/src/services/photoReplacementService.js new file mode 100644 index 00000000..b607a132 --- /dev/null +++ b/backend/src/services/photoReplacementService.js @@ -0,0 +1,146 @@ +/** + * PhotoReplacementService + * + * Handles replacing existing photos by matching original_filename. + * Preserves the photo's ID, position, feedback, category, and visibility + * while updating the physical file and metadata. + */ + +const path = require('path'); +const fsp = require('fs/promises'); +const sharp = require('sharp'); +const { db } = require('../database/db'); +const { generateThumbnail, extractCaptureDate } = require('./imageProcessor'); +const { generatePhotoFilename } = require('../utils/filenameSanitizer'); +const watermarkGeneratorService = require('./watermarkGeneratorService'); +const logger = require('../utils/logger'); + +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +/** + * Find a replacement candidate by matching original_filename (case-insensitive). + * Returns the photo row if exactly one match, { ambiguous: true, count } if multiple, or null. + */ +async function findReplacementCandidate(eventId, originalFilename) { + if (!originalFilename) return null; + + const matches = await db('photos') + .where({ event_id: eventId }) + .whereRaw('LOWER(original_filename) = LOWER(?)', [originalFilename]); + + if (matches.length === 1) return matches[0]; + if (matches.length > 1) return { ambiguous: true, count: matches.length }; + return null; +} + +/** + * Replace an existing photo's file while preserving its DB identity. + * + * @param {Object} existingPhoto - The current photo DB row + * @param {string} newFileTempPath - Path to the new file (will be moved) + * @param {Object} opts - { originalFilename, mimeType, event } + * @returns {{ success: boolean, photo?: Object, error?: string }} + */ +async function replacePhoto(existingPhoto, newFileTempPath, { originalFilename, mimeType, event }) { + const eventDir = path.join(getStoragePath(), 'events', 'active', event.slug); + const categorySlug = existingPhoto.type === 'collage' ? 'collages' : 'individual'; + const targetDir = path.join(eventDir, categorySlug); + + try { + // Generate new filename + const ext = path.extname(originalFilename); + const newFilename = generatePhotoFilename(event.event_name, categorySlug, Date.now(), ext); + const tempTargetPath = path.join(targetDir, `_replacing_${Date.now()}_${newFilename}`); + const finalPath = path.join(targetDir, newFilename); + const relativePath = path.join(event.slug, categorySlug, newFilename); + + // Write new file to temp name in target directory + await fsp.mkdir(targetDir, { recursive: true }); + await fsp.copyFile(newFileTempPath, tempTargetPath); + + // Delete old physical file + const oldFilePath = path.join(getStoragePath(), 'events', 'active', existingPhoto.path); + await fsp.unlink(oldFilePath).catch(() => {}); + + // Delete old thumbnail + if (existingPhoto.thumbnail_path) { + const oldThumbPath = path.join(getStoragePath(), existingPhoto.thumbnail_path); + await fsp.unlink(oldThumbPath).catch(() => {}); + } + + // Delete old watermark cache + try { + await watermarkGeneratorService.deleteForPhoto(existingPhoto.id); + } catch { + // Ignore — watermark may not exist + } + + // Rename temp → final + await fsp.rename(tempTargetPath, finalPath); + + // Extract metadata from new file + let capturedAt = null; + try { + capturedAt = await extractCaptureDate(finalPath); + } catch { + // No EXIF — keep null + } + + let width = null; + let height = null; + try { + const metadata = await sharp(finalPath).metadata(); + width = metadata.width || null; + height = metadata.height || null; + } catch { + // Non-image or corrupt + } + + const stats = await fsp.stat(finalPath); + + // Generate new thumbnail + let thumbnailPath = null; + try { + thumbnailPath = await generateThumbnail(finalPath); + } catch { + logger.warn('Failed to generate thumbnail for replaced photo', { photoId: existingPhoto.id }); + } + + // Update DB record — preserve id, event_id, category_id, type, visibility, + // uploaded_at, sort_order, feedback counts, view/download counts + const updates = { + filename: newFilename, + original_filename: originalFilename, + path: relativePath, + thumbnail_path: thumbnailPath, + size_bytes: stats.size, + width, + height, + captured_at: capturedAt, + mime_type: mimeType, + media_type: mimeType?.startsWith('video/') ? 'video' : 'image', + }; + + await db('photos').where({ id: existingPhoto.id }).update(updates); + + const updatedPhoto = await db('photos').where({ id: existingPhoto.id }).first(); + + logger.info('Photo replaced', { + photoId: existingPhoto.id, + oldFilename: existingPhoto.filename, + newFilename, + originalFilename, + }); + + return { + success: true, + photo: updatedPhoto, + previousFilename: existingPhoto.filename, + }; + } catch (err) { + logger.error('replacePhoto error', { photoId: existingPhoto.id, error: err.message }); + return { success: false, error: err.message }; + } +} + +module.exports = { findReplacementCandidate, replacePhoto }; diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx index fd0789b1..326d8a2c 100644 --- a/frontend/src/components/admin/PhotoUpload.tsx +++ b/frontend/src/components/admin/PhotoUpload.tsx @@ -26,6 +26,7 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl const [currentChunk, setCurrentChunk] = useState(0); const [totalChunks, setTotalChunks] = useState(0); const [selectedCategoryId, setSelectedCategoryId] = useState(null); + const [replaceByName, setReplaceByName] = useState(false); const fileInputRef = useRef(null); // Fetch categories for this event @@ -135,6 +136,7 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl setTotalChunks(chunks.length); let totalUploaded = 0; + let totalReplaced = 0; let failedFiles = []; try { @@ -150,9 +152,12 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl if (selectedCategoryId) { formData.append('category_id', selectedCategoryId.toString()); } + if (replaceByName) { + formData.append('replace_by_name', 'true'); + } try { - await api.post(`/admin/events/${eventId}/upload`, formData, { + const response = await api.post(`/admin/events/${eventId}/upload`, formData, { onUploadProgress: (progressEvent) => { if (progressEvent.total) { // Calculate overall progress across all chunks @@ -163,7 +168,8 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl }, }); - totalUploaded += chunk.length; + totalUploaded += (response.data?.successCount || chunk.length); + totalReplaced += (response.data?.replacedCount || 0); } catch (error: any) { console.error(`Error uploading chunk ${chunkIndex + 1}:`, error); failedFiles.push(...chunk.map(f => f.name)); @@ -180,6 +186,9 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl } // Show appropriate message + if (totalReplaced > 0) { + toast.info(t('upload.replacedFiles', { count: totalReplaced }) || `${totalReplaced} photo(s) replaced`); + } if (failedFiles.length === 0) { toast.success(t('upload.uploadComplete') || `Successfully uploaded ${totalUploaded} files`); } else { @@ -231,6 +240,20 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl + {/* Replace by name toggle */} +
+ setReplaceByName(e.target.checked)} + className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500" + /> + +
+ {/* File Input Area */}
= ({ slug, event }) => { return; } - downloadAllMutation.mutate(slug); + downloadAllMutation.mutate({ slug, zipReady: data?.event?.download_zip_ready }); // Track download all action analyticsService.trackGalleryEvent('bulk_download', { diff --git a/frontend/src/hooks/useGallery.ts b/frontend/src/hooks/useGallery.ts index 5fd0186d..4f77c1ee 100644 --- a/frontend/src/hooks/useGallery.ts +++ b/frontend/src/hooks/useGallery.ts @@ -67,7 +67,8 @@ export const useDownloadPhoto = () => { export const useDownloadAllPhotos = () => { return useMutation({ - mutationFn: (slug: string) => galleryService.downloadAllPhotos(slug), + mutationFn: ({ slug, zipReady }: { slug: string; zipReady?: boolean }) => + galleryService.downloadAllPhotos(slug, zipReady), onSuccess: () => { toast.success('Download started'); }, diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 765c5787..891a6c2d 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -134,6 +134,8 @@ "uploadComplete": "Upload abgeschlossen!", "uploadFailed": "Upload fehlgeschlagen", "someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden", + "replaceByName": "Vorhandene Fotos mit gleichem Namen ersetzen", + "replacedFiles": "{{count}} Foto(s) ersetzt", "uploadPhotos": "Fotos hochladen", "uploadMedia": "Fotos & Videos hochladen", "importExternal": "Aus externem Ordner importieren", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 767b7a1f..d2d3ba87 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -134,6 +134,8 @@ "uploadComplete": "Upload complete!", "uploadFailed": "Upload failed", "someFilesFailed": "Some files failed to upload", + "replaceByName": "Replace existing photos with same name", + "replacedFiles": "{{count}} photo(s) replaced", "uploadPhotos": "Upload Photos", "uploadMedia": "Upload Photos & Videos", "importExternal": "Import from External Folder", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 87477e9f..ad5d64d4 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -134,6 +134,8 @@ "uploadComplete": "Upload voltooid!", "uploadFailed": "Upload mislukt", "someFilesFailed": "Sommige bestanden konden niet worden geupload", + "replaceByName": "Bestaande foto's met dezelfde naam vervangen", + "replacedFiles": "{{count}} foto('s) vervangen", "uploadPhotos": "Foto's uploaden", "uploadMedia": "Foto's & video's uploaden", "importExternal": "Importeren uit externe map", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 625011da..620fa972 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -134,6 +134,8 @@ "uploadComplete": "Envio concluído!", "uploadFailed": "Falha no envio", "someFilesFailed": "Alguns arquivos falharam ao enviar", + "replaceByName": "Substituir fotos existentes com o mesmo nome", + "replacedFiles": "{{count}} foto(s) substituída(s)", "uploadPhotos": "Enviar Fotos", "uploadMedia": "Enviar Fotos e Vídeos", "importExternal": "Importar de Pasta Externa", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 97e2a52e..7e2a0897 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -134,6 +134,8 @@ "uploadComplete": "Загрузка завершена!", "uploadFailed": "Ошибка загрузки", "someFilesFailed": "Не удалось загрузить некоторые файлы", + "replaceByName": "Заменить существующие фото с таким же именем", + "replacedFiles": "{{count}} фото заменено", "uploadPhotos": "Загрузить фото", "uploadMedia": "Загрузить фото и видео", "importExternal": "Импортировать из внешней папки", diff --git a/frontend/src/services/gallery.service.ts b/frontend/src/services/gallery.service.ts index 5c199612..9535ab91 100644 --- a/frontend/src/services/gallery.service.ts +++ b/frontend/src/services/gallery.service.ts @@ -82,12 +82,26 @@ export const galleryService = { }, // Download all photos as ZIP - async downloadAllPhotos(slug: string): Promise { + // When a pre-generated zip is available, use native browser download (Content-Length → progress bar). + // Otherwise fall back to blob download. + async downloadAllPhotos(slug: string, zipReady?: boolean): Promise { + if (zipReady) { + // Native browser download — the server sends Content-Length so + // the browser shows a real progress bar and mobile doesn't crash. + const link = document.createElement('a'); + link.href = `/api/gallery/${slug}/download-all`; + link.setAttribute('download', `${slug}.zip`); + document.body.appendChild(link); + link.click(); + link.remove(); + return; + } + + // Fallback: blob download (no Content-Length, buffered in memory) const response = await api.get(`/gallery/${slug}/download-all`, { responseType: 'blob', }); - // Create download link const url = window.URL.createObjectURL(new Blob([response.data])); const link = document.createElement('a'); link.href = url;