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/src/routes/gallery.js b/backend/src/routes/gallery.js index 1464fca3..2b5a6db6 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -172,7 +172,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 +333,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,6 +762,20 @@ 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 const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); @@ -761,6 +783,7 @@ router.get('/:slug/photo/:photoId', 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 +792,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,18 +844,32 @@ 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 }); - // Check if watermarks are enabled and apply to thumbnail - const watermarkSettings = await watermarkService.getWatermarkSettings(); - if (watermarkSettings && watermarkSettings.enabled) { // Apply watermark to thumbnail const watermarkedBuffer = await watermarkService.applyWatermark(thumbPath, watermarkSettings); 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 { +interface AuthenticatedImageProps extends Omit, 'onLoad'> { src: string; fallbackSrc?: string; useWatermark?: boolean; @@ -29,6 +29,7 @@ interface AuthenticatedImageProps extends React.ImgHTMLAttributes void; } export const AuthenticatedImage: React.FC = ({ @@ -54,6 +55,7 @@ export const AuthenticatedImage: React.FC = ({ detectDevTools, protectionLevel, useEnhancedProtection, + onLoad, ...props }) => { const unusedProps = { @@ -221,6 +223,7 @@ export const AuthenticatedImage: React.FC = ({ img.onload = () => { imageRef.current = img; drawToCanvas(); + onLoad?.(); }; img.onerror = (e) => { @@ -235,7 +238,7 @@ export const AuthenticatedImage: React.FC = ({ img.onload = null; img.onerror = null; }; - }, [imageSrc, useCanvasRendering, drawToCanvas]); + }, [imageSrc, useCanvasRendering, drawToCanvas, onLoad]); if (isLoading) { return ( @@ -282,5 +285,5 @@ export const AuthenticatedImage: React.FC = ({ ); } - return {alt}; + return {alt}; }; diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index c6ceb8f6..b9c70d8f 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; -import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react'; +import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star, Loader2 } from 'lucide-react'; import type { Photo } from '../../types'; import { useDownloadPhoto } from '../../hooks/useGallery'; import { AuthenticatedImage } from '../common'; @@ -62,12 +62,18 @@ export const PhotoLightbox: React.FC = ({ const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); const [showIdentityModal, setShowIdentityModal] = useState(false); const [pendingAction, setPendingAction] = useState(null); + const [imageLoaded, setImageLoaded] = useState(false); useEffect(() => { const onResize = () => setIsSmallScreen(window.innerWidth < 640); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); + + // Reset image loaded state when changing photos + useEffect(() => { + setImageLoaded(false); + }, [currentIndex]); const downloadPhotoMutation = useDownloadPhoto(); const currentPhoto = photos[currentIndex]; @@ -488,6 +494,13 @@ export const PhotoLightbox: React.FC = ({ right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0, }} > + {/* Loading spinner */} + {!imageLoaded && currentPhoto.media_type !== 'video' && ( +
+ +
+ )} + {currentPhoto.media_type === 'video' ? ( = ({ style={{ transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`, transition: isDragging ? 'none' : 'transform 0.2s', + opacity: imageLoaded ? 1 : 0, }} draggable={false} + onLoad={() => setImageLoaded(true)} useWatermark={useEnhancedProtection} watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined} isGallery={true} @@ -524,7 +539,7 @@ export const PhotoLightbox: React.FC = ({ detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'} onProtectionViolation={(violationType) => { console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`); - + // Track analytics if (typeof window !== 'undefined' && (window as any).umami) { (window as any).umami.track('lightbox_protection_violation', { @@ -534,7 +549,7 @@ export const PhotoLightbox: React.FC = ({ zoom }); } - + // For maximum protection, close lightbox on violation if (protectionLevel === 'maximum' && ['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {