diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index bd7cc755..78488659 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -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(); + + 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 diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index c3c504ab..b586fd7e 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -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 { // 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 { // 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(); diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 1ce73adf..9153dc16 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -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 { // 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(); diff --git a/packages/server/src/utils/fileCleanup.ts b/packages/server/src/utils/fileCleanup.ts index c2603004..23e9c194 100644 --- a/packages/server/src/utils/fileCleanup.ts +++ b/packages/server/src/utils/fileCleanup.ts @@ -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(); +} diff --git a/packages/server/src/utils/storageJanitor.ts b/packages/server/src/utils/storageJanitor.ts index 46f3304d..e42962b3 100644 --- a/packages/server/src/utils/storageJanitor.ts +++ b/packages/server/src/utils/storageJanitor.ts @@ -124,7 +124,10 @@ function getReferencedFilenames(): Set { 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)) diff --git a/packages/server/src/utils/thumbnail.ts b/packages/server/src/utils/thumbnail.ts index 38a82196..b1a06a2f 100644 --- a/packages/server/src/utils/thumbnail.ts +++ b/packages/server/src/utils/thumbnail.ts @@ -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 { + 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', diff --git a/packages/web/index.html b/packages/web/index.html index 13913baa..abbad9b8 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -6,7 +6,7 @@ - + diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 57634e0d..812a8860 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -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 ( <> - + )} diff --git a/packages/web/src/components/modals/CreateSpace.tsx b/packages/web/src/components/modals/CreateSpace.tsx index 80528457..c07a22b3 100644 --- a/packages/web/src/components/modals/CreateSpace.tsx +++ b/packages/web/src/components/modals/CreateSpace.tsx @@ -300,6 +300,7 @@ export function CreateSpaceModal() { title="Crop Space Icon" cropShape="round" aspectRatio={1} + maxOutputDimension={256} /> ); diff --git a/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx index 1f896ad7..24e68396 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx @@ -66,7 +66,7 @@ export function GeneralPanel() { }; return ( -
+
e.preventDefault()}>
Configure your Backspace instance. These settings affect all users.
@@ -178,6 +178,6 @@ export function GeneralPanel() {
)} - + ); } diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx index 57bbe70d..a3c96ba8 100644 --- a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx @@ -741,7 +741,7 @@ export function AccountPanel() { title="Crop Avatar" cropShape="round" aspectRatio={1} - maxOutputDimension={512} + maxOutputDimension={256} /> ); diff --git a/packages/web/src/components/ui/SwUpdatePrompt.tsx b/packages/web/src/components/ui/SwUpdatePrompt.tsx index 0357ddf6..ce72576f 100644 --- a/packages/web/src/components/ui/SwUpdatePrompt.tsx +++ b/packages/web/src/components/ui/SwUpdatePrompt.tsx @@ -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 ( -
- A new version is available - -
- ); + return null; } diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 2660fed2..3b288d06 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -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, }, }), ],