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