fix: add lightbox loading spinner and watermark cache invalidation

- Add spinning loader in lightbox while large images are loading
- Add onLoad callback to AuthenticatedImage for canvas and img modes
- Add ETag headers based on watermark settings for HTTP cache validation
- Add watermark version query param to photo/thumbnail URLs for cache busting
- Ensures images refresh when watermark settings are enabled/changed
This commit is contained in:
Paul Nothaft
2026-01-15 16:19:22 +01:00
parent 83a4344a01
commit 050ed37819
3 changed files with 71 additions and 15 deletions
+47 -9
View File
@@ -172,7 +172,13 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
try { try {
// Get filter parameters from query // Get filter parameters from query
const { filter, guest_id } = req.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 // First get all photos
let photos = await db('photos') let photos = await db('photos')
.where('photos.event_id', req.event.id) .where('photos.event_id', req.event.id)
@@ -327,15 +333,17 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
categories: categories, categories: categories,
photos: photos.map(photo => { photos: photos.map(photo => {
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard'); const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
const photoUrl = useJwtUrl ? // Add watermark version to URLs for cache busting when settings change
`/api/gallery/${req.params.slug}/photo/${photo.id}` : 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}}`; `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
return { return {
id: photo.id, id: photo.id,
filename: photo.filename, filename: photo.filename,
url: photoUrl, 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}}`, 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}}`, download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type, type: photo.type,
@@ -754,6 +762,20 @@ router.get('/:slug/photo/:photoId',
// Get watermark settings // Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings(); 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) { if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark and send // Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
@@ -761,6 +783,7 @@ router.get('/:slug/photo/:photoId',
res.set({ res.set({
'Content-Type': photo.mime_type || 'image/jpeg', 'Content-Type': photo.mime_type || 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes 'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
'ETag': etag,
'X-Protection-Level': 'basic' 'X-Protection-Level': 'basic'
}); });
@@ -769,6 +792,7 @@ router.get('/:slug/photo/:photoId',
// Send original file with basic protection headers // Send original file with basic protection headers
res.set({ res.set({
'Cache-Control': 'private, max-age=1800', 'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic' 'X-Protection-Level': 'basic'
}); });
// Ensure absolute path for res.sendFile // Ensure absolute path for res.sendFile
@@ -820,18 +844,32 @@ router.get('/:slug/thumbnail/:photoId',
'thumbnail' '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 // Set appropriate headers with enhanced security
res.set({ res.set({
'Content-Type': 'image/jpeg', 'Content-Type': 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Reduced cache time 'Cache-Control': 'private, max-age=1800', // Reduced cache time
'Cross-Origin-Resource-Policy': 'cross-origin', 'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff', '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) { if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark to thumbnail // Apply watermark to thumbnail
const watermarkedBuffer = await watermarkService.applyWatermark(thumbPath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(thumbPath, watermarkSettings);
@@ -7,7 +7,7 @@ import {
resolveSlugFromRequestUrl, resolveSlugFromRequestUrl,
} from '../../utils/galleryAuthStorage'; } from '../../utils/galleryAuthStorage';
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> { interface AuthenticatedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'onLoad'> {
src: string; src: string;
fallbackSrc?: string; fallbackSrc?: string;
useWatermark?: boolean; useWatermark?: boolean;
@@ -29,6 +29,7 @@ interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageEleme
detectDevTools?: boolean; detectDevTools?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean; useEnhancedProtection?: boolean;
onLoad?: () => void;
} }
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
@@ -54,6 +55,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
detectDevTools, detectDevTools,
protectionLevel, protectionLevel,
useEnhancedProtection, useEnhancedProtection,
onLoad,
...props ...props
}) => { }) => {
const unusedProps = { const unusedProps = {
@@ -221,6 +223,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
img.onload = () => { img.onload = () => {
imageRef.current = img; imageRef.current = img;
drawToCanvas(); drawToCanvas();
onLoad?.();
}; };
img.onerror = (e) => { img.onerror = (e) => {
@@ -235,7 +238,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
img.onload = null; img.onload = null;
img.onerror = null; img.onerror = null;
}; };
}, [imageSrc, useCanvasRendering, drawToCanvas]); }, [imageSrc, useCanvasRendering, drawToCanvas, onLoad]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -282,5 +285,5 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
); );
} }
return <img src={imageSrc} alt={alt} {...props} />; return <img src={imageSrc} alt={alt} onLoad={onLoad} {...props} />;
}; };
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; 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 type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery'; import { useDownloadPhoto } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common'; import { AuthenticatedImage } from '../common';
@@ -62,12 +62,18 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const [showIdentityModal, setShowIdentityModal] = useState(false); const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null); const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null);
const [imageLoaded, setImageLoaded] = useState(false);
useEffect(() => { useEffect(() => {
const onResize = () => setIsSmallScreen(window.innerWidth < 640); const onResize = () => setIsSmallScreen(window.innerWidth < 640);
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize);
}, []); }, []);
// Reset image loaded state when changing photos
useEffect(() => {
setImageLoaded(false);
}, [currentIndex]);
const downloadPhotoMutation = useDownloadPhoto(); const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex]; const currentPhoto = photos[currentIndex];
@@ -488,6 +494,13 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0, right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
}} }}
> >
{/* Loading spinner */}
{!imageLoaded && currentPhoto.media_type !== 'video' && (
<div className="absolute inset-0 flex items-center justify-center z-10">
<Loader2 className="w-12 h-12 text-white animate-spin" />
</div>
)}
{currentPhoto.media_type === 'video' ? ( {currentPhoto.media_type === 'video' ? (
<VideoPlayer <VideoPlayer
src={currentPhoto.url} src={currentPhoto.url}
@@ -505,8 +518,10 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
style={{ style={{
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`, transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
transition: isDragging ? 'none' : 'transform 0.2s', transition: isDragging ? 'none' : 'transform 0.2s',
opacity: imageLoaded ? 1 : 0,
}} }}
draggable={false} draggable={false}
onLoad={() => setImageLoaded(true)}
useWatermark={useEnhancedProtection} useWatermark={useEnhancedProtection}
watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined} watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined}
isGallery={true} isGallery={true}
@@ -524,7 +539,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'} detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
onProtectionViolation={(violationType) => { onProtectionViolation={(violationType) => {
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`); console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`);
// Track analytics // Track analytics
if (typeof window !== 'undefined' && (window as any).umami) { if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('lightbox_protection_violation', { (window as any).umami.track('lightbox_protection_violation', {
@@ -534,7 +549,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
zoom zoom
}); });
} }
// For maximum protection, close lightbox on violation // For maximum protection, close lightbox on violation
if (protectionLevel === 'maximum' && if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) { ['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {