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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user