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:
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user