Files
picpeak/backend/src/routes/adminThumbnails.js
T
Paul Nothaft 97d92f8428 fix(thumbnails): regenerate external photos instead of dropping their tiers (#1129)
POST /admin/thumbnails/regenerate resolved every source as
storage/events/active/<photo.path> and fs.access'd it. External and reference
rows are not there, so every one failed and was counted as an error — and
because the tier deletion runs first, the endpoint dropped every ?w= tier and
rebuilt nothing, leaving the library worse than before it ran. The UI reported
success either way.

Now routed through ensureThumbnail, which resolves both source kinds, uses the
per-photo ext<id>_ output name, and writes thumbnail_path back itself.

Review rounds also removed both destructive deletes in generateThumbnail: the
pre-delete ran before sharp opened the source, so an unreadable source left the
previous rendition gone and the database pointing at it — across a bulk run,
the whole gallery. Neither delete was needed, since put stages to a temp file
and renames atomically and is the last statement in the try.

Videos are filtered out, and the superseded rendition is removed only when the
storage key actually moved, compared through the same canonicalisation the
backends apply so a legacy backslash path is not mistaken for a different
object.

Reported by @BraynArts, who also identified the fix.
2026-08-22 21:37:49 +02:00

337 lines
14 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { ensureThumbnail, ensurePreviewImage } = require('../services/imageProcessor');
const { getStorage } = require('../services/storage');
const logger = require('../utils/logger');
/**
* Do these two stored paths address the same object?
*
* Compared the way the storage backends do, not as raw strings.
* LocalFsStorage._resolve and S3StorageBackend._key both fold `\` to `/` and
* strip a leading `./`, so `thumbnails\thumb_x.jpg`, `./thumbnails/thumb_x.jpg`
* and `thumbnails/thumb_x.jpg` are all one file. A legacy thumbnail_path in
* any of those shapes would compare unequal to the freshly generated POSIX
* key — and the "the key moved, delete the old one" branch below would then
* delete the thumbnail that had just been written, leaving every regenerated
* photo pointing at nothing.
*/
function sameStorageKey(a, b) {
const canonical = (key) => String(key)
.replace(/\\/g, '/')
.replace(/^\.?\/+/, '')
.replace(/\/+/g, '/');
return canonical(a) === canonical(b);
}
// Parse JSON-encoded setting values
function parseSettingValue(value) {
if (value === null || value === undefined) return null;
try { return JSON.parse(value); } catch (e) { return value; }
}
// Get thumbnail settings
router.get('/settings', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'thumbnail_width',
'thumbnail_height',
'thumbnail_fit',
'thumbnail_quality',
'thumbnail_format',
// Lightbox preview tier (#492). Boolean, default false.
'lightbox_preview_enabled'
])
.select('setting_key', 'setting_value');
const settingsMap = {};
settings.forEach(s => {
const parsed = parseSettingValue(s.setting_value);
settingsMap[s.setting_key] = {
value: String(parsed ?? '')
};
});
res.json({
settings: settingsMap,
fitOptions: ['cover', 'contain', 'fill', 'inside', 'outside'],
formatOptions: ['jpeg', 'png', 'webp']
});
} catch (error) {
logger.error('Error fetching thumbnail settings:', error);
res.status(500).json({ error: 'Failed to fetch thumbnail settings' });
}
});
// Update thumbnail settings
router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { width, height, fit, quality, format, lightbox_preview_enabled } = req.body;
// Validate inputs
if (width && (width < 50 || width > 1000)) {
return res.status(400).json({ error: 'Width must be between 50 and 1000 pixels' });
}
if (height && (height < 50 || height > 1000)) {
return res.status(400).json({ error: 'Height must be between 50 and 1000 pixels' });
}
if (quality && (quality < 1 || quality > 100)) {
return res.status(400).json({ error: 'Quality must be between 1 and 100' });
}
if (fit && !['cover', 'contain', 'fill', 'inside', 'outside'].includes(fit)) {
return res.status(400).json({ error: 'Invalid fit option' });
}
if (format && !['jpeg', 'png', 'webp'].includes(format)) {
return res.status(400).json({ error: 'Invalid format option' });
}
// Update settings
const updates = [];
if (width) updates.push({ setting_key: 'thumbnail_width', setting_value: width });
if (height) updates.push({ setting_key: 'thumbnail_height', setting_value: height });
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) {
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({
message: 'Thumbnail settings updated successfully',
regenerateRequired: true
});
} catch (error) {
logger.error('Error updating thumbnail settings:', error);
res.status(500).json({ error: 'Failed to update thumbnail settings' });
}
});
// Regenerate all thumbnails with new settings
router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { eventId } = req.body; // Optional: regenerate for specific event only
// source_origin/external_relpath/filename feed BOTH deleteThumbnailTiers
// (which derives the tier keys from the same fields ensureThumbnailAtWidth
// wrote them with) and ensureThumbnail, which branches on them to resolve
// an external source off its mount instead of under events/active.
// thumbnail_path is selected so it can be nulled — see below.
let query = db('photos')
.select(
'id', 'event_id', 'path', 'media_type', 'mime_type', 'thumbnail_path',
'source_origin', 'external_relpath', 'filename'
);
if (eventId) {
query = query.where('event_id', eventId);
}
// Skip videos, matching /regenerate-previews. Their thumbnail is a poster
// frame from videoProcessor, so handing the container file to Sharp here
// only ever produced an error per video row.
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 photos to regenerate' });
}
// Start regeneration in background
res.json({
message: `Started regenerating ${photos.length} thumbnails`,
count: photos.length
});
// Process thumbnails in background
setImmediate(async () => {
let successCount = 0;
let errorCount = 0;
for (const photo of photos) {
try {
// Drop the responsive tiers first (#1095), same as the preview
// endpoint below. They are cached by width outside thumbnail_path
// and their key carries no settings version, so regenerating only
// the canonical rendition leaves phones served the old fit, quality
// or format indefinitely — which is exactly what this endpoint is
// invoked to undo after a settings change.
await require('../services/imageProcessor').deleteThumbnailTiers(photo);
// Through ensureThumbnail, not a hand-rolled path (#1129). This
// route used to resolve the source as `storage/events/active/<path>`
// and fs.access it — a location that does not exist for external or
// reference rows, whose originals live under events.external_path.
// Every such photo failed the check and was counted as an error, so
// on a reference install the endpoint dropped every tier and
// rebuilt nothing, while the UI reported success (the response is
// sent before this loop starts).
//
// ensureThumbnail already resolves both source kinds via
// resolvePhotoFilePath/resolvePhotoStorageKey, uses the per-photo
// ext<id>_ output name so two events referencing one NAS basename
// cannot clobber each other, and writes thumbnail_path back itself.
// Nulling thumbnail_path is what stops it short-circuiting on
// isThumbnailValid — the same trick /regenerate-previews uses.
const newThumbnailPath = await ensureThumbnail({ ...photo, thumbnail_path: null });
if (newThumbnailPath) {
// Drop the superseded canonical rendition when the key MOVED.
//
// For a managed photo on S3, ensureThumbnail downloads the source
// to a randomly-named temp file, and for non-RAW input
// withProcessableImage passes no outputBasename — so the key is
// derived from that random name and differs on every run. Nulling
// thumbnail_path above hides the old key from everything that
// would otherwise clean it up, so without this each regeneration
// strands a full thumbnail in the bucket, once per photo per run.
//
// Guarded on the key actually changing: on local storage it is
// stable, and deleting the equal key would delete the file that
// was just written.
if (photo.thumbnail_path && !sameStorageKey(photo.thumbnail_path, newThumbnailPath)) {
await getStorage().delete(photo.thumbnail_path).catch((err) => {
// Losing the old object is untidy, not a failed regeneration.
logger.warn(
`Could not remove superseded thumbnail ${photo.thumbnail_path} for photo ${photo.id}: ${err.message}`
);
});
}
successCount++;
logger.info(`Regenerated thumbnail for photo ${photo.id}`);
} else {
errorCount++;
}
} catch (error) {
logger.error(`Error regenerating thumbnail for photo ${photo.id}:`, error);
errorCount++;
}
}
logger.info(`Thumbnail regeneration complete: ${successCount} success, ${errorCount} errors`);
});
} catch (error) {
logger.error('Error starting thumbnail regeneration:', error);
res.status(500).json({ error: 'Failed to start thumbnail regeneration' });
}
});
// 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;
// source_origin/external_relpath/filename are what ensurePreviewImage
// branches on for external/reference rows (#1078) — without them every
// external photo looks managed here and generation is skipped.
let query = db('photos').select(
'id', 'event_id', 'path', 'media_type', 'mime_type', 'preview_path',
'source_origin', 'external_relpath', 'filename'
);
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.
// Drop the responsive tiers first (#1095). They are cached by width
// outside preview_path, so regenerating only the canonical rendition
// leaves phones served the stale 640/1280 copy indefinitely — which
// is precisely the case this endpoint exists for (a replaced
// reference source, or a corrupted rendition).
await require('../services/imageProcessor').deletePreviewTiers(photo);
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 {
// Count photos with and without thumbnails
const totalPhotos = await db('photos').count('id as count').first();
const photosWithThumbnails = await db('photos')
.whereNotNull('thumbnail_path')
.count('id as count')
.first();
res.json({
total: totalPhotos.count,
withThumbnails: photosWithThumbnails.count,
withoutThumbnails: totalPhotos.count - photosWithThumbnails.count,
percentage: Math.round((photosWithThumbnails.count / totalPhotos.count) * 100)
});
} catch (error) {
logger.error('Error fetching regeneration status:', error);
res.status(500).json({ error: 'Failed to fetch regeneration status' });
}
});
module.exports = router;