feat: optimize profile image sizes, silent PWA updates, storage cleanup fixes

- Resize avatars/icons to 256px and banners to 1280px (client crop + server safety net)
- Add server-side resizeProfileImage() for federation/API uploads without crop modal
- Fix unconstrained crop on RegisterPage and CreateSpace (was missing maxOutputDimension)
- PWA: switch to autoUpdate with skipWaiting/clientsClaim for seamless deploys
- Storage janitor: exclude profile images from unlinked cleanup, delete stale thumbnails
- Add deleteAttachmentByFilename() to clean orphaned attachment records for profile images
- Migration: one-time cleanup of stale profile image attachment records
- GeneralPanel: wrap in <form> to prevent implicit submission
This commit is contained in:
Jannis Braun
2026-03-15 19:16:48 +01:00
parent 4d230711fc
commit 2e6fa3cdc6
15 changed files with 250 additions and 30 deletions
+33
View File
@@ -1,6 +1,8 @@
import fs from 'fs';
import path from 'path';
import { eq } from 'drizzle-orm';
import { config } from '../config.js';
import { getDb, schema } from '../db/index.js';
import { thumbFilename } from './thumbnail.js';
/**
@@ -39,3 +41,34 @@ export function deleteAttachmentFiles(rows: { filename: string }[]): void {
deleteUploadFile(row.filename);
}
}
/**
* Delete the attachment DB record for a given filename, plus its thumbnail.
* Used when profile images (avatars, banners, icons) are set or replaced —
* the file reference moves to users/spaces tables, making the attachment
* record unnecessary. Idempotent (no-op if record doesn't exist).
*/
export function deleteAttachmentByFilename(filename: string): void {
const db = getDb();
const safeName = path.basename(filename);
// Read the record first to get the thumbnail filename before deleting
const record = db.select({
id: schema.attachments.id,
thumbnailFilename: schema.attachments.thumbnailFilename,
}).from(schema.attachments)
.where(eq(schema.attachments.filename, safeName))
.get();
if (!record) return;
// Delete thumbnail from disk (profile images don't need thumbnails)
if (record.thumbnailFilename) {
const thumbPath = path.join(config.uploadDir, path.basename(record.thumbnailFilename));
try { fs.unlinkSync(thumbPath); } catch { /* may not exist */ }
}
// Delete the attachment record
db.delete(schema.attachments)
.where(eq(schema.attachments.id, record.id))
.run();
}
+12 -1
View File
@@ -124,7 +124,10 @@ function getReferencedFilenames(): Set<string> {
function getUnlinkedAttachments(): { id: string; filename: string; thumbnailFilename: string | null; size: number }[] {
const db = getDb();
const cutoff = Date.now() - UNLINKED_AGE_MS;
// Attachments with no message_id AND no dm_message_id, older than 1 hour
const profileReferenced = getProfileReferencedFilenames();
// Attachments with no message_id AND no dm_message_id, older than 1 hour,
// excluding files currently used as profile images (avatars, banners, icons)
const rows = db.select({
id: schema.attachments.id,
filename: schema.attachments.filename,
@@ -137,6 +140,7 @@ function getUnlinkedAttachments(): { id: string; filename: string; thumbnailFile
return rows.filter(r =>
r.messageId === null && r.dmMessageId === null && r.createdAt < cutoff
&& !profileReferenced.has(path.basename(r.filename))
).map(r => ({
id: r.id,
filename: r.filename,
@@ -242,12 +246,19 @@ export function cleanupStorage(dryRun: boolean): CleanupResult {
// Phase 2: Clean up stale unlinked attachment records.
// Files referenced by user/space profiles (avatars, banners, icons) are
// preserved on disk — only the orphaned attachment DB record is removed.
// Thumbnails are always deleted since profile images don't need them.
for (const att of unlinked) {
const fileInUseByProfile = profileReferenced.has(path.basename(att.filename));
if (!dryRun) {
try {
if (!fileInUseByProfile) {
deleteUploadFile(att.filename);
} else if (att.thumbnailFilename) {
// Main file is a profile image — keep it. But delete the thumbnail
// since it's only useful for message attachments, and the attachment
// record is about to be deleted (which would orphan the thumbnail).
const thumbPath = path.join(config.uploadDir, path.basename(att.thumbnailFilename));
try { fs.unlinkSync(thumbPath); } catch { /* may not exist */ }
}
db.delete(schema.attachments)
.where(eq(schema.attachments.id, att.id))
+31
View File
@@ -1,6 +1,37 @@
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
const PROFILE_LIMITS = { avatar: 256, icon: 256, banner: 1280 } as const;
/**
* Resize a profile image (avatar, icon, or banner) to the target max dimension.
* Preserves animated GIF frames. No-op if the image is already small enough.
* Writes to a temp file then atomically renames to avoid corruption.
*/
export async function resizeProfileImage(
filepath: string,
type: 'avatar' | 'icon' | 'banner',
): Promise<void> {
const maxDim = PROFILE_LIMITS[type];
try {
const image = sharp(filepath, { animated: true });
const metadata = await image.metadata();
if (!metadata.width || metadata.width <= maxDim) return;
const tmpPath = filepath + '.tmp';
await sharp(filepath, { animated: true })
.resize({ width: maxDim, withoutEnlargement: true })
.toFile(tmpPath);
fs.renameSync(tmpPath, filepath);
} catch (err) {
// Non-fatal — the original file is still intact
console.error(`Profile image resize failed (non-fatal) for ${path.basename(filepath)}:`, err);
// Clean up temp file if it was partially written
try { fs.unlinkSync(filepath + '.tmp'); } catch { /* ignore */ }
}
}
const RESIZABLE_MIMETYPES = new Set([
'image/jpeg',
'image/png',