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
+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();