Files
backspace/packages/server/src/utils/storageJanitor.ts
T
Jannis Braun ee11408060 feat: add age-based media cleanup endpoint
Adds cleanupOldMedia() to the storage janitor and a new endpoint
POST /api/admin/storage/cleanup-media { maxAgeDays, dryRun }.
Deletes chat attachments older than the specified threshold while
preserving profile images.
2026-03-23 02:20:28 +01:00

382 lines
12 KiB
TypeScript

import fs from 'fs';
import path from 'path';
import { eq, isNotNull } from 'drizzle-orm';
import { config } from '../config.js';
import { getDb, getRawDb, schema } from '../db/index.js';
import { deleteUploadFile } from './fileCleanup.js';
import type { StorageStats, StorageBreakdown, OrphanedFile, CleanupResult } from '@backspace/shared';
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif']);
const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.avi', '.mkv']);
const AUDIO_EXTS = new Set(['.mp3', '.ogg', '.wav', '.flac', '.aac', '.m4a', '.opus']);
const DOC_EXTS = new Set(['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv', '.json', '.xml']);
/** 1-hour threshold: attachments uploaded but never linked to a message */
const UNLINKED_AGE_MS = 60 * 60 * 1000;
function classifyFile(filename: string): string {
const ext = path.extname(filename).toLowerCase();
if (IMAGE_EXTS.has(ext)) return 'image';
if (VIDEO_EXTS.has(ext)) return 'video';
if (AUDIO_EXTS.has(ext)) return 'audio';
if (DOC_EXTS.has(ext)) return 'document';
return 'other';
}
interface DiskFile {
filename: string;
size: number;
modifiedAt: number;
}
function getDiskFiles(): DiskFile[] {
try {
const entries = fs.readdirSync(config.uploadDir, { withFileTypes: true });
const files: DiskFile[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
try {
const stat = fs.statSync(path.join(config.uploadDir, entry.name));
files.push({
filename: entry.name,
size: stat.size,
modifiedAt: stat.mtimeMs,
});
} catch {
// Skip files that vanished between readdir and stat
}
}
return files;
} catch (err: any) {
if (err.code === 'ENOENT') return [];
throw err;
}
}
/** Filenames referenced by user/space profiles (avatars, banners, icons). */
function getProfileReferencedFilenames(): Set<string> {
const db = getDb();
const referenced = new Set<string>();
// User avatars
const avatarRows = db.select({ avatar: schema.users.avatar })
.from(schema.users)
.where(isNotNull(schema.users.avatar))
.all();
for (const row of avatarRows) {
if (row.avatar) referenced.add(path.basename(row.avatar));
}
// User banners
const bannerRows = db.select({ banner: schema.users.banner })
.from(schema.users)
.where(isNotNull(schema.users.banner))
.all();
for (const row of bannerRows) {
if (row.banner) referenced.add(path.basename(row.banner));
}
// Space icons
const iconRows = db.select({ icon: schema.spaces.icon })
.from(schema.spaces)
.where(isNotNull(schema.spaces.icon))
.all();
for (const row of iconRows) {
if (row.icon) referenced.add(path.basename(row.icon));
}
// Space banners
const spaceBannerRows = db.select({ banner: schema.spaces.banner })
.from(schema.spaces)
.where(isNotNull(schema.spaces.banner))
.all();
for (const row of spaceBannerRows) {
if (row.banner) referenced.add(path.basename(row.banner));
}
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 }[] {
const db = getDb();
const cutoff = Date.now() - UNLINKED_AGE_MS;
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,
thumbnailFilename: schema.attachments.thumbnailFilename,
size: schema.attachments.size,
messageId: schema.attachments.messageId,
dmMessageId: schema.attachments.dmMessageId,
createdAt: schema.attachments.createdAt,
}).from(schema.attachments).all();
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,
thumbnailFilename: r.thumbnailFilename,
size: r.size,
}));
}
/** Attachment records whose message_id or dm_message_id references a deleted message. */
function getDanglingAttachments(): { id: string; filename: string; size: number }[] {
const rawDb = getRawDb();
// Space message attachments with dangling references
const danglingSpace = rawDb.prepare(`
SELECT a.id, a.filename, a.size
FROM attachments a
LEFT JOIN messages m ON a.message_id = m.id
WHERE a.message_id IS NOT NULL AND m.id IS NULL
`).all() as { id: string; filename: string; size: number }[];
// DM message attachments with dangling references
const danglingDm = rawDb.prepare(`
SELECT a.id, a.filename, a.size
FROM attachments a
LEFT JOIN dm_messages dm ON a.dm_message_id = dm.id
WHERE a.dm_message_id IS NOT NULL AND dm.id IS NULL
`).all() as { id: string; filename: string; size: number }[];
return [...danglingSpace, ...danglingDm];
}
export function getStorageStats(): StorageStats {
const diskFiles = getDiskFiles();
const referenced = getReferencedFilenames();
const unlinked = getUnlinkedAttachments();
const dangling = getDanglingAttachments();
const danglingFilenames = new Set<string>();
let danglingSize = 0;
for (const att of dangling) {
danglingFilenames.add(path.basename(att.filename));
danglingSize += att.size;
}
let totalSize = 0;
let referencedSize = 0;
let orphanedFiles = 0;
let orphanedSize = 0;
let danglingFilesOnDisk = 0;
const breakdownMap = new Map<string, { count: number; size: number }>();
for (const file of diskFiles) {
totalSize += file.size;
const type = classifyFile(file.filename);
const entry = breakdownMap.get(type) || { count: 0, size: 0 };
entry.count++;
entry.size += file.size;
breakdownMap.set(type, entry);
if (danglingFilenames.has(file.filename)) {
// File is on disk but its attachment record points to a deleted message
danglingFilesOnDisk++;
} else if (referenced.has(file.filename)) {
referencedSize += file.size;
} else {
orphanedFiles++;
orphanedSize += file.size;
}
}
let unlinkedSize = 0;
for (const att of unlinked) {
unlinkedSize += att.size;
}
const breakdown: StorageBreakdown[] = [];
for (const [type, data] of breakdownMap) {
breakdown.push({ type, count: data.count, size: data.size });
}
breakdown.sort((a, b) => b.size - a.size);
return {
totalFiles: diskFiles.length,
totalSize,
referencedFiles: diskFiles.length - orphanedFiles - danglingFilesOnDisk,
referencedSize,
orphanedFiles,
orphanedSize,
unlinkedAttachments: unlinked.length,
unlinkedSize,
danglingAttachments: dangling.length,
danglingSize,
breakdown,
};
}
export function getOrphanedFiles(): OrphanedFile[] {
const diskFiles = getDiskFiles();
const referenced = getReferencedFilenames();
const orphans: OrphanedFile[] = [];
for (const file of diskFiles) {
if (!referenced.has(file.filename)) {
orphans.push({
filename: file.filename,
size: file.size,
modifiedAt: file.modifiedAt,
});
}
}
orphans.sort((a, b) => b.size - a.size);
return orphans;
}
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;
// Phase 1: Delete orphaned disk files (not referenced by any DB record)
for (const orphan of orphans) {
if (!dryRun) {
try {
deleteUploadFile(orphan.filename);
} catch (err: any) {
errors.push(`Failed to delete ${orphan.filename}: ${err.message}`);
continue;
}
}
deletedFiles++;
freedBytes += orphan.size;
}
// 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))
.run();
} catch (err: any) {
errors.push(`Failed to clean up attachment ${att.id}: ${err.message}`);
continue;
}
}
deletedAttachmentRecords++;
if (!fileInUseByProfile) {
freedBytes += att.size;
}
}
// Phase 3: Delete dangling attachment records (message_id / dm_message_id
// points to a message that no longer exists) and their files from disk.
const dangling = getDanglingAttachments();
for (const att of dangling) {
if (!dryRun) {
try {
deleteUploadFile(att.filename);
db.delete(schema.attachments)
.where(eq(schema.attachments.id, att.id))
.run();
} catch (err: any) {
errors.push(`Failed to clean up dangling attachment ${att.id}: ${err.message}`);
continue;
}
}
deletedFiles++;
freedBytes += att.size;
deletedAttachmentRecords++;
}
return {
dryRun,
deletedFiles,
freedBytes,
deletedAttachmentRecords,
errors,
};
}
export function cleanupOldMedia(maxAgeDays: number, dryRun: boolean): CleanupResult {
const db = getDb();
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
const profileReferenced = getProfileReferencedFilenames();
const errors: string[] = [];
let deletedFiles = 0;
let freedBytes = 0;
let deletedAttachmentRecords = 0;
// Find message attachments older than the cutoff
const rawDb = getRawDb();
const oldMedia = rawDb.prepare(`
SELECT id, filename, size FROM attachments
WHERE (message_id IS NOT NULL OR dm_message_id IS NOT NULL)
AND created_at < ?
`).all(cutoff) as { id: string; filename: string; size: number }[];
for (const att of oldMedia) {
// Never delete files currently used as profile images
if (profileReferenced.has(path.basename(att.filename))) continue;
if (!dryRun) {
try {
deleteUploadFile(att.filename);
db.delete(schema.attachments)
.where(eq(schema.attachments.id, att.id))
.run();
} catch (err: any) {
errors.push(`Failed to delete old media ${att.id}: ${err.message}`);
continue;
}
}
deletedFiles++;
freedBytes += att.size;
deletedAttachmentRecords++;
}
return { dryRun, deletedFiles, freedBytes, deletedAttachmentRecords, errors };
}