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
This commit is contained in:
Paul Nothaft
2026-04-23 16:49:31 +02:00
parent 4353acebf9
commit e18afd3e6b
16 changed files with 664 additions and 36 deletions
+7
View File
@@ -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);
+85 -15
View File
@@ -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);
+37 -9
View File
@@ -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,