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:
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -26,6 +26,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
const [currentChunk, setCurrentChunk] = useState(0);
|
||||
const [totalChunks, setTotalChunks] = useState(0);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||
const [replaceByName, setReplaceByName] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fetch categories for this event
|
||||
@@ -135,6 +136,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
|
||||
setTotalChunks(chunks.length);
|
||||
let totalUploaded = 0;
|
||||
let totalReplaced = 0;
|
||||
let failedFiles = [];
|
||||
|
||||
try {
|
||||
@@ -150,9 +152,12 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ 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<PhotoUploadProps> = ({ 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<PhotoUploadProps> = ({ 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<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Replace by name toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="replace-by-name"
|
||||
checked={replaceByName}
|
||||
onChange={(e) => setReplaceByName(e.target.checked)}
|
||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor="replace-by-name" className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{t('upload.replaceByName', 'Replace existing photos with same name')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* File Input Area */}
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -514,7 +514,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadAllMutation.mutate(slug);
|
||||
downloadAllMutation.mutate({ slug, zipReady: data?.event?.download_zip_ready });
|
||||
|
||||
// Track download all action
|
||||
analyticsService.trackGalleryEvent('bulk_download', {
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"uploadComplete": "Загрузка завершена!",
|
||||
"uploadFailed": "Ошибка загрузки",
|
||||
"someFilesFailed": "Не удалось загрузить некоторые файлы",
|
||||
"replaceByName": "Заменить существующие фото с таким же именем",
|
||||
"replacedFiles": "{{count}} фото заменено",
|
||||
"uploadPhotos": "Загрузить фото",
|
||||
"uploadMedia": "Загрузить фото и видео",
|
||||
"importExternal": "Импортировать из внешней папки",
|
||||
|
||||
@@ -82,12 +82,26 @@ export const galleryService = {
|
||||
},
|
||||
|
||||
// Download all photos as ZIP
|
||||
async downloadAllPhotos(slug: string): Promise<void> {
|
||||
// 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<void> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user