From 0772b61de800bcb9d9cd541da6e576b2aa525033 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 10 May 2026 01:20:12 +0800 Subject: [PATCH] feat(api): add admin storage management endpoints (stats, list, delete, cleanup) --- lib/api/admin-storage.ts | 321 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 lib/api/admin-storage.ts diff --git a/lib/api/admin-storage.ts b/lib/api/admin-storage.ts new file mode 100644 index 0000000..b65d353 --- /dev/null +++ b/lib/api/admin-storage.ts @@ -0,0 +1,321 @@ +import { Elysia, t } from "elysia" +import { db } from "@/lib/db/index" +import { storageObjects, user, games } from "@/lib/db/schema" +import { eq, sql, and, ilike, desc } from "drizzle-orm" +import { requireAdmin } from "@/lib/auth/guard" +import { deleteObject, isR2Configured } from "@/lib/storage" + +export const adminStorageRoutes = new Elysia({ prefix: "/admin/storage" }) + + // ── GET /stats ────────────────────────────────────────────────── + .get( + "/stats", + async ({ request, set }) => { + const guard = await requireAdmin(request.headers) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const [totalResult] = await db + .select({ count: sql`count(*)::int` }) + .from(storageObjects) + + const [orphanedResult] = await db + .select({ count: sql`count(*)::int` }) + .from(storageObjects) + .where(eq(storageObjects.isOrphaned, true)) + + const byEntityType = await db + .select({ + entityType: storageObjects.entityType, + count: sql`count(*)::int`, + }) + .from(storageObjects) + .groupBy(storageObjects.entityType) + + const [totalSizeResult] = await db + .select({ total: sql`coalesce(sum(${storageObjects.size}), 0)::int` }) + .from(storageObjects) + + return { + total: totalResult?.count ?? 0, + orphaned: orphanedResult?.count ?? 0, + totalSizeBytes: totalSizeResult?.total ?? 0, + byEntityType: byEntityType.reduce>( + (acc, row) => { + acc[row.entityType] = row.count + return acc + }, + {}, + ), + } + }, + ) + + // ── GET /objects ───────────────────────────────────────────────── + .get( + "/objects", + async ({ query, request, set }) => { + const guard = await requireAdmin(request.headers) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const limit = Math.min(Number(query.limit) || 20, 100) + const offset = Number(query.offset) || 0 + + const conditions: (ReturnType | ReturnType | ReturnType)[] = [] + + if (query.entityType) { + conditions.push(eq(storageObjects.entityType, query.entityType)) + } + + if (query.orphaned === "true") { + conditions.push(eq(storageObjects.isOrphaned, true)) + } else if (query.orphaned === "false") { + conditions.push(eq(storageObjects.isOrphaned, false)) + } + + if (query.search) { + conditions.push(ilike(storageObjects.key, `%${query.search}%`)) + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + + const baseQuery = db + .select({ + id: storageObjects.id, + key: storageObjects.key, + bucket: storageObjects.bucket, + size: storageObjects.size, + mimeType: storageObjects.mimeType, + entityType: storageObjects.entityType, + entityId: storageObjects.entityId, + uploadedBy: storageObjects.uploadedBy, + createdAt: storageObjects.createdAt, + lastAccessedAt: storageObjects.lastAccessedAt, + isOrphaned: storageObjects.isOrphaned, + uploaderName: user.name, + }) + .from(storageObjects) + .innerJoin(user, eq(storageObjects.uploadedBy, user.id)) + .orderBy(desc(storageObjects.createdAt)) + + const items = whereClause + ? await baseQuery.where(whereClause).limit(limit).offset(offset) + : await baseQuery.limit(limit).offset(offset) + + const countQuery = db + .select({ count: sql`count(*)::int` }) + .from(storageObjects) + .innerJoin(user, eq(storageObjects.uploadedBy, user.id)) + + const countResult = whereClause + ? await countQuery.where(whereClause) + : await countQuery + + const total = countResult[0]?.count ?? 0 + + return { + data: items.map((item) => ({ + ...item, + createdAt: item.createdAt.toISOString(), + lastAccessedAt: item.lastAccessedAt?.toISOString() ?? null, + })), + total, + limit, + offset, + } + }, + { + query: t.Object({ + limit: t.Optional(t.String()), + offset: t.Optional(t.String()), + entityType: t.Optional(t.String()), + orphaned: t.Optional(t.Union([t.Literal("true"), t.Literal("false")])), + search: t.Optional(t.String()), + }), + }, + ) + + // ── DELETE /objects/:id ────────────────────────────────────────── + .delete( + "/objects/:id", + async ({ params, request, set }) => { + const guard = await requireAdmin(request.headers) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const [obj] = await db + .select() + .from(storageObjects) + .where(eq(storageObjects.id, params.id)) + .limit(1) + + if (!obj) { + set.status = 404 + return { error: "Storage object not found" } + } + + // Delete from R2 (best-effort; don't block on R2 failure) + if (isR2Configured()) { + try { + await deleteObject(obj.key) + } catch { + console.warn(`Failed to delete object from R2: ${obj.key}`) + } + } + + // Clear entity references for known entity types + if (obj.entityType === "avatar" && obj.entityId) { + try { + await db + .update(user) + .set({ image: null, updatedAt: new Date() }) + .where(eq(user.id, obj.entityId)) + } catch { + // User may have been deleted already; skip silently + } + } else if (obj.entityType === "game_cover" && obj.entityId) { + try { + await db + .update(games) + .set({ headerImage: null, updatedAt: new Date() }) + .where(eq(games.id, obj.entityId)) + } catch { + // Game may have been deleted already; skip silently + } + } + + // Delete the DB record + await db + .delete(storageObjects) + .where(eq(storageObjects.id, params.id)) + + return { success: true, deleted: { id: obj.id, key: obj.key } } + }, + { + params: t.Object({ id: t.String() }), + }, + ) + + // ── POST /cleanup ─────────────────────────────────────────────── + .post( + "/cleanup", + async ({ request, set }) => { + const guard = await requireAdmin(request.headers) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + if (!isR2Configured()) { + set.status = 503 + return { error: "Storage (R2) not configured" } + } + + // 1. Mark orphaned avatars (entityId not in user table) + const avatarOrphans = await db.execute(sql` + UPDATE storage_objects + SET is_orphaned = true + WHERE entity_type = 'avatar' + AND is_orphaned = false + AND entity_id NOT IN (SELECT id FROM "user") + `) + + // 2. Mark orphaned game covers (entityId not in games table) + const gameCoverOrphans = await db.execute(sql` + UPDATE storage_objects + SET is_orphaned = true + WHERE entity_type = 'game_cover' + AND is_orphaned = false + AND entity_id NOT IN (SELECT id FROM games) + `) + + // 3. Fetch all now-orphaned objects for deletion + const orphaned = await db + .select() + .from(storageObjects) + .where(eq(storageObjects.isOrphaned, true)) + + let deletedFromR2 = 0 + let r2Errors = 0 + + // 4. Delete from R2 in batches of 50 + for (let i = 0; i < orphaned.length; i += 50) { + const batch = orphaned.slice(i, i + 50) + const results = await Promise.allSettled( + batch.map(async (obj) => { + await deleteObject(obj.key) + }), + ) + for (const result of results) { + if (result.status === "fulfilled") { + deletedFromR2++ + } else { + r2Errors++ + } + } + } + + // 5. Clear entity references for orphaned objects that still reference entities + // (These are orphans where the entity was deleted but reference wasn't cleared) + const orphanedAvatars = orphaned.filter((o) => o.entityType === "avatar" && o.entityId) + const orphanedGameCovers = orphaned.filter((o) => o.entityType === "game_cover" && o.entityId) + + // Clear avatar references + if (orphanedAvatars.length > 0) { + const avatarEntityIds = [...new Set(orphanedAvatars.map((o) => o.entityId!))] + // Only clear for users that still exist + for (const userId of avatarEntityIds) { + try { + await db + .update(user) + .set({ image: null, updatedAt: new Date() }) + .where(eq(user.id, userId)) + } catch { + // User may not exist; skip + } + } + } + + // Clear game cover references + if (orphanedGameCovers.length > 0) { + const gameIds = [...new Set(orphanedGameCovers.map((o) => o.entityId!))] + for (const gameId of gameIds) { + try { + await db + .update(games) + .set({ headerImage: null, updatedAt: new Date() }) + .where(eq(games.id, gameId)) + } catch { + // Game may not exist; skip + } + } + } + + // 6. Delete DB records + if (orphaned.length > 0) { + await db + .delete(storageObjects) + .where(eq(storageObjects.isOrphaned, true)) + } + + return { + success: true, + marked: { + avatars: avatarOrphans.rowCount ?? 0, + gameCovers: gameCoverOrphans.rowCount ?? 0, + }, + deleted: { + fromR2: deletedFromR2, + r2Errors, + fromDb: orphaned.length, + }, + } + }, + ) \ No newline at end of file