diff --git a/backend/migrations/README.md b/backend/migrations/README.md
index d6bae21d..3adfceb6 100644
--- a/backend/migrations/README.md
+++ b/backend/migrations/README.md
@@ -1,6 +1,6 @@
# Database Migrations
-This directory contains database migrations for the Wedding Photo Sharing platform.
+This directory contains database migrations for the PicPeak photo sharing platform.
## Directory Structure
@@ -9,6 +9,7 @@ Essential migrations that are always run for new deployments. These include:
- `init.js` - Initial database schema creation
- Backup service tables (029-035)
- Gallery feedback tables (033)
+- Pre-generated watermarks (061)
### `/legacy`
Migrations needed only when upgrading from older versions. New deployments can skip these as the core schema already includes all necessary tables and columns.
diff --git a/backend/migrations/core/061_add_watermark_path.js b/backend/migrations/core/061_add_watermark_path.js
new file mode 100644
index 00000000..454a2088
--- /dev/null
+++ b/backend/migrations/core/061_add_watermark_path.js
@@ -0,0 +1,28 @@
+/**
+ * Migration 061: Add pre-generated watermark path to photos table
+ * - photos.watermark_path: path to pre-generated watermarked image
+ * - photos.watermark_generated_at: timestamp of watermark generation
+ */
+
+const { addColumnIfNotExists } = require('../helpers');
+
+exports.up = async function(knex) {
+ console.log('Running migration: 061_add_watermark_path');
+
+ // photos.watermark_path (nullable - path to pre-generated watermarked image)
+ await addColumnIfNotExists(knex, 'photos', 'watermark_path', (table) => {
+ table.string('watermark_path', 512);
+ });
+
+ // photos.watermark_generated_at (nullable - when watermark was last generated)
+ await addColumnIfNotExists(knex, 'photos', 'watermark_generated_at', (table) => {
+ table.timestamp('watermark_generated_at');
+ });
+
+ console.log('Migration 061_add_watermark_path completed');
+};
+
+exports.down = async function(knex) {
+ console.log('Rollback: 061_add_watermark_path');
+ // Keep columns (safe rollback not removing data). Intentionally no-op.
+};
diff --git a/backend/migrations/legacy/006_add_photo_counter_to_categories.js b/backend/migrations/legacy/006_add_photo_counter_to_categories.js
index 6f378aad..4f4a46a6 100644
--- a/backend/migrations/legacy/006_add_photo_counter_to_categories.js
+++ b/backend/migrations/legacy/006_add_photo_counter_to_categories.js
@@ -1,22 +1,27 @@
exports.up = async function(knex) {
- // Add photo_counter column to photo_categories table
- await knex.schema.alterTable('photo_categories', function(table) {
- table.integer('photo_counter').defaultTo(0).notNullable();
- });
+ // Check if photo_counter column already exists to make migration idempotent
+ const hasPhotoCounter = await knex.schema.hasColumn('photo_categories', 'photo_counter');
- // Initialize counters based on existing photos
- const categories = await knex('photo_categories').select('id');
-
- for (const category of categories) {
- const photoCount = await knex('photos')
- .where('category_id', category.id)
- .count('id as count')
- .first();
-
- if (photoCount && photoCount.count > 0) {
- await knex('photo_categories')
- .where('id', category.id)
- .update({ photo_counter: photoCount.count });
+ if (!hasPhotoCounter) {
+ // Add photo_counter column to photo_categories table
+ await knex.schema.alterTable('photo_categories', function(table) {
+ table.integer('photo_counter').defaultTo(0).notNullable();
+ });
+
+ // Initialize counters based on existing photos
+ const categories = await knex('photo_categories').select('id');
+
+ for (const category of categories) {
+ const photoCount = await knex('photos')
+ .where('category_id', category.id)
+ .count('id as count')
+ .first();
+
+ if (photoCount && photoCount.count > 0) {
+ await knex('photo_categories')
+ .where('id', category.id)
+ .update({ photo_counter: photoCount.count });
+ }
}
}
};
diff --git a/backend/migrations/legacy/008_add_language_support_to_email_templates.js b/backend/migrations/legacy/008_add_language_support_to_email_templates.js
index 40718509..d2604504 100644
--- a/backend/migrations/legacy/008_add_language_support_to_email_templates.js
+++ b/backend/migrations/legacy/008_add_language_support_to_email_templates.js
@@ -1,23 +1,33 @@
exports.up = async function(knex) {
- // Add language-specific columns to email_templates
- await knex.schema.alterTable('email_templates', function(table) {
- // Add English versions (rename existing columns for consistency)
- table.renameColumn('subject', 'subject_en');
- table.renameColumn('body_html', 'body_html_en');
- table.renameColumn('body_text', 'body_text_en');
-
- // Add German versions
- table.string('subject_de');
- table.text('body_html_de');
- table.text('body_text_de');
- });
+ // Check which columns already exist to make migration idempotent
+ const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
+ const hasSubjectDe = await knex.schema.hasColumn('email_templates', 'subject_de');
+ const hasSubjectOriginal = await knex.schema.hasColumn('email_templates', 'subject');
- // Copy existing values to German columns as defaults
- await knex('email_templates').update({
- subject_de: knex.raw('subject_en'),
- body_html_de: knex.raw('body_html_en'),
- body_text_de: knex.raw('body_text_en')
- });
+ // Only rename columns if they haven't been renamed yet
+ if (hasSubjectOriginal && !hasSubjectEn) {
+ await knex.schema.alterTable('email_templates', function(table) {
+ table.renameColumn('subject', 'subject_en');
+ table.renameColumn('body_html', 'body_html_en');
+ table.renameColumn('body_text', 'body_text_en');
+ });
+ }
+
+ // Only add German columns if they don't exist
+ if (!hasSubjectDe) {
+ await knex.schema.alterTable('email_templates', function(table) {
+ table.string('subject_de');
+ table.text('body_html_de');
+ table.text('body_text_de');
+ });
+
+ // Copy existing values to German columns as defaults
+ await knex('email_templates').update({
+ subject_de: knex.raw('subject_en'),
+ body_html_de: knex.raw('body_html_en'),
+ body_text_de: knex.raw('body_text_en')
+ });
+ }
};
exports.down = async function(knex) {
diff --git a/backend/migrations/run-migrations-safe.js b/backend/migrations/run-migrations-safe.js
index 99c5abee..f66271d0 100644
--- a/backend/migrations/run-migrations-safe.js
+++ b/backend/migrations/run-migrations-safe.js
@@ -67,26 +67,37 @@ async function runMigrationSafely(filepath) {
const migrationPath = path.join(__dirname, filepath);
const migration = require(migrationPath);
const filename = path.basename(filepath);
-
+
if (migration.up) {
console.log(`Running migration: ${filepath}`);
-
+
// Run migration in a transaction if possible
+ // IMPORTANT: Include the migrations table insert INSIDE the transaction
+ // to ensure atomicity between schema changes and tracking
if (db.client.config.client === 'pg') {
await db.transaction(async (trx) => {
await migration.up(trx);
+ // Insert migration record inside transaction for atomicity
+ await trx('migrations').insert({ filename });
});
} else {
await migration.up(db);
+ await db('migrations').insert({ filename });
}
-
- await db('migrations').insert({ filename });
+
console.log(`Migration ${filepath} completed successfully`);
}
} catch (error) {
// Check if error is because schema already exists
- if (error.code === '42P07' || // PostgreSQL: relation already exists
- error.code === 'SQLITE_ERROR' && error.message.includes('already exists')) {
+ // PostgreSQL error codes:
+ // - 42P07: duplicate_table (relation already exists)
+ // - 42701: duplicate_column (column already exists)
+ // - 42710: duplicate_object (constraint, index, etc. already exists)
+ // - 23505: unique_violation (migration record already exists)
+ const schemaExistsErrors = ['42P07', '42701', '42710', '23505'];
+ const isSQLiteAlreadyExists = error.code === 'SQLITE_ERROR' && error.message.includes('already exists');
+
+ if (schemaExistsErrors.includes(error.code) || isSQLiteAlreadyExists) {
console.log(`Migration ${filepath} - schema already exists, marking as applied`);
await markMigrationAsApplied(path.basename(filepath));
} else {
diff --git a/backend/migrations/run-migrations.js b/backend/migrations/run-migrations.js
index 3ab7f7b1..86c9114f 100644
--- a/backend/migrations/run-migrations.js
+++ b/backend/migrations/run-migrations.js
@@ -26,11 +26,22 @@ async function runMigration(filepath) {
const migrationPath = path.join(__dirname, filepath);
const migration = require(migrationPath);
const filename = path.basename(filepath);
-
+
if (migration.up) {
console.log(`Running migration: ${filepath}`);
- await migration.up(db);
- await db('migrations').insert({ filename });
+
+ // Run migration in a transaction if PostgreSQL to ensure atomicity
+ // between schema changes and migration tracking
+ if (db.client.config.client === 'pg') {
+ await db.transaction(async (trx) => {
+ await migration.up(trx);
+ await trx('migrations').insert({ filename });
+ });
+ } else {
+ await migration.up(db);
+ await db('migrations').insert({ filename });
+ }
+
console.log(`Migration ${filepath} completed`);
}
}
diff --git a/backend/package.json b/backend/package.json
index 73734b0e..12fb6cd1 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -8,6 +8,7 @@
"dev": "nodemon server.js",
"migrate": "node migrations/run-migrations.js",
"migrate:safe": "node migrations/run-migrations-safe.js",
+ "generate:watermarks": "node scripts/generate-watermarks.js",
"test": "jest",
"lint": "eslint src/"
},
diff --git a/backend/scripts/generate-watermarks.js b/backend/scripts/generate-watermarks.js
new file mode 100644
index 00000000..af2ee138
--- /dev/null
+++ b/backend/scripts/generate-watermarks.js
@@ -0,0 +1,161 @@
+#!/usr/bin/env node
+
+/**
+ * Script to generate pre-watermarked versions for existing photos
+ * This is a one-time migration script to populate watermarks for photos
+ * that existed before the pre-generation feature was implemented.
+ *
+ * Usage: node scripts/generate-watermarks.js [eventId]
+ *
+ * Options:
+ * eventId - Optional: Only generate watermarks for a specific event
+ *
+ * Examples:
+ * node scripts/generate-watermarks.js # Generate for all photos
+ * node scripts/generate-watermarks.js 5 # Generate for event ID 5
+ */
+
+const path = require('path');
+const { db } = require('../src/database/db');
+const watermarkService = require('../src/services/watermarkService');
+const watermarkGeneratorService = require('../src/services/watermarkGeneratorService');
+
+async function generateWatermarks(eventId = null) {
+ try {
+ console.log('='.repeat(60));
+ console.log('PicPeak Watermark Generation Script');
+ console.log('='.repeat(60));
+
+ // Check if watermarking is enabled
+ const settings = await watermarkService.getWatermarkSettings();
+
+ if (!settings || !settings.enabled) {
+ console.log('\nWatermarking is currently DISABLED in settings.');
+ console.log('Enable watermarking in Admin > Branding settings first.');
+ console.log('Exiting without generating watermarks.');
+ process.exit(0);
+ }
+
+ console.log('\nWatermark Settings:');
+ console.log(` Enabled: ${settings.enabled}`);
+ console.log(` Position: ${settings.position}`);
+ console.log(` Opacity: ${settings.opacity}%`);
+ console.log(` Size: ${settings.size}%`);
+ console.log(` Logo: ${settings.logoPath || '(using text fallback)'}`);
+
+ // Build query
+ let query = db('photos')
+ .join('events', 'photos.event_id', 'events.id')
+ .whereNull('photos.watermark_path')
+ .whereNot(function() {
+ this.where('photos.media_type', 'video')
+ .orWhere('photos.mime_type', 'like', 'video/%');
+ })
+ .select(
+ 'photos.id',
+ 'photos.filename',
+ 'photos.event_id',
+ 'events.event_name'
+ );
+
+ if (eventId) {
+ query = query.where('photos.event_id', eventId);
+ console.log(`\nFiltering to event ID: ${eventId}`);
+ }
+
+ const photos = await query;
+
+ if (photos.length === 0) {
+ console.log('\nNo photos found without watermarks.');
+ if (eventId) {
+ console.log(`(Checked event ID: ${eventId})`);
+ }
+ console.log('All photos already have pre-generated watermarks or watermarking is disabled.');
+ process.exit(0);
+ }
+
+ console.log(`\nFound ${photos.length} photos without watermarks.`);
+
+ // Group by event for display
+ const eventCounts = {};
+ photos.forEach(p => {
+ eventCounts[p.event_name] = (eventCounts[p.event_name] || 0) + 1;
+ });
+
+ console.log('\nPhotos by event:');
+ Object.entries(eventCounts).forEach(([name, count]) => {
+ console.log(` ${name}: ${count} photos`);
+ });
+
+ console.log('\nStarting watermark generation...\n');
+
+ let successCount = 0;
+ let failCount = 0;
+ const startTime = Date.now();
+
+ // Process photos with progress display
+ for (let i = 0; i < photos.length; i++) {
+ const photo = photos[i];
+ const progress = Math.round(((i + 1) / photos.length) * 100);
+
+ process.stdout.write(`\r[${progress}%] Processing photo ${i + 1}/${photos.length}: ${photo.filename.substring(0, 30)}...`);
+
+ try {
+ const result = await watermarkGeneratorService.generateForPhoto(photo.id);
+
+ if (result.success) {
+ successCount++;
+ } else {
+ failCount++;
+ console.log(`\n Failed: ${photo.filename} - ${result.error}`);
+ }
+ } catch (error) {
+ failCount++;
+ console.log(`\n Error: ${photo.filename} - ${error.message}`);
+ }
+ }
+
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
+
+ console.log('\n');
+ console.log('='.repeat(60));
+ console.log('Watermark Generation Complete');
+ console.log('='.repeat(60));
+ console.log(` Total processed: ${photos.length}`);
+ console.log(` Successful: ${successCount}`);
+ console.log(` Failed: ${failCount}`);
+ console.log(` Duration: ${duration} seconds`);
+ console.log(` Average: ${(photos.length / parseFloat(duration)).toFixed(1)} photos/second`);
+
+ if (failCount > 0) {
+ console.log('\nSome watermarks failed to generate. Check the errors above.');
+ console.log('You can re-run this script to retry failed photos.');
+ }
+
+ process.exit(failCount > 0 ? 1 : 0);
+ } catch (error) {
+ console.error('\nFatal error:', error.message);
+ console.error(error.stack);
+ process.exit(1);
+ }
+}
+
+// Parse command line arguments
+const args = process.argv.slice(2);
+const eventId = args[0] ? parseInt(args[0], 10) : null;
+
+if (args[0] && isNaN(eventId)) {
+ console.error('Error: eventId must be a number');
+ console.log('Usage: node scripts/generate-watermarks.js [eventId]');
+ process.exit(1);
+}
+
+// Run the script
+generateWatermarks(eventId)
+ .then(() => {
+ process.exit(0);
+ })
+ .catch(error => {
+ console.error('Unhandled error:', error);
+ process.exit(1);
+ });
diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js
index 8f13e84b..ff65a6fc 100644
--- a/backend/src/routes/adminPhotos.js
+++ b/backend/src/routes/adminPhotos.js
@@ -12,6 +12,7 @@ const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
const { processUploadedPhotos } = require('../services/photoProcessor');
const chunkedUpload = require('../services/chunkedUploadService');
+const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const router = express.Router();
// Get storage path from environment or default
@@ -308,7 +309,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
let thumbnailPath = null;
try {
thumbnailPath = await generateThumbnail(operation.finalPath);
-
+
// Update the database with thumbnail path
if (thumbnailPath && insertedIds[idx]) {
const photoId = insertedIds[idx]?.id || insertedIds[idx];
@@ -319,6 +320,14 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
} catch (thumbError) {
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
}
+
+ // Queue watermark generation in background (non-blocking)
+ // This pre-generates watermarked versions for fast serving in lightbox
+ if (insertedIds[idx]) {
+ const photoId = insertedIds[idx]?.id || insertedIds[idx];
+ watermarkGeneratorService.generateForPhoto(photoId)
+ .catch(err => console.warn(`Watermark generation queued failed for photo ${photoId}:`, err.message));
+ }
// Add to successful uploads
uploadedPhotos.push({
@@ -461,7 +470,12 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
}
}
}
-
+
+ // Delete pre-generated watermark if exists
+ if (photo.watermark_path) {
+ await watermarkGeneratorService.deleteForPhoto(photo.id);
+ }
+
// Remove from database
await db('photos').where({ id: photoId }).delete();
@@ -582,8 +596,13 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
}
}
}
+
+ // Delete pre-generated watermark
+ if (photo.watermark_path) {
+ await watermarkGeneratorService.deleteForPhoto(photo.id);
+ }
}
-
+
// Delete from database
await db('photos')
.whereIn('id', photoIds)
diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js
index b92c5ec7..64896bbf 100644
--- a/backend/src/routes/adminSettings.js
+++ b/backend/src/routes/adminSettings.js
@@ -23,6 +23,8 @@ const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
const { resetSecurityConfigCache } = require('../utils/authSecurity');
const router = express.Router();
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
+const watermarkService = require('../services/watermarkService');
+const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -196,6 +198,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
hide_powered_by
} = req.body;
+ // Get current watermark settings hash for change detection
+ const oldSettingsHash = await watermarkService.getSettingsHash();
+
const brandingSettings = {
company_name,
company_tagline,
@@ -306,7 +311,40 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
clearPublicSiteCache();
- res.json({ message: 'Branding settings updated successfully' });
+ // Check if watermark settings changed and trigger regeneration
+ const newSettingsHash = await watermarkService.getSettingsHash();
+ let watermarkRegenerationStarted = false;
+
+ if (oldSettingsHash !== newSettingsHash) {
+ // Clear watermark cache
+ watermarkService.clearCache();
+
+ // Check if watermarking is now enabled or settings changed
+ const currentSettings = await watermarkService.getWatermarkSettings();
+
+ if (currentSettings && currentSettings.enabled) {
+ // Start background regeneration of all watermarks
+ console.log('Watermark settings changed, starting background regeneration');
+ watermarkGeneratorService.regenerateAll()
+ .then(result => {
+ console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
+ })
+ .catch(err => {
+ console.error('Watermark regeneration failed:', err);
+ });
+ watermarkRegenerationStarted = true;
+ } else {
+ // Watermarking was disabled, clear all pre-generated watermarks
+ console.log('Watermarking disabled, clearing pre-generated watermarks');
+ watermarkGeneratorService.clearAllWatermarks()
+ .catch(err => console.error('Failed to clear watermarks:', err));
+ }
+ }
+
+ res.json({
+ message: 'Branding settings updated successfully',
+ watermarkRegenerationStarted
+ });
} catch (error) {
console.error('Branding update error:', error);
res.status(500).json({ error: 'Failed to update branding settings' });
@@ -441,9 +479,27 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
updated_at: new Date()
});
- res.json({
+ // Trigger watermark regeneration since the logo changed
+ watermarkService.clearCache();
+ const currentSettings = await watermarkService.getWatermarkSettings();
+ let watermarkRegenerationStarted = false;
+
+ if (currentSettings && currentSettings.enabled) {
+ console.log('Watermark logo changed, starting background regeneration');
+ watermarkGeneratorService.regenerateAll()
+ .then(result => {
+ console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
+ })
+ .catch(err => {
+ console.error('Watermark regeneration failed:', err);
+ });
+ watermarkRegenerationStarted = true;
+ }
+
+ res.json({
message: 'Watermark logo uploaded successfully',
- watermarkLogoUrl: publicPath
+ watermarkLogoUrl: publicPath,
+ watermarkRegenerationStarted
});
} catch (error) {
console.error('Watermark logo upload error:', error);
diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js
index 4b5b7a40..e75572ad 100644
--- a/backend/src/routes/gallery.js
+++ b/backend/src/routes/gallery.js
@@ -5,6 +5,7 @@ const archiver = require('archiver');
const path = require('path');
const router = express.Router();
const watermarkService = require('../services/watermarkService');
+const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
@@ -172,7 +173,13 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
try {
// Get filter parameters from query
const { filter, guest_id } = req.query;
-
+
+ // Get watermark settings to generate cache-busting version for URLs
+ const watermarkSettings = await watermarkService.getWatermarkSettings();
+ const wmVersion = watermarkSettings?.enabled
+ ? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
+ : '';
+
// First get all photos
let photos = await db('photos')
.where('photos.event_id', req.event.id)
@@ -327,15 +334,17 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
categories: categories,
photos: photos.map(photo => {
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
- const photoUrl = useJwtUrl ?
- `/api/gallery/${req.params.slug}/photo/${photo.id}` :
+ // Add watermark version to URLs for cache busting when settings change
+ const wmQuery = wmVersion ? `?${wmVersion}` : '';
+ const photoUrl = useJwtUrl ?
+ `/api/gallery/${req.params.slug}/photo/${photo.id}${wmQuery}` :
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
-
+
return {
id: photo.id,
filename: photo.filename,
url: photoUrl,
- thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
+ thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null,
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type,
@@ -754,13 +763,54 @@ router.get('/:slug/photo/:photoId',
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
+ // Generate ETag based on photo id, modification time, and watermark settings
+ // This ensures cache invalidation when watermark settings change
+ const fs = require('fs');
+ const stat = fs.statSync(filePath);
+ const watermarkHash = watermarkSettings?.enabled
+ ? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
+ : '-nowm';
+ const etag = `"${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
+
+ // Check if client has valid cached version
+ if (req.headers['if-none-match'] === etag) {
+ return res.status(304).end();
+ }
+
if (watermarkSettings && watermarkSettings.enabled) {
- // Apply watermark and send
+ // Try to serve pre-generated watermarked file for instant loading
+ if (photo.watermark_path) {
+ const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
+ try {
+ const fs = require('fs');
+ // Check if pre-generated watermark file exists
+ if (fs.existsSync(watermarkFilePath)) {
+ res.set({
+ 'Content-Type': photo.mime_type || 'image/jpeg',
+ 'Cache-Control': 'private, max-age=1800',
+ 'ETag': etag,
+ 'X-Protection-Level': 'basic'
+ });
+ return res.sendFile(watermarkFilePath);
+ }
+ } catch (err) {
+ // File doesn't exist or error, fall through to on-the-fly generation
+ logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`);
+ }
+ }
+
+ // Fallback: Apply watermark on-the-fly (slower, but ensures image is served)
+ // Also queue regeneration for next time
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
+ // Queue watermark generation in background for next request
+ watermarkGeneratorService.generateForPhoto(photo.id)
+ .catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message));
+
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
+ 'ETag': etag,
'X-Protection-Level': 'basic'
});
@@ -769,6 +819,7 @@ router.get('/:slug/photo/:photoId',
// Send original file with basic protection headers
res.set({
'Cache-Control': 'private, max-age=1800',
+ 'ETag': etag,
'X-Protection-Level': 'basic'
});
// Ensure absolute path for res.sendFile
@@ -820,17 +871,40 @@ router.get('/:slug/thumbnail/:photoId',
'thumbnail'
);
+ // Check if watermarks are enabled and apply to thumbnail
+ const watermarkSettings = await watermarkService.getWatermarkSettings();
+
+ // Generate ETag based on photo id, thumbnail modification time, and watermark settings
+ const fs = require('fs');
+ const stat = fs.statSync(thumbPath);
+ const watermarkHash = watermarkSettings?.enabled
+ ? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
+ : '-nowm';
+ const etag = `"thumb-${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
+
+ // Check if client has valid cached version
+ if (req.headers['if-none-match'] === etag) {
+ return res.status(304).end();
+ }
+
// Set appropriate headers with enhanced security
res.set({
'Content-Type': 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Reduced cache time
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
- 'X-Protected-Thumbnail': 'true'
+ 'X-Protected-Thumbnail': 'true',
+ 'ETag': etag
});
- // Send file
- res.sendFile(path.resolve(thumbPath));
+ if (watermarkSettings && watermarkSettings.enabled) {
+ // Apply watermark to thumbnail
+ const watermarkedBuffer = await watermarkService.applyWatermark(thumbPath, watermarkSettings);
+ res.send(watermarkedBuffer);
+ } else {
+ // Send file without watermark
+ res.sendFile(path.resolve(thumbPath));
+ }
} catch (error) {
logger.error('Error serving thumbnail:', {
error: error.message,
diff --git a/backend/src/services/watermarkGeneratorService.js b/backend/src/services/watermarkGeneratorService.js
new file mode 100644
index 00000000..1dde9ec1
--- /dev/null
+++ b/backend/src/services/watermarkGeneratorService.js
@@ -0,0 +1,433 @@
+/**
+ * WatermarkGeneratorService
+ *
+ * Handles batch generation of pre-watermarked images for fast serving.
+ * This service is responsible for:
+ * - Generating watermarks for newly uploaded photos
+ * - Regenerating all watermarks when settings change
+ * - Tracking regeneration progress
+ */
+
+const path = require('path');
+const { db } = require('../database/db');
+const watermarkService = require('./watermarkService');
+const { getStoragePath } = require('../config/storage');
+
+class WatermarkGeneratorService {
+ constructor() {
+ // Track active regeneration jobs
+ this.activeJobs = new Map();
+ // Batch size for processing (to manage memory)
+ this.batchSize = 10;
+ // Concurrent processing limit
+ this.concurrentLimit = 2;
+ }
+
+ /**
+ * Generate watermark for a single photo
+ * @param {number} photoId - The photo ID
+ * @returns {Object} Result with success status and watermark path
+ */
+ async generateForPhoto(photoId) {
+ try {
+ // Get photo with event info
+ const photo = await db('photos')
+ .join('events', 'photos.event_id', 'events.id')
+ .where('photos.id', photoId)
+ .select(
+ 'photos.*',
+ 'events.slug',
+ 'events.source_mode',
+ 'events.external_path'
+ )
+ .first();
+
+ if (!photo) {
+ return { success: false, error: 'Photo not found' };
+ }
+
+ // Skip video files
+ if (photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'))) {
+ return { success: false, error: 'Videos do not support watermarks' };
+ }
+
+ // Get watermark settings
+ const settings = await watermarkService.getWatermarkSettings();
+ if (!settings || !settings.enabled) {
+ return { success: false, error: 'Watermarking is disabled' };
+ }
+
+ // Resolve the original file path
+ const originalPath = this.resolvePhotoPath(photo);
+ if (!originalPath) {
+ return { success: false, error: 'Could not resolve photo path' };
+ }
+
+ // Generate and save watermark
+ const result = await watermarkService.generateAndSaveWatermark(photo, originalPath, settings);
+
+ if (result.success) {
+ // Update database with watermark path
+ await db('photos')
+ .where({ id: photoId })
+ .update({
+ watermark_path: result.watermarkPath,
+ watermark_generated_at: db.fn.now()
+ });
+ }
+
+ return result;
+ } catch (error) {
+ console.error(`Error generating watermark for photo ${photoId}:`, error);
+ return { success: false, error: error.message };
+ }
+ }
+
+ /**
+ * Resolve the full file path for a photo
+ */
+ resolvePhotoPath(photo) {
+ const storagePath = getStoragePath();
+
+ // Handle external/reference mode
+ if (photo.source_mode === 'reference' && photo.external_relpath) {
+ const externalRoot = process.env.EXTERNAL_MEDIA_PATH || path.join(storagePath, 'external');
+ return path.join(externalRoot, photo.external_path || '', photo.external_relpath);
+ }
+
+ // Standard managed mode
+ if (photo.file_path) {
+ // file_path might be absolute or relative
+ if (path.isAbsolute(photo.file_path)) {
+ return photo.file_path;
+ }
+ return path.join(storagePath, photo.file_path);
+ }
+
+ // Fallback to constructing path from slug and filename
+ return path.join(storagePath, 'events', 'active', photo.slug, photo.filename);
+ }
+
+ /**
+ * Generate watermarks for all photos in an event
+ * @param {number} eventId - The event ID
+ * @param {Function} onProgress - Optional callback for progress updates
+ * @returns {Object} Result with success count and errors
+ */
+ async generateForEvent(eventId, onProgress = null) {
+ const results = { total: 0, success: 0, failed: 0, errors: [] };
+
+ try {
+ // Get all photos for the event (excluding videos)
+ const photos = await db('photos')
+ .join('events', 'photos.event_id', 'events.id')
+ .where('photos.event_id', eventId)
+ .whereNot(function() {
+ this.where('photos.media_type', 'video')
+ .orWhere('photos.mime_type', 'like', 'video/%');
+ })
+ .select(
+ 'photos.*',
+ 'events.slug',
+ 'events.source_mode',
+ 'events.external_path'
+ );
+
+ results.total = photos.length;
+
+ if (photos.length === 0) {
+ return results;
+ }
+
+ // Get watermark settings once
+ const settings = await watermarkService.getWatermarkSettings();
+ if (!settings || !settings.enabled) {
+ return { ...results, errors: ['Watermarking is disabled'] };
+ }
+
+ // Process in batches
+ for (let i = 0; i < photos.length; i += this.batchSize) {
+ const batch = photos.slice(i, i + this.batchSize);
+
+ // Process batch with limited concurrency
+ const batchResults = await Promise.all(
+ batch.map(photo => this.processPhotoWatermark(photo, settings))
+ );
+
+ // Collect results
+ for (const result of batchResults) {
+ if (result.success) {
+ results.success++;
+ } else {
+ results.failed++;
+ if (result.error) {
+ results.errors.push(`Photo ${result.photoId}: ${result.error}`);
+ }
+ }
+ }
+
+ // Progress callback
+ if (onProgress) {
+ onProgress({
+ total: results.total,
+ processed: results.success + results.failed,
+ success: results.success,
+ failed: results.failed
+ });
+ }
+ }
+
+ return results;
+ } catch (error) {
+ console.error(`Error generating watermarks for event ${eventId}:`, error);
+ return { ...results, errors: [...results.errors, error.message] };
+ }
+ }
+
+ /**
+ * Process watermark for a single photo (internal helper)
+ */
+ async processPhotoWatermark(photo, settings) {
+ try {
+ const originalPath = this.resolvePhotoPath(photo);
+ if (!originalPath) {
+ return { success: false, photoId: photo.id, error: 'Could not resolve path' };
+ }
+
+ const result = await watermarkService.generateAndSaveWatermark(photo, originalPath, settings);
+
+ if (result.success) {
+ await db('photos')
+ .where({ id: photo.id })
+ .update({
+ watermark_path: result.watermarkPath,
+ watermark_generated_at: db.fn.now()
+ });
+ }
+
+ return { ...result, photoId: photo.id };
+ } catch (error) {
+ return { success: false, photoId: photo.id, error: error.message };
+ }
+ }
+
+ /**
+ * Regenerate watermarks for all photos in the system
+ * @param {Function} onProgress - Optional callback for progress updates
+ * @returns {Object} Result with success count and errors
+ */
+ async regenerateAll(onProgress = null) {
+ const jobId = Date.now().toString();
+ const results = { jobId, total: 0, success: 0, failed: 0, errors: [], status: 'running' };
+
+ try {
+ this.activeJobs.set(jobId, results);
+
+ // Get watermark settings
+ const settings = await watermarkService.getWatermarkSettings();
+ if (!settings || !settings.enabled) {
+ results.status = 'completed';
+ results.errors.push('Watermarking is disabled');
+ return results;
+ }
+
+ // First, clear existing watermarks from DB (the files will be overwritten)
+ // This ensures stale paths don't persist if regeneration fails
+
+ // Get all image photos (exclude videos)
+ const photos = await db('photos')
+ .join('events', 'photos.event_id', 'events.id')
+ .whereNot(function() {
+ this.where('photos.media_type', 'video')
+ .orWhere('photos.mime_type', 'like', 'video/%');
+ })
+ .select(
+ 'photos.*',
+ 'events.slug',
+ 'events.source_mode',
+ 'events.external_path'
+ );
+
+ results.total = photos.length;
+
+ if (photos.length === 0) {
+ results.status = 'completed';
+ return results;
+ }
+
+ console.log(`Starting watermark regeneration for ${photos.length} photos`);
+
+ // Process in batches
+ for (let i = 0; i < photos.length; i += this.batchSize) {
+ // Check if job was cancelled
+ if (!this.activeJobs.has(jobId)) {
+ results.status = 'cancelled';
+ return results;
+ }
+
+ const batch = photos.slice(i, i + this.batchSize);
+
+ // Process batch with limited concurrency
+ const batchResults = await Promise.all(
+ batch.map(photo => this.processPhotoWatermark(photo, settings))
+ );
+
+ // Collect results
+ for (const result of batchResults) {
+ if (result.success) {
+ results.success++;
+ } else {
+ results.failed++;
+ if (result.error && results.errors.length < 50) {
+ results.errors.push(`Photo ${result.photoId}: ${result.error}`);
+ }
+ }
+ }
+
+ // Update job status
+ this.activeJobs.set(jobId, { ...results });
+
+ // Progress callback
+ if (onProgress) {
+ onProgress({
+ jobId,
+ total: results.total,
+ processed: results.success + results.failed,
+ success: results.success,
+ failed: results.failed,
+ percentComplete: Math.round(((results.success + results.failed) / results.total) * 100)
+ });
+ }
+
+ // Small delay between batches to prevent CPU saturation
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+
+ results.status = 'completed';
+ console.log(`Watermark regeneration completed: ${results.success}/${results.total} successful`);
+
+ return results;
+ } catch (error) {
+ console.error('Error during watermark regeneration:', error);
+ results.status = 'failed';
+ results.errors.push(error.message);
+ return results;
+ } finally {
+ // Clean up job tracking after a delay
+ setTimeout(() => {
+ this.activeJobs.delete(jobId);
+ }, 60000); // Keep for 1 minute for status queries
+ }
+ }
+
+ /**
+ * Clear all watermarks (when watermarking is disabled)
+ */
+ async clearAllWatermarks() {
+ try {
+ // Get all photos with watermarks
+ const photos = await db('photos')
+ .whereNotNull('watermark_path')
+ .select('id', 'watermark_path');
+
+ // Delete watermark files
+ for (const photo of photos) {
+ await watermarkService.deleteWatermarkFile(photo.watermark_path);
+ }
+
+ // Clear database paths
+ await db('photos')
+ .whereNotNull('watermark_path')
+ .update({
+ watermark_path: null,
+ watermark_generated_at: null
+ });
+
+ console.log(`Cleared ${photos.length} watermarks`);
+ return { success: true, cleared: photos.length };
+ } catch (error) {
+ console.error('Error clearing watermarks:', error);
+ return { success: false, error: error.message };
+ }
+ }
+
+ /**
+ * Delete watermark for a specific photo
+ */
+ async deleteForPhoto(photoId) {
+ try {
+ const photo = await db('photos')
+ .where({ id: photoId })
+ .select('watermark_path')
+ .first();
+
+ if (photo && photo.watermark_path) {
+ await watermarkService.deleteWatermarkFile(photo.watermark_path);
+ await db('photos')
+ .where({ id: photoId })
+ .update({
+ watermark_path: null,
+ watermark_generated_at: null
+ });
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error(`Error deleting watermark for photo ${photoId}:`, error);
+ return { success: false, error: error.message };
+ }
+ }
+
+ /**
+ * Get status of an active regeneration job
+ */
+ getJobStatus(jobId) {
+ return this.activeJobs.get(jobId) || null;
+ }
+
+ /**
+ * Cancel an active regeneration job
+ */
+ cancelJob(jobId) {
+ if (this.activeJobs.has(jobId)) {
+ this.activeJobs.delete(jobId);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Check if there's an active regeneration job
+ */
+ hasActiveJob() {
+ for (const [, job] of this.activeJobs) {
+ if (job.status === 'running') {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get count of photos needing watermark generation
+ */
+ async getPendingCount() {
+ const settings = await watermarkService.getWatermarkSettings();
+ if (!settings || !settings.enabled) {
+ return 0;
+ }
+
+ const result = await db('photos')
+ .whereNull('watermark_path')
+ .whereNot(function() {
+ this.where('media_type', 'video')
+ .orWhere('mime_type', 'like', 'video/%');
+ })
+ .count('id as count')
+ .first();
+
+ return parseInt(result.count) || 0;
+ }
+}
+
+module.exports = new WatermarkGeneratorService();
diff --git a/backend/src/services/watermarkService.js b/backend/src/services/watermarkService.js
index 2ef44a6c..37d08172 100644
--- a/backend/src/services/watermarkService.js
+++ b/backend/src/services/watermarkService.js
@@ -2,6 +2,7 @@ const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
+const { getStoragePath } = require('../config/storage');
class WatermarkService {
constructor() {
@@ -232,6 +233,125 @@ class WatermarkService {
clearCache() {
this.cache.clear();
}
+
+ /**
+ * Get the watermarks directory path, creating it if needed
+ */
+ async getWatermarksDir() {
+ const watermarksDir = path.join(getStoragePath(), 'watermarks');
+ try {
+ await fs.access(watermarksDir);
+ } catch {
+ await fs.mkdir(watermarksDir, { recursive: true });
+ }
+ return watermarksDir;
+ }
+
+ /**
+ * Get the file extension from a filename
+ */
+ getFileExtension(filename) {
+ const ext = path.extname(filename).toLowerCase();
+ // Map common extensions
+ if (ext === '.jpeg') return '.jpg';
+ return ext || '.jpg';
+ }
+
+ /**
+ * Generate watermarked version of a photo and save to disk
+ * @param {Object} photo - Photo object with id, filename, and path info
+ * @param {string} originalPath - Full path to the original image file
+ * @param {Object} settings - Watermark settings (optional, will fetch if not provided)
+ * @returns {Object} { success, watermarkPath, error }
+ */
+ async generateAndSaveWatermark(photo, originalPath, settings = null) {
+ try {
+ // Get settings if not provided
+ if (!settings) {
+ settings = await this.getWatermarkSettings();
+ }
+
+ // If watermarking is disabled, return early
+ if (!settings || !settings.enabled) {
+ return { success: false, watermarkPath: null, error: 'Watermarking is disabled' };
+ }
+
+ // Verify original file exists
+ try {
+ await fs.access(originalPath);
+ } catch {
+ return { success: false, watermarkPath: null, error: 'Original file not found' };
+ }
+
+ // Generate watermarked buffer using existing method
+ const watermarkedBuffer = await this.applyWatermark(originalPath, settings);
+
+ // Determine output path
+ const watermarksDir = await this.getWatermarksDir();
+ const ext = this.getFileExtension(photo.filename);
+ const outputFilename = `${photo.id}_watermarked${ext}`;
+ const outputPath = path.join(watermarksDir, outputFilename);
+
+ // Write the watermarked image to disk
+ await fs.writeFile(outputPath, watermarkedBuffer);
+
+ // Return relative path for database storage
+ const relativePath = `watermarks/${outputFilename}`;
+
+ return {
+ success: true,
+ watermarkPath: relativePath,
+ error: null
+ };
+ } catch (error) {
+ console.error(`Error generating watermark for photo ${photo.id}:`, error);
+ return {
+ success: false,
+ watermarkPath: null,
+ error: error.message
+ };
+ }
+ }
+
+ /**
+ * Delete a pre-generated watermark file
+ * @param {string} watermarkPath - Relative path to the watermark file
+ * @returns {boolean} - True if deleted successfully
+ */
+ async deleteWatermarkFile(watermarkPath) {
+ if (!watermarkPath) return false;
+
+ try {
+ const fullPath = path.join(getStoragePath(), watermarkPath);
+ await fs.unlink(fullPath);
+ return true;
+ } catch (error) {
+ // File might not exist, which is fine
+ if (error.code !== 'ENOENT') {
+ console.error('Error deleting watermark file:', error);
+ }
+ return false;
+ }
+ }
+
+ /**
+ * Create a hash of current watermark settings for change detection
+ * @returns {string} - Hash string of settings
+ */
+ async getSettingsHash() {
+ const settings = await this.getWatermarkSettings();
+ if (!settings) return '';
+
+ const hashData = `${settings.enabled}-${settings.logoPath || ''}-${settings.position}-${settings.opacity}-${settings.size}`;
+ // Simple hash for change detection (not cryptographic)
+ let hash = 0;
+ for (let i = 0; i < hashData.length; i++) {
+ const char = hashData.charCodeAt(i);
+ hash = ((hash << 5) - hash) + char;
+ hash = hash & hash; // Convert to 32bit integer
+ }
+ return hash.toString(16);
+ }
}
module.exports = new WatermarkService();
\ No newline at end of file
diff --git a/frontend/src/components/common/AuthenticatedImage.tsx b/frontend/src/components/common/AuthenticatedImage.tsx
index 66d52748..71ad2c2f 100644
--- a/frontend/src/components/common/AuthenticatedImage.tsx
+++ b/frontend/src/components/common/AuthenticatedImage.tsx
@@ -7,7 +7,7 @@ import {
resolveSlugFromRequestUrl,
} from '../../utils/galleryAuthStorage';
-interface AuthenticatedImageProps extends React.ImgHTMLAttributes;
+ return
;
};
diff --git a/frontend/src/components/common/DynamicFavicon.tsx b/frontend/src/components/common/DynamicFavicon.tsx
index 40d90682..eff7e9ed 100644
--- a/frontend/src/components/common/DynamicFavicon.tsx
+++ b/frontend/src/components/common/DynamicFavicon.tsx
@@ -2,6 +2,8 @@ import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl, buildResourceUrl } from '../../utils/url';
+const DEFAULT_TITLE = 'PicPeak - Photo Sharing Platform';
+
export const DynamicFavicon: React.FC = () => {
const { data: settings } = useQuery({
queryKey: ['public-settings'],
@@ -19,6 +21,7 @@ export const DynamicFavicon: React.FC = () => {
staleTime: 5 * 60 * 1000, // 5 minutes
});
+ // Update favicon when branding settings change
useEffect(() => {
if (settings?.branding_favicon_url) {
// Remove existing favicon links
@@ -29,13 +32,27 @@ export const DynamicFavicon: React.FC = () => {
const link = document.createElement('link');
link.rel = 'icon';
link.type = 'image/png';
- link.href = settings.branding_favicon_url.startsWith('http')
- ? settings.branding_favicon_url
+ link.href = settings.branding_favicon_url.startsWith('http')
+ ? settings.branding_favicon_url
: buildResourceUrl(settings.branding_favicon_url);
-
+
document.head.appendChild(link);
}
}, [settings?.branding_favicon_url]);
+ // Update document title when company name or tagline changes
+ useEffect(() => {
+ const companyName = settings?.branding_company_name?.trim();
+ const tagline = settings?.branding_company_tagline?.trim();
+
+ if (companyName && tagline) {
+ document.title = `${companyName} - ${tagline}`;
+ } else if (companyName) {
+ document.title = companyName;
+ } else {
+ document.title = DEFAULT_TITLE;
+ }
+ }, [settings?.branding_company_name, settings?.branding_company_tagline]);
+
return null;
};
\ No newline at end of file
diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx
index 87738879..be708d27 100644
--- a/frontend/src/components/gallery/GalleryLayout.tsx
+++ b/frontend/src/components/gallery/GalleryLayout.tsx
@@ -366,7 +366,10 @@ export const GalleryLayout: React.FC
- {brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
+ {brandingSettings?.footer_text || `© ${new Date().getFullYear()}${brandingSettings?.company_name ? ` ${brandingSettings.company_name}` : ''}. All rights reserved.`}
{!brandingSettings?.hide_powered_by && (
<> | Powered by PicPeak>
)}
diff --git a/frontend/src/components/gallery/GallerySidebar.tsx b/frontend/src/components/gallery/GallerySidebar.tsx
index a0575d27..8f9febdc 100644
--- a/frontend/src/components/gallery/GallerySidebar.tsx
+++ b/frontend/src/components/gallery/GallerySidebar.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react';
-import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Upload, Star } from 'lucide-react';
+import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Star } from 'lucide-react';
import { Button } from '../common';
import { PhotoCategory } from '../../types';
import { useTranslation } from 'react-i18next';
@@ -139,24 +139,6 @@ export const GallerySidebar: React.FC
- © 2024 PicPeak. All rights reserved.
+ © {new Date().getFullYear()} PicPeak. All rights reserved.