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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -201,6 +201,7 @@ export class BackspaceApiClient {
|
||||
storageStats: () => Promise<StorageStats>;
|
||||
storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>;
|
||||
storageCleanup: (dryRun?: boolean) => Promise<CleanupResult>;
|
||||
cleanupOldMedia: (maxAgeDays: number, dryRun?: boolean) => Promise<CleanupResult>;
|
||||
listUsers: (params?: { q?: string; page?: number; pageSize?: number; showDeleted?: boolean; homeInstance?: string; role?: string; joinedAfter?: string; joinedBefore?: string; sort?: string }) => Promise<AdminUserListResponse>;
|
||||
listInstances: () => Promise<{ instances: string[] }>;
|
||||
setUserRole: (userId: string, isAdmin: boolean) => Promise<AdminUser>;
|
||||
@@ -578,6 +579,8 @@ export class BackspaceApiClient {
|
||||
storageStats: () => request<StorageStats>('GET', '/admin/storage/stats'),
|
||||
storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'),
|
||||
storageCleanup: (dryRun = false) => request<CleanupResult>('POST', '/admin/storage/cleanup', { dryRun }),
|
||||
cleanupOldMedia: (maxAgeDays: number, dryRun = false) =>
|
||||
request<CleanupResult>('POST', '/admin/storage/cleanup-media', { maxAgeDays, dryRun }),
|
||||
listUsers: (params) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.q) qs.set('q', params.q);
|
||||
|
||||
Reference in New Issue
Block a user