Merge pull request #496 from the-luap/feat/lightbox-preview-tier-492

feat(lightbox): medium-resolution preview tier (#492)
This commit is contained in:
Paul Nothaft
2026-05-14 22:44:35 +02:00
committed by GitHub
13 changed files with 578 additions and 8 deletions
+10
View File
@@ -649,6 +649,12 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
if (photo.hero_path) {
await storage.delete(photo.hero_path).catch(() => {});
}
// Lightbox preview tier (#492). Same disposable-derived semantics
// as thumbnail / hero — wipe on photo delete so we don't leak
// orphaned files into previews/ that no DB row references.
if (photo.preview_path) {
await storage.delete(photo.preview_path).catch(() => {});
}
// Delete pre-generated watermark if exists
if (photo.watermark_path) {
@@ -783,6 +789,10 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
if (photo.hero_path) {
await storage.delete(photo.hero_path).catch(() => {});
}
// Lightbox preview tier (#492) — bulk delete cleanup.
if (photo.preview_path) {
await storage.delete(photo.preview_path).catch(() => {});
}
if (photo.watermark_path) {
await watermarkGeneratorService.deleteForPhoto(photo.id);
}
+79 -4
View File
@@ -3,7 +3,7 @@ const router = express.Router();
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { generateThumbnail } = require('../services/imageProcessor');
const { generateThumbnail, ensurePreviewImage } = require('../services/imageProcessor');
const path = require('path');
const fs = require('fs').promises;
const logger = require('../utils/logger');
@@ -25,7 +25,9 @@ router.get('/settings', adminAuth, requirePermission('photos.view'), async (req,
'thumbnail_height',
'thumbnail_fit',
'thumbnail_quality',
'thumbnail_format'
'thumbnail_format',
// Lightbox preview tier (#492). Boolean, default false.
'lightbox_preview_enabled'
])
.select('setting_key', 'setting_value');
@@ -51,7 +53,7 @@ router.get('/settings', adminAuth, requirePermission('photos.view'), async (req,
// Update thumbnail settings
router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { width, height, fit, quality, format } = req.body;
const { width, height, fit, quality, format, lightbox_preview_enabled } = req.body;
// Validate inputs
if (width && (width < 50 || width > 1000)) {
@@ -77,14 +79,35 @@ router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req,
if (fit) updates.push({ setting_key: 'thumbnail_fit', setting_value: JSON.stringify(fit) });
if (quality) updates.push({ setting_key: 'thumbnail_quality', setting_value: quality });
if (format) updates.push({ setting_key: 'thumbnail_format', setting_value: JSON.stringify(format) });
// Lightbox preview tier (#492). Boolean — store JSON-stringified
// so the round-trip matches what migration 104 seeds.
if (typeof lightbox_preview_enabled === 'boolean') {
updates.push({
setting_key: 'lightbox_preview_enabled',
setting_value: JSON.stringify(lightbox_preview_enabled),
});
}
for (const update of updates) {
await db('app_settings')
const updated = await db('app_settings')
.where('setting_key', update.setting_key)
.update({
setting_value: update.setting_value,
updated_at: db.fn.now()
});
// Defensive insert when the row is missing — covers the case
// where lightbox_preview_enabled is being saved on an install
// that pre-dates migration 104. Existing thumbnail_* keys are
// seeded by migration 040 so the update path always wins for
// them; this only fires on the new key.
if (!updated) {
await db('app_settings').insert({
setting_key: update.setting_key,
setting_value: update.setting_value,
setting_type: update.setting_key === 'lightbox_preview_enabled' ? 'thumbnail' : 'thumbnail',
updated_at: db.fn.now(),
});
}
}
res.json({
@@ -170,6 +193,58 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
}
});
// Regenerate all preview-tier images (#492). Eager backfill counterpart
// to ensurePreviewImage's lazy generation. Mirrors the regenerate
// (thumbnails) endpoint above — same auth, same fire-and-forget shape,
// same per-photo error handling.
router.post('/regenerate-previews', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { eventId } = req.body;
let query = db('photos').select('id', 'event_id', 'path', 'media_type', 'mime_type', 'preview_path');
if (eventId) query = query.where('event_id', eventId);
// Skip videos — preview tier is image-only.
query = query.where(function() {
this.whereNull('media_type').orWhere('media_type', '!=', 'video');
});
const photos = await query;
if (photos.length === 0) {
return res.json({ message: 'No image photos to regenerate previews for', count: 0 });
}
res.json({
message: `Started regenerating ${photos.length} previews`,
count: photos.length,
});
setImmediate(async () => {
let successCount = 0;
let errorCount = 0;
for (const photo of photos) {
try {
// Force regeneration regardless of existing preview state by
// nulling the cached path so ensurePreviewImage doesn't
// short-circuit on a stale isPreviewValid check.
const newPreviewPath = await ensurePreviewImage({ ...photo, preview_path: null });
if (newPreviewPath) {
successCount++;
} else {
errorCount++;
}
} catch (error) {
logger.error(`Error regenerating preview for photo ${photo.id}:`, error);
errorCount++;
}
}
logger.info(`Preview regeneration complete: ${successCount} success, ${errorCount} errors`);
});
} catch (error) {
logger.error('Error starting preview regeneration:', error);
res.status(500).json({ error: 'Failed to start preview regeneration' });
}
});
// Get regeneration status
router.get('/regenerate/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
+132 -1
View File
@@ -13,7 +13,7 @@ const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors');
const { ensureThumbnail, ensureHeroImage, withLocalCopy } = require('../services/imageProcessor');
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
const downloadZipService = require('../services/downloadZipService');
const { getStorage } = require('../services/storage');
const fs = require('fs');
@@ -399,6 +399,31 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
fragmentation_level: req.event.fragmentation_level || 3,
overlay_protection: req.event.overlay_protection !== false
};
// Lightbox preview tier (#492). When the admin opts in, the
// photos response carries a preview_url alongside url/thumbnail_url
// — the lightbox uses preview_url when present and falls back to
// url when not, so existing galleries continue working before
// any preview has actually been generated.
let lightboxPreviewEnabled = false;
try {
const setting = await db('app_settings')
.where('setting_key', 'lightbox_preview_enabled')
.first();
if (setting) {
const raw = setting.setting_value;
// setting_value is JSON-stringified per migration 104; tolerate
// raw boolean/string for forward-compat.
const parsed = typeof raw === 'string' ? (() => {
try { return JSON.parse(raw); } catch { return raw; }
})() : raw;
lightboxPreviewEnabled = parsed === true || parsed === 'true' || parsed === 1;
}
} catch (e) {
// Setting missing / DB blip → fall back to off so the lightbox
// keeps working with the original. logger.debug to avoid noise.
logger.debug('lightbox_preview_enabled lookup failed, treating as off', { error: e?.message });
}
res.json({
@@ -445,6 +470,17 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null,
// Hero-optimized image URL (1920x1080) for full-width hero sections
hero_url: `/api/gallery/${req.params.slug}/hero/${photo.id}${wmQuery}`,
// Lightbox preview URL (#492). Only emitted when the admin
// has flipped lightbox_preview_enabled — the frontend
// lightbox reads preview_url with a fallback to url so
// installs that haven't opted in keep loading the original
// (current behaviour). Skipped for videos since they don't
// get a preview tier; lightbox will use the original .url.
preview_url: lightboxPreviewEnabled
&& photo.media_type !== 'video'
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
? `/api/gallery/${req.params.slug}/preview/${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,
@@ -1337,6 +1373,101 @@ router.get('/:slug/hero/:photoId',
}
);
// Lightbox preview tier (#492). Aspect-preserved JPEG capped at 1920px
// long edge — admin-controlled opt-in via app_settings.lightbox_preview_enabled.
// Mirrors the hero route shape: same auth, ETag from preview mtime,
// fall back to original on any failure so the lightbox never shows a
// broken image. The watermark application path is preserved so a
// preview surfaced in the lightbox carries the same protection a
// guest would see on the full original.
router.get('/:slug/preview/:photoId',
verifyGalleryAccess,
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Videos don't get a preview tier — fall through to the regular
// photo endpoint (which serves the source). The frontend should
// already be checking media_type before requesting /preview but
// belt-and-braces in case a stale tab does.
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
if (isVideo) {
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
}
// Lazy generation: ensurePreviewImage returns null on any
// failure (corrupt source, sharp OOM, storage unavailable, …).
// Fall back to the original so the lightbox always renders.
const previewPath = await ensurePreviewImage(photo);
if (!previewPath) {
logger.warn(`Failed to generate preview for photo ${photoId}, falling back to original`);
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
}
const storage = getStorage();
const stat = await storage.stat(previewPath);
if (!stat) {
logger.error('Preview file does not exist in storage backend', {
slug: req.params.slug, photoId, eventId: req.event.id, previewPath,
});
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
}
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
const watermarkSettings = await watermarkService.getWatermarkSettings();
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
const etag = `"preview-${photoId}-${mtimeMs}${watermarkHash}"`;
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set({
'Content-Type': 'image/jpeg',
// Cache aggressively — preview only changes on photo
// re-upload (which generates a new preview key) or settings
// regenerate (which writes a new mtime + ETag).
'Cache-Control': 'private, max-age=3600',
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
'X-Preview-Image': 'true',
'ETag': etag,
});
if (watermarkSettings && watermarkSettings.enabled) {
const watermarkedBuffer = await withLocalCopy(previewPath, (localPath) =>
watermarkService.applyWatermark(localPath, watermarkSettings)
);
res.send(watermarkedBuffer);
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(previewPath);
stream.pipe(res);
}
} catch (error) {
logger.error('Error serving preview image:', {
error: error.message,
photoId: req.params.photoId,
eventId: req.event?.id,
});
res.redirect(`/api/gallery/${req.params.slug}/photo/${req.params.photoId}`);
}
}
);
// Get feedback settings for gallery
router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) => {
try {
+9 -1
View File
@@ -129,7 +129,10 @@ async function archiveEvent(event) {
);
}
// Delete thumbnails for this event's photos.
// Delete derived images (thumbnails / heroes / previews / watermarks)
// for this event's photos. The originals are inside the zip; the
// derived tiers are throwaway and will be regenerated lazily on
// restore (or not at all for archived events that nobody opens).
const photos = await db('photos').where('event_id', event.id);
for (const photo of photos) {
if (photo.thumbnail_path) {
@@ -138,6 +141,11 @@ async function archiveEvent(event) {
if (photo.hero_path) {
await storage.delete(photo.hero_path).catch(() => {});
}
// Lightbox preview tier (#492). Same disposable-derived
// semantics as thumbnails / heroes — wipe on archive.
if (photo.preview_path) {
await storage.delete(photo.preview_path).catch(() => {});
}
// Best effort: remove watermarked variants too if a refactor added them.
if (photo.watermark_path) {
await storage.delete(photo.watermark_path).catch(() => {});
+10
View File
@@ -361,6 +361,16 @@ async function getFilesToBackupInternal(includeArchived = true) {
}
await scanDirectory(path.join(storagePath, 'thumbnails'), files, storagePath);
// Lightbox preview tier (#492). Cheap to back up — typically a few
// hundred KB per photo — and saves admins the regenerate cycle on
// a restore. Tolerated when missing (admins who never enabled the
// feature won't have the folder; scanDirectory short-circuits on
// ENOENT cleanly).
await scanDirectory(path.join(storagePath, 'previews'), files, storagePath);
// Heroes too — same logic; admins who picked a hero photo for the
// gallery header had its 1920x1080 file generated and was missed
// by the original backup walk before this addition.
await scanDirectory(path.join(storagePath, 'heroes'), files, storagePath);
await scanDirectory(path.join(storagePath, 'uploads'), files, storagePath);
return files;
+138
View File
@@ -30,6 +30,15 @@ const DEFAULT_HERO_WIDTH = 1920;
const DEFAULT_HERO_HEIGHT = 1080;
const DEFAULT_HERO_QUALITY = 85;
// Preview tier (#492). Aspect-preserved downscale for the lightbox so
// guests don't pay the full 512 MB original on every photo open.
// Same long edge as the hero (admins are already sizing for it) and
// quality 85 — JPEG artefacts at this size are imperceptible to clients
// browsing on phones / Retina laptops, and storage cost stays modest
// (~200500 KB per photo vs originals at multi-MB).
const DEFAULT_PREVIEW_LONG_EDGE = 1920;
const DEFAULT_PREVIEW_QUALITY = 85;
// Helper to parse setting value (handles both JSON-encoded and plain values)
function parseSettingValue(value) {
if (value === null || value === undefined) {
@@ -476,6 +485,132 @@ async function ensureHeroImage(photo) {
return null;
}
/**
* Generate a lightbox preview image (#492).
*
* Aspect-preserving downscale (`fit: 'inside'`) capped at
* DEFAULT_PREVIEW_LONG_EDGE. Distinct from generateHeroImage:
* - hero → 1920x1080 cover-cropped (gallery hero header banner)
* - preview → ≤1920px long edge, aspect preserved (lightbox tile)
*
* Output to `previews/preview_<filename>` so an admin who flips the
* setting back off can wipe the folder cleanly without touching
* thumbnails or heroes.
*/
async function generatePreviewImage(imagePath, options = {}) {
const filename = path.basename(imagePath);
const previewFilename = `preview_${filename}`;
const previewRelKey = path.posix.join('previews', previewFilename);
const storage = getStorage();
if (options.regenerate) {
await storage.delete(previewRelKey).catch(() => {});
}
try {
const metadata = await sharp(imagePath).metadata();
if (!metadata.width || !metadata.height) {
throw new Error('Invalid image metadata - file may be incomplete');
}
const longEdge = options.longEdge || DEFAULT_PREVIEW_LONG_EDGE;
const quality = options.quality || DEFAULT_PREVIEW_QUALITY;
let sharpInstance = sharp(imagePath, {
limitInputPixels: 268402689, // ~16k x 16k max
sequentialRead: true,
failOnError: false,
});
// Strip EXIF — same privacy reasoning as thumbnails/heroes.
sharpInstance = sharpInstance.withMetadata(false);
// fit: 'inside' + withoutEnlargement keeps small originals at
// their native size (no upscaling artefacts) and shrinks larger
// ones until both dimensions fit inside longEdge×longEdge.
sharpInstance = sharpInstance.resize(longEdge, longEdge, {
withoutEnlargement: true,
fit: 'inside',
});
sharpInstance = sharpInstance.jpeg({
quality,
progressive: true,
mozjpeg: true,
});
const buffer = await sharpInstance.toBuffer();
if (!buffer || buffer.length === 0) {
throw new Error('Generated preview image is empty');
}
await storage.put(previewRelKey, buffer, { contentType: 'image/jpeg' });
logger.info(`Generated preview image for ${filename}${previewRelKey}`);
return previewRelKey;
} catch (error) {
const msg = (error && error.message) ? error.message : String(error);
logger.error(`Failed to generate preview image for ${filename}: ${msg}`);
await storage.delete(previewRelKey).catch(() => {});
return null;
}
}
/**
* Validate an existing preview file is non-empty + readable by Sharp.
* Mirrors isHeroValid / isThumbnailValid.
*/
async function isPreviewValid(previewPath) {
const storage = getStorage();
try {
const stat = await storage.stat(previewPath);
if (!stat || stat.size === 0) return false;
if (storage.kind() === 'local') {
const localPath = storage.resolveLocalPath(previewPath);
await sharp(localPath).metadata();
}
return true;
} catch {
return false;
}
}
/**
* Lazy-generate the preview image for a photo if missing or invalid.
* Returns the storage key or null on failure (callers fall back to
* the original URL so the lightbox never shows a broken image).
*/
async function ensurePreviewImage(photo) {
const { resolvePhotoStorageKey } = require('./photoResolver');
let sourceKey;
try {
const event = await db('events').where('id', photo.event_id).first();
sourceKey = resolvePhotoStorageKey(event, photo);
} catch (e) {
const msg = (e && e.message) ? e.message : String(e);
logger.error(`Failed to resolve original key for preview (photo ${photo.id}): ${msg}`);
return null;
}
if (photo.preview_path) {
const ok = await isPreviewValid(photo.preview_path);
if (ok) return photo.preview_path;
logger.warn(`Invalid preview detected for photo ${photo.id}, regenerating…`);
}
const newPreviewPath = await withLocalCopy(sourceKey, (localPath) =>
generatePreviewImage(localPath, { regenerate: true })
);
if (newPreviewPath) {
await db('photos').where({ id: photo.id }).update({ preview_path: newPreviewPath });
return newPreviewPath;
}
return null;
}
/**
* Extract capture date from EXIF metadata
*/
@@ -525,6 +660,9 @@ module.exports = {
generateHeroImage,
isHeroValid,
ensureHeroImage,
generatePreviewImage,
isPreviewValid,
ensurePreviewImage,
extractCaptureDate,
withLocalCopy,
};