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
+76
View File
@@ -323,6 +323,9 @@ export function runMigrations(db: Database.Database): void {
// ─── Add indexes on FK columns for query performance ─────────────────────
migrateAddIndexes(db);
// ─── Clean up stale attachment records for profile images ───────────────
migrateCleanupProfileAttachmentRecords(db);
console.log('Migrations complete.');
}
@@ -884,6 +887,79 @@ function migrateReplicatedUsernames(db: Database.Database): void {
}
}
/**
* Clean up stale attachment records left behind by profile image uploads.
* Profile images (avatars, banners, space icons) go through POST /api/uploads
* but are referenced by users/spaces columns, not by attachments.message_id.
* This leaves orphaned attachment records that inflate the "Unlinked Uploads"
* count in the storage panel.
*
* Gated by a persistent flag so it runs exactly once.
*/
function migrateCleanupProfileAttachmentRecords(db: Database.Database): void {
const cols = db.pragma('table_info(instance_settings)') as { name: string }[];
if (!cols.some(c => c.name === 'profile_attachments_cleaned')) {
db.exec('ALTER TABLE instance_settings ADD COLUMN profile_attachments_cleaned INTEGER DEFAULT 0');
}
const row = db.prepare('SELECT profile_attachments_cleaned FROM instance_settings WHERE id = 1').get() as
{ profile_attachments_cleaned: number } | undefined;
if (row && row.profile_attachments_cleaned === 1) return;
// Collect all filenames currently referenced by profiles
const profileFilenames = new Set<string>();
const avatarRows = db.prepare('SELECT avatar FROM users WHERE avatar IS NOT NULL').all() as { avatar: string }[];
for (const r of avatarRows) profileFilenames.add(path.basename(r.avatar));
const bannerRows = db.prepare('SELECT banner FROM users WHERE banner IS NOT NULL').all() as { banner: string }[];
for (const r of bannerRows) profileFilenames.add(path.basename(r.banner));
const iconRows = db.prepare('SELECT icon FROM spaces WHERE icon IS NOT NULL').all() as { icon: string }[];
for (const r of iconRows) profileFilenames.add(path.basename(r.icon));
const spaceBannerRows = db.prepare('SELECT banner FROM spaces WHERE banner IS NOT NULL').all() as { banner: string }[];
for (const r of spaceBannerRows) profileFilenames.add(path.basename(r.banner));
// Find unlinked attachment records (no message reference)
const unlinkedRows = db.prepare(
'SELECT id, filename FROM attachments WHERE message_id IS NULL AND dm_message_id IS NULL'
).all() as { id: string; filename: string }[];
const deleteStmt = db.prepare('DELETE FROM attachments WHERE id = ?');
let cleaned = 0;
for (const att of unlinkedRows) {
const basename = path.basename(att.filename);
// Delete if the file is a current profile image (record is unnecessary)
// or if the file no longer exists on disk (stale record from a replaced profile image)
if (profileFilenames.has(basename)) {
deleteStmt.run(att.id);
cleaned++;
} else {
// Check if the file still exists on disk — if not, this is a stale
// record from a previously-replaced profile image whose file was
// already deleted by the PATCH handler
try {
const uploadDir = process.env.UPLOAD_DIR || path.join(process.cwd(), 'data', 'uploads');
const filePath = path.join(uploadDir, basename);
if (!fs.existsSync(filePath)) {
deleteStmt.run(att.id);
cleaned++;
}
} catch {
// Skip on error — the normal cleanup can handle it later
}
}
}
if (cleaned > 0) {
console.log(`Migrating: Cleaned up ${cleaned} stale profile image attachment record(s)`);
}
db.prepare('UPDATE instance_settings SET profile_attachments_cleaned = 1 WHERE id = 1').run();
}
/**
* Async backfill: generate thumbnails for all existing image attachments that
* don't have one yet. Runs once after server startup, gated by a persistent
+42 -1
View File
@@ -1,3 +1,4 @@
import path from 'path';
import type { FastifyInstance } from 'fastify';
import { eq, and, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
@@ -7,7 +8,9 @@ import { isMember, isSpaceOwner, isBanned, hasPermission, computePermissions, Pe
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
import crypto from 'crypto';
import { connectionManager } from '../ws/handler.js';
import { deleteAttachmentFiles, deleteUploadFile } from '../utils/fileCleanup.js';
import { deleteAttachmentFiles, deleteUploadFile, deleteAttachmentByFilename } from '../utils/fileCleanup.js';
import { resizeProfileImage } from '../utils/thumbnail.js';
import { config } from '../config.js';
import type {
CreateSpaceRequest,
UpdateSpaceRequest,
@@ -173,6 +176,24 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
// Register the creator in connectionManager so they receive WS broadcasts for this space
connectionManager.addUserSpace(request.userId, spaceId);
// Clean up attachment records for icon/banner — reference is now in spaces table
if (icon && typeof icon === 'string' && icon.includes('/api/uploads/')) {
deleteAttachmentByFilename(icon);
}
if (banner && typeof banner === 'string' && banner.includes('/api/uploads/')) {
deleteAttachmentByFilename(banner);
}
// Resize profile images to optimal dimensions
if (icon && typeof icon === 'string' && !icon.startsWith('http')) {
const filePath = path.join(config.uploadDir, path.basename(icon));
await resizeProfileImage(filePath, 'icon');
}
if (banner && typeof banner === 'string' && !banner.startsWith('http')) {
const filePath = path.join(config.uploadDir, path.basename(banner));
await resizeProfileImage(filePath, 'banner');
}
return reply.code(201).send(rowToSpace(server));
});
@@ -412,9 +433,29 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
// Clean up old icon/banner files that were replaced
if (icon !== undefined && oldIcon && oldIcon !== (icon || null) && !oldIcon.startsWith('http')) {
deleteUploadFile(oldIcon);
deleteAttachmentByFilename(oldIcon);
}
if (banner !== undefined && oldBanner && oldBanner !== (banner || null) && !oldBanner.startsWith('http')) {
deleteUploadFile(oldBanner);
deleteAttachmentByFilename(oldBanner);
}
// Clean up attachment records for newly-set profile images — the reference
// now lives in the spaces table, so the attachment record is unnecessary
if (icon && typeof icon === 'string' && icon.includes('/api/uploads/')) {
deleteAttachmentByFilename(icon);
}
if (banner && typeof banner === 'string' && banner.includes('/api/uploads/')) {
deleteAttachmentByFilename(banner);
}
// Resize profile images to optimal dimensions
if (icon && typeof icon === 'string' && !icon.startsWith('http')) {
const filePath = path.join(config.uploadDir, path.basename(icon));
await resizeProfileImage(filePath, 'icon');
}
if (banner && typeof banner === 'string' && !banner.startsWith('http')) {
const filePath = path.join(config.uploadDir, path.basename(banner));
await resizeProfileImage(filePath, 'banner');
}
const updated = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
+24 -1
View File
@@ -6,9 +6,12 @@ import { connectionManager } from '../ws/handler.js';
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder } from '@backspace/shared';
import { AVATAR_COLORS } from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';
import { deleteUploadFile } from '../utils/fileCleanup.js';
import { deleteUploadFile, deleteAttachmentByFilename } from '../utils/fileCleanup.js';
import { tombstoneUser } from '../utils/userDeletion.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { resizeProfileImage } from '../utils/thumbnail.js';
import { config } from '../config.js';
import path from 'path';
/** Validates that a URL is a safe asset URL (relative upload path, bare filename, or http/https) */
function isValidAssetUrl(url: string | null | undefined): boolean {
@@ -332,9 +335,29 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
// Clean up old avatar/banner files that were replaced
if (avatar !== undefined && oldAvatar && oldAvatar !== (avatar || null) && !oldAvatar.startsWith('http')) {
deleteUploadFile(oldAvatar);
deleteAttachmentByFilename(oldAvatar);
}
if (banner !== undefined && oldBanner && oldBanner !== (updateData.banner ?? null) && !oldBanner.startsWith('http')) {
deleteUploadFile(oldBanner);
deleteAttachmentByFilename(oldBanner);
}
// Clean up attachment records for newly-set profile images — the reference
// now lives in the users table, so the attachment record is unnecessary
if (avatar && typeof avatar === 'string' && avatar.includes('/api/uploads/')) {
deleteAttachmentByFilename(avatar);
}
if (updateData.banner && typeof updateData.banner === 'string' && updateData.banner.includes('/api/uploads/')) {
deleteAttachmentByFilename(updateData.banner);
}
// Resize profile images to optimal dimensions (safety net for federation, API clients, etc.)
if (avatar && typeof avatar === 'string' && !avatar.startsWith('http')) {
const filePath = path.join(config.uploadDir, path.basename(avatar));
await resizeProfileImage(filePath, 'avatar');
}
if (updateData.banner && typeof updateData.banner === 'string' && !updateData.banner.startsWith('http')) {
const filePath = path.join(config.uploadDir, path.basename(updateData.banner));
await resizeProfileImage(filePath, 'banner');
}
const updatedUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
+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',
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Self-hosted chat platform" />
<meta name="theme-color" content="#0b0b10" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Backspace" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
+2 -2
View File
@@ -4,7 +4,7 @@ import { LoginPage } from './components/auth/LoginPage';
import { RegisterPage } from './components/auth/RegisterPage';
import { AppLayout } from './components/layout/AppLayout';
import { JoinPage } from './components/JoinPage';
import { SwUpdatePrompt } from './components/ui/SwUpdatePrompt';
import { SwAutoUpdate } from './components/ui/SwUpdatePrompt';
import { useAuthStore } from './stores/authStore';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -29,7 +29,7 @@ function AuthRedirect({ children }: { children: React.ReactNode }) {
export function App() {
return (
<>
<SwUpdatePrompt />
<SwAutoUpdate />
<Routes>
<Route
path="/login"
@@ -473,6 +473,7 @@ export function RegisterPage() {
title="Crop Avatar"
aspectRatio={1}
cropShape="round"
maxOutputDimension={256}
/>
)}
</div>
@@ -300,6 +300,7 @@ export function CreateSpaceModal() {
title="Crop Space Icon"
cropShape="round"
aspectRatio={1}
maxOutputDimension={256}
/>
</>
);
@@ -66,7 +66,7 @@ export function GeneralPanel() {
};
return (
<div className="space-y-5">
<form className="space-y-5" onSubmit={(e) => e.preventDefault()}>
<div className="text-xs text-txt-tertiary">
Configure your Backspace instance. These settings affect all users.
</div>
@@ -178,6 +178,6 @@ export function GeneralPanel() {
</div>
</div>
)}
</div>
</form>
);
}
@@ -741,7 +741,7 @@ export function AccountPanel() {
title="Crop Avatar"
cropShape="round"
aspectRatio={1}
maxOutputDimension={512}
maxOutputDimension={256}
/>
<ImageCropModal
isOpen={bannerCropSrc !== null}
@@ -751,7 +751,7 @@ export function AccountPanel() {
title="Crop Banner"
cropShape="rect"
aspectRatio={3}
maxOutputDimension={1920}
maxOutputDimension={1280}
/>
<DeleteAccountModal
@@ -601,7 +601,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
title="Crop Space Icon"
cropShape="round"
aspectRatio={1}
maxOutputDimension={512}
maxOutputDimension={256}
/>
<ImageCropModal
@@ -612,7 +612,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
title="Crop Space Banner"
cropShape="rect"
aspectRatio={16 / 9}
maxOutputDimension={1920}
maxOutputDimension={1280}
/>
</>
);
@@ -1,22 +1,22 @@
import { useRegisterSW } from 'virtual:pwa-register/react';
import { useEffect } from 'react';
export function SwUpdatePrompt() {
const {
needRefresh: [needRefresh],
updateServiceWorker,
} = useRegisterSW();
export function SwAutoUpdate() {
useRegisterSW({
onRegisteredSW(_swUrl, registration) {
if (!registration) return;
setInterval(() => {
registration.update();
}, 60_000);
},
});
if (!needRefresh) return null;
useEffect(() => {
if (!navigator.serviceWorker) return;
const onControllerChange = () => window.location.reload();
navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
return () => navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
}, []);
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[9999] glass-pill px-4 py-2.5 flex items-center gap-3 text-sm text-txt-primary shadow-lg">
<span>A new version is available</span>
<button
onClick={() => updateServiceWorker(true)}
className="px-3 py-1 rounded-md bg-accent-primary text-white text-xs font-medium hover:opacity-90 transition-opacity"
>
Reload
</button>
</div>
);
return null;
}
+4 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'prompt',
registerType: 'autoUpdate',
includeAssets: ['icons/favicon-32.png', 'icons/favicon-16.png', 'icons/apple-touch-icon.png'],
manifest: {
name: 'Backspace',
@@ -27,6 +27,9 @@ export default defineConfig({
workbox: {
navigateFallback: '/index.html',
navigateFallbackDenylist: [/^\/api/, /^\/ws/, /^\/uploads/],
skipWaiting: true,
clientsClaim: true,
cleanupOutdatedCaches: true,
},
}),
],