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 crypto from 'crypto';
|
||||||
import { eq, like, or, and, ne, sql, isNull, isNotNull, gte, lte, asc, desc } from 'drizzle-orm';
|
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 { 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 { getDb, schema } from '../db/index.js';
|
||||||
import { connectionManager } from '../ws/handler.js';
|
import { connectionManager } from '../ws/handler.js';
|
||||||
import { tombstoneUser } from '../utils/userDeletion.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 ────────────────────────────────────────────────────
|
// ─── User Management ────────────────────────────────────────────────────
|
||||||
|
|
||||||
// GET /api/admin/users — paginated user list with search
|
// GET /api/admin/users — paginated user list with search
|
||||||
|
|||||||
@@ -339,3 +339,43 @@ export function cleanupStorage(dryRun: boolean): CleanupResult {
|
|||||||
errors,
|
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>;
|
storageStats: () => Promise<StorageStats>;
|
||||||
storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>;
|
storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>;
|
||||||
storageCleanup: (dryRun?: boolean) => Promise<CleanupResult>;
|
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>;
|
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[] }>;
|
listInstances: () => Promise<{ instances: string[] }>;
|
||||||
setUserRole: (userId: string, isAdmin: boolean) => Promise<AdminUser>;
|
setUserRole: (userId: string, isAdmin: boolean) => Promise<AdminUser>;
|
||||||
@@ -578,6 +579,8 @@ export class BackspaceApiClient {
|
|||||||
storageStats: () => request<StorageStats>('GET', '/admin/storage/stats'),
|
storageStats: () => request<StorageStats>('GET', '/admin/storage/stats'),
|
||||||
storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'),
|
storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'),
|
||||||
storageCleanup: (dryRun = false) => request<CleanupResult>('POST', '/admin/storage/cleanup', { dryRun }),
|
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) => {
|
listUsers: (params) => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params?.q) qs.set('q', params.q);
|
if (params?.q) qs.set('q', params.q);
|
||||||
|
|||||||
Reference in New Issue
Block a user