From ed4dcdcf695bb417302088c5c160615e6b5a2d9d Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:49:11 +0100 Subject: [PATCH] fix: thumbnail content-type, animated GIF preservation, and janitor cleanup - Add extension-based mimetype fallback in uploads route so thumbnail files serve correct Content-Type (image/webp) instead of falling back to application/octet-stream when DB lookup misses - Skip animated images (metadata.pages > 1) during thumbnail generation to preserve GIF/WebP animations instead of flattening to static frame - Remove redundant explicit thumbnail deletion in storageJanitor since deleteUploadFile() already auto-deletes the thumbnail variant --- packages/server/src/routes/uploads.ts | 17 +++++- packages/server/src/utils/storageJanitor.ts | 60 +++++++++++++-------- packages/server/src/utils/thumbnail.ts | 5 ++ 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/packages/server/src/routes/uploads.ts b/packages/server/src/routes/uploads.ts index 89ce28f6..1d10b28b 100644 --- a/packages/server/src/routes/uploads.ts +++ b/packages/server/src/routes/uploads.ts @@ -10,6 +10,17 @@ import { pipeline } from 'stream/promises'; import type { Attachment } from '@backspace/shared'; import { generateThumbnail, isResizableImage } from '../utils/thumbnail.js'; +const EXT_MIMETYPES: Record = { + '.webp': 'image/webp', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.png': 'image/png', '.gif': 'image/gif', '.svg': 'image/svg+xml', + '.avif': 'image/avif', '.tiff': 'image/tiff', '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', + '.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.wav': 'audio/wav', + '.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus', + '.pdf': 'application/pdf', +}; + export async function uploadRoutes(app: FastifyInstance): Promise { // Ensure upload directory exists if (!fs.existsSync(config.uploadDir)) { @@ -101,11 +112,13 @@ export async function uploadRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'File not found', statusCode: 404 }); } - // Get mimetype from DB or guess from extension + // Get mimetype from DB, falling back to extension-based lookup for thumbnails/orphans const db = getDb(); const attachment = db.select().from(schema.attachments).where(eq(schema.attachments.filename, safeName)).get(); - const mimetype = attachment?.mimetype ?? 'application/octet-stream'; const originalName = attachment?.originalName ?? safeName; + const mimetype = attachment?.mimetype + ?? EXT_MIMETYPES[path.extname(safeName).toLowerCase()] + ?? 'application/octet-stream'; // Set caching headers reply.header('Cache-Control', 'public, max-age=31536000, immutable'); diff --git a/packages/server/src/utils/storageJanitor.ts b/packages/server/src/utils/storageJanitor.ts index e660c10b..46f3304d 100644 --- a/packages/server/src/utils/storageJanitor.ts +++ b/packages/server/src/utils/storageJanitor.ts @@ -53,26 +53,11 @@ function getDiskFiles(): DiskFile[] { } } -function getReferencedFilenames(): Set { +/** Filenames referenced by user/space profiles (avatars, banners, icons). */ +function getProfileReferencedFilenames(): Set { const db = getDb(); const referenced = new Set(); - // Attachment filenames - const attachmentRows = db.select({ filename: schema.attachments.filename }) - .from(schema.attachments).all(); - for (const row of attachmentRows) { - referenced.add(path.basename(row.filename)); - } - - // Attachment thumbnails - const thumbRows = db.select({ thumbnailFilename: schema.attachments.thumbnailFilename }) - .from(schema.attachments) - .where(isNotNull(schema.attachments.thumbnailFilename)) - .all(); - for (const row of thumbRows) { - if (row.thumbnailFilename) referenced.add(path.basename(row.thumbnailFilename)); - } - // User avatars const avatarRows = db.select({ avatar: schema.users.avatar }) .from(schema.users) @@ -112,6 +97,30 @@ function getReferencedFilenames(): Set { return referenced; } +/** All filenames referenced anywhere: attachments + profiles. */ +function getReferencedFilenames(): Set { + const db = getDb(); + const referenced = getProfileReferencedFilenames(); + + // Attachment filenames + const attachmentRows = db.select({ filename: schema.attachments.filename }) + .from(schema.attachments).all(); + for (const row of attachmentRows) { + referenced.add(path.basename(row.filename)); + } + + // Attachment thumbnails + const thumbRows = db.select({ thumbnailFilename: schema.attachments.thumbnailFilename }) + .from(schema.attachments) + .where(isNotNull(schema.attachments.thumbnailFilename)) + .all(); + for (const row of thumbRows) { + if (row.thumbnailFilename) referenced.add(path.basename(row.thumbnailFilename)); + } + + return referenced; +} + function getUnlinkedAttachments(): { id: string; filename: string; thumbnailFilename: string | null; size: number }[] { const db = getDb(); const cutoff = Date.now() - UNLINKED_AGE_MS; @@ -210,12 +219,13 @@ export function cleanupStorage(dryRun: boolean): CleanupResult { const db = getDb(); const orphans = getOrphanedFiles(); const unlinked = getUnlinkedAttachments(); + const profileReferenced = getProfileReferencedFilenames(); const errors: string[] = []; let deletedFiles = 0; let freedBytes = 0; let deletedAttachmentRecords = 0; - // Delete orphaned disk files + // Phase 1: Delete orphaned disk files (not referenced by any DB record) for (const orphan of orphans) { if (!dryRun) { try { @@ -229,13 +239,15 @@ export function cleanupStorage(dryRun: boolean): CleanupResult { freedBytes += orphan.size; } - // Delete stale unlinked attachment records (and their disk files) + // 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. for (const att of unlinked) { + const fileInUseByProfile = profileReferenced.has(path.basename(att.filename)); if (!dryRun) { try { - deleteUploadFile(att.filename); - if (att.thumbnailFilename) { - deleteUploadFile(att.thumbnailFilename); + if (!fileInUseByProfile) { + deleteUploadFile(att.filename); } db.delete(schema.attachments) .where(eq(schema.attachments.id, att.id)) @@ -246,7 +258,9 @@ export function cleanupStorage(dryRun: boolean): CleanupResult { } } deletedAttachmentRecords++; - freedBytes += att.size; + if (!fileInUseByProfile) { + freedBytes += att.size; + } } return { diff --git a/packages/server/src/utils/thumbnail.ts b/packages/server/src/utils/thumbnail.ts index 894c9a0e..38a82196 100644 --- a/packages/server/src/utils/thumbnail.ts +++ b/packages/server/src/utils/thumbnail.ts @@ -49,6 +49,11 @@ export async function generateThumbnail( return null; } + // Skip animated images — Sharp would flatten to a single static frame + if (metadata.pages && metadata.pages > 1) { + return null; + } + const originalFilename = path.basename(originalPath); const thumbName = thumbFilename(originalFilename); const thumbPath = path.join(uploadDir, thumbName);