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