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)) {