feat: backfill thumbnails for existing image attachments on first startup

One-time async migration runs after server is listening — generates 800px
WebP thumbnails for all existing image attachments that lack one. Gated by
a persistent flag in instance_settings so it never re-runs.
This commit is contained in:
Jannis Braun
2026-03-13 17:07:29 +01:00
parent 3e97c2b0f5
commit d3101c4ba4
2 changed files with 66 additions and 1 deletions
+59
View File
@@ -1,6 +1,9 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import crypto from 'crypto'; import crypto from 'crypto';
import path from 'path';
import fs from 'fs';
import { DEFAULT_EVERYONE_PERMISSIONS, PermissionBits, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js'; import { DEFAULT_EVERYONE_PERMISSIONS, PermissionBits, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
import { generateThumbnail, isResizableImage } from '../utils/thumbnail.js';
export function runMigrations(db: Database.Database): void { export function runMigrations(db: Database.Database): void {
console.log('Checking for database migrations...'); console.log('Checking for database migrations...');
@@ -679,3 +682,59 @@ function migrateReplicatedUsernames(db: Database.Database): void {
console.log(`Migrating: Renamed replicated user "${row.username}" → "${newUsername}"`); console.log(`Migrating: Renamed replicated user "${row.username}" → "${newUsername}"`);
} }
} }
/**
* Async backfill: generate thumbnails for all existing image attachments that
* don't have one yet. Runs once after server startup, gated by a persistent
* flag in instance_settings so it never re-runs.
*
* Call this AFTER the server is listening — it's fire-and-forget and doesn't
* block startup.
*/
export async function backfillThumbnails(db: Database.Database, uploadDir: string): Promise<void> {
// Ensure the flag column exists
const cols = db.pragma('table_info(instance_settings)') as { name: string }[];
if (!cols.some(c => c.name === 'thumbnails_backfilled')) {
db.exec('ALTER TABLE instance_settings ADD COLUMN thumbnails_backfilled INTEGER DEFAULT 0');
}
const row = db.prepare('SELECT thumbnails_backfilled FROM instance_settings WHERE id = 1').get() as
{ thumbnails_backfilled: number } | undefined;
if (row && row.thumbnails_backfilled === 1) return;
// Find all image attachments without a thumbnail
const rows = db.prepare(
"SELECT id, filename, mimetype FROM attachments WHERE thumbnail_filename IS NULL"
).all() as { id: string; filename: string; mimetype: string }[];
const candidates = rows.filter(r => isResizableImage(r.mimetype));
if (candidates.length === 0) {
db.prepare('UPDATE instance_settings SET thumbnails_backfilled = 1 WHERE id = 1').run();
return;
}
console.log(`Backfill: Generating thumbnails for ${candidates.length} existing image(s)...`);
const update = db.prepare('UPDATE attachments SET thumbnail_filename = ? WHERE id = ?');
let generated = 0;
let skipped = 0;
for (const att of candidates) {
const originalPath = path.join(uploadDir, path.basename(att.filename));
if (!fs.existsSync(originalPath)) {
skipped++;
continue;
}
const thumbName = await generateThumbnail(originalPath, att.mimetype, uploadDir);
if (thumbName) {
update.run(thumbName, att.id);
generated++;
} else {
skipped++;
}
}
console.log(`Backfill: Generated ${generated} thumbnail(s), skipped ${skipped} (small or missing)`);
db.prepare('UPDATE instance_settings SET thumbnails_backfilled = 1 WHERE id = 1').run();
}
+7 -1
View File
@@ -5,8 +5,9 @@ import websocket from '@fastify/websocket';
import multipart from '@fastify/multipart'; import multipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import { config } from './config.js'; import { config } from './config.js';
import { getDb } from './db/index.js'; import { getDb, getRawDb } from './db/index.js';
import { seedDatabase } from './db/seed.js'; import { seedDatabase } from './db/seed.js';
import { backfillThumbnails } from './db/migrate.js';
import { authRoutes } from './routes/auth.js'; import { authRoutes } from './routes/auth.js';
import { userRoutes } from './routes/users.js'; import { userRoutes } from './routes/users.js';
import { spaceRoutes } from './routes/spaces.js'; import { spaceRoutes } from './routes/spaces.js';
@@ -111,6 +112,11 @@ async function main(): Promise<void> {
process.exit(1); process.exit(1);
} }
// Fire-and-forget: backfill thumbnails for existing images (runs once)
backfillThumbnails(getRawDb(), config.uploadDir).catch(err => {
console.error('Thumbnail backfill failed (non-fatal):', err);
});
const shutdown = async () => { const shutdown = async () => {
console.log('Shutting down...'); console.log('Shutting down...');
await app.close(); await app.close();