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.
This commit is contained in:
Jannis Braun
2026-03-23 02:20:28 +01:00
parent eb743ca2ca
commit ee11408060
3 changed files with 59 additions and 1 deletions
+16 -1
View File
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
import crypto from 'crypto';
import { eq, like, or, and, ne, sql, isNull, isNotNull, gte, lte, asc, desc } from 'drizzle-orm';
import { authenticate, requireAdmin, hashPassword } from '../utils/auth.js';
import { getStorageStats, getOrphanedFiles, cleanupStorage } from '../utils/storageJanitor.js';
import { getStorageStats, getOrphanedFiles, cleanupStorage, cleanupOldMedia } from '../utils/storageJanitor.js';
import { getDb, schema } from '../db/index.js';
import { connectionManager } from '../ws/handler.js';
import { tombstoneUser } from '../utils/userDeletion.js';
@@ -59,6 +59,21 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
}
});
// POST /api/admin/storage/cleanup-media — delete chat media older than N days
app.post<{ Body: { maxAgeDays: number; dryRun?: boolean } }>('/api/admin/storage/cleanup-media', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
try {
const maxAgeDays = Number(request.body?.maxAgeDays);
if (isNaN(maxAgeDays) || maxAgeDays < 1) {
return reply.code(400).send({ error: 'maxAgeDays must be a positive number', statusCode: 400 });
}
const dryRun = request.body?.dryRun ?? false;
const result = cleanupOldMedia(maxAgeDays, dryRun);
return reply.code(200).send(result);
} catch (err: any) {
return reply.code(500).send({ error: `Media cleanup failed: ${err.message}`, statusCode: 500 });
}
});
// ─── User Management ────────────────────────────────────────────────────
// GET /api/admin/users — paginated user list with search
@@ -339,3 +339,43 @@ export function cleanupStorage(dryRun: boolean): CleanupResult {
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 };
}