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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user