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
This commit is contained in:
@@ -10,6 +10,17 @@ import { pipeline } from 'stream/promises';
|
|||||||
import type { Attachment } from '@backspace/shared';
|
import type { Attachment } from '@backspace/shared';
|
||||||
import { generateThumbnail, isResizableImage } from '../utils/thumbnail.js';
|
import { generateThumbnail, isResizableImage } from '../utils/thumbnail.js';
|
||||||
|
|
||||||
|
const EXT_MIMETYPES: Record<string, string> = {
|
||||||
|
'.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<void> {
|
export async function uploadRoutes(app: FastifyInstance): Promise<void> {
|
||||||
// Ensure upload directory exists
|
// Ensure upload directory exists
|
||||||
if (!fs.existsSync(config.uploadDir)) {
|
if (!fs.existsSync(config.uploadDir)) {
|
||||||
@@ -101,11 +112,13 @@ export async function uploadRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(404).send({ error: 'File not found', statusCode: 404 });
|
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 db = getDb();
|
||||||
const attachment = db.select().from(schema.attachments).where(eq(schema.attachments.filename, safeName)).get();
|
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 originalName = attachment?.originalName ?? safeName;
|
||||||
|
const mimetype = attachment?.mimetype
|
||||||
|
?? EXT_MIMETYPES[path.extname(safeName).toLowerCase()]
|
||||||
|
?? 'application/octet-stream';
|
||||||
|
|
||||||
// Set caching headers
|
// Set caching headers
|
||||||
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
|
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
|||||||
@@ -53,26 +53,11 @@ function getDiskFiles(): DiskFile[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReferencedFilenames(): Set<string> {
|
/** Filenames referenced by user/space profiles (avatars, banners, icons). */
|
||||||
|
function getProfileReferencedFilenames(): Set<string> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const referenced = new Set<string>();
|
const referenced = new Set<string>();
|
||||||
|
|
||||||
// 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
|
// User avatars
|
||||||
const avatarRows = db.select({ avatar: schema.users.avatar })
|
const avatarRows = db.select({ avatar: schema.users.avatar })
|
||||||
.from(schema.users)
|
.from(schema.users)
|
||||||
@@ -112,6 +97,30 @@ function getReferencedFilenames(): Set<string> {
|
|||||||
return referenced;
|
return referenced;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All filenames referenced anywhere: attachments + profiles. */
|
||||||
|
function getReferencedFilenames(): Set<string> {
|
||||||
|
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 }[] {
|
function getUnlinkedAttachments(): { id: string; filename: string; thumbnailFilename: string | null; size: number }[] {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const cutoff = Date.now() - UNLINKED_AGE_MS;
|
const cutoff = Date.now() - UNLINKED_AGE_MS;
|
||||||
@@ -210,12 +219,13 @@ export function cleanupStorage(dryRun: boolean): CleanupResult {
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const orphans = getOrphanedFiles();
|
const orphans = getOrphanedFiles();
|
||||||
const unlinked = getUnlinkedAttachments();
|
const unlinked = getUnlinkedAttachments();
|
||||||
|
const profileReferenced = getProfileReferencedFilenames();
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
let deletedFiles = 0;
|
let deletedFiles = 0;
|
||||||
let freedBytes = 0;
|
let freedBytes = 0;
|
||||||
let deletedAttachmentRecords = 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) {
|
for (const orphan of orphans) {
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
try {
|
try {
|
||||||
@@ -229,13 +239,15 @@ export function cleanupStorage(dryRun: boolean): CleanupResult {
|
|||||||
freedBytes += orphan.size;
|
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) {
|
for (const att of unlinked) {
|
||||||
|
const fileInUseByProfile = profileReferenced.has(path.basename(att.filename));
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
try {
|
try {
|
||||||
deleteUploadFile(att.filename);
|
if (!fileInUseByProfile) {
|
||||||
if (att.thumbnailFilename) {
|
deleteUploadFile(att.filename);
|
||||||
deleteUploadFile(att.thumbnailFilename);
|
|
||||||
}
|
}
|
||||||
db.delete(schema.attachments)
|
db.delete(schema.attachments)
|
||||||
.where(eq(schema.attachments.id, att.id))
|
.where(eq(schema.attachments.id, att.id))
|
||||||
@@ -246,7 +258,9 @@ export function cleanupStorage(dryRun: boolean): CleanupResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
deletedAttachmentRecords++;
|
deletedAttachmentRecords++;
|
||||||
freedBytes += att.size;
|
if (!fileInUseByProfile) {
|
||||||
|
freedBytes += att.size;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ export async function generateThumbnail(
|
|||||||
return null;
|
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 originalFilename = path.basename(originalPath);
|
||||||
const thumbName = thumbFilename(originalFilename);
|
const thumbName = thumbFilename(originalFilename);
|
||||||
const thumbPath = path.join(uploadDir, thumbName);
|
const thumbPath = path.join(uploadDir, thumbName);
|
||||||
|
|||||||
Reference in New Issue
Block a user