Merge pull request #110 from the-luap/fix/codeql-v4-upgrade

fix: database migration restart bug, lightbox loading spinner, and watermark cache invalidation
This commit is contained in:
Paul Nothaft
2026-01-15 10:24:04 -05:00
committed by GitHub
7 changed files with 152 additions and 59 deletions
@@ -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 });
}
}
}
};
@@ -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) {
+17 -6
View File
@@ -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 {
+14 -3
View File
@@ -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`);
}
}
+47 -9
View File
@@ -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);
@@ -7,7 +7,7 @@ import {
resolveSlugFromRequestUrl,
} from '../../utils/galleryAuthStorage';
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
interface AuthenticatedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'onLoad'> {
src: string;
fallbackSrc?: string;
useWatermark?: boolean;
@@ -29,6 +29,7 @@ interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageEleme
detectDevTools?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
onLoad?: () => void;
}
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
@@ -54,6 +55,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
detectDevTools,
protectionLevel,
useEnhancedProtection,
onLoad,
...props
}) => {
const unusedProps = {
@@ -221,6 +223,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
img.onload = () => {
imageRef.current = img;
drawToCanvas();
onLoad?.();
};
img.onerror = (e) => {
@@ -235,7 +238,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
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<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 { 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<PhotoLightboxProps> = ({
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(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<PhotoLightboxProps> = ({
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' ? (
<VideoPlayer
src={currentPhoto.url}
@@ -505,8 +518,10 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
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<PhotoLightboxProps> = ({
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<PhotoLightboxProps> = ({
zoom
});
}
// For maximum protection, close lightbox on violation
if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {