diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts index f979b1da..4ed20ba3 100644 --- a/packages/server/src/routes/admin.ts +++ b/packages/server/src/routes/admin.ts @@ -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 { } }); + // 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 diff --git a/packages/server/src/utils/storageJanitor.ts b/packages/server/src/utils/storageJanitor.ts index 54cc663f..c98b335b 100644 --- a/packages/server/src/utils/storageJanitor.ts +++ b/packages/server/src/utils/storageJanitor.ts @@ -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 }; +} diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index a0388f31..6522913c 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -201,6 +201,7 @@ export class BackspaceApiClient { storageStats: () => Promise; storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>; storageCleanup: (dryRun?: boolean) => Promise; + cleanupOldMedia: (maxAgeDays: number, dryRun?: boolean) => Promise; listUsers: (params?: { q?: string; page?: number; pageSize?: number; showDeleted?: boolean; homeInstance?: string; role?: string; joinedAfter?: string; joinedBefore?: string; sort?: string }) => Promise; listInstances: () => Promise<{ instances: string[] }>; setUserRole: (userId: string, isAdmin: boolean) => Promise; @@ -578,6 +579,8 @@ export class BackspaceApiClient { storageStats: () => request('GET', '/admin/storage/stats'), storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'), storageCleanup: (dryRun = false) => request('POST', '/admin/storage/cleanup', { dryRun }), + cleanupOldMedia: (maxAgeDays: number, dryRun = false) => + request('POST', '/admin/storage/cleanup-media', { maxAgeDays, dryRun }), listUsers: (params) => { const qs = new URLSearchParams(); if (params?.q) qs.set('q', params.q);