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, cleanupOldMedia, cleanupStaleTusSessions } from '../utils/storageJanitor.js'; import { getDb, schema } from '../db/index.js'; import { connectionManager } from '../ws/handler.js'; import { tombstoneUser, collectDeletionBroadcastTargets } from '../utils/userDeletion.js'; import { deleteUploadFile } from '../utils/fileCleanup.js'; import { sanitizeUser } from '../utils/sanitize.js'; import type { AdminUser, AdminUserListResponse, AdminResetPasswordResponse } from '@backspace/shared'; function toAdminUser(row: typeof schema.users.$inferSelect): AdminUser { return { id: row.id, username: row.username, displayName: row.displayName, avatar: row.avatar, avatarColor: row.avatarColor, status: row.status ?? 'offline', isAdmin: row.isAdmin === 1, isDeleted: row.isDeleted === 1, homeInstance: row.homeInstance, createdAt: row.createdAt, }; } export async function adminRoutes(app: FastifyInstance): Promise { // ─── Storage Management ────────────────────────────────────────────────── // GET /api/admin/storage/stats — storage overview app.get('/api/admin/storage/stats', { preHandler: [authenticate, requireAdmin] }, async (_request, reply) => { try { const stats = getStorageStats(); return reply.code(200).send(stats); } catch (err: any) { return reply.code(500).send({ error: `Failed to compute storage stats: ${err.message}`, statusCode: 500 }); } }); // GET /api/admin/storage/orphans — list orphaned files app.get('/api/admin/storage/orphans', { preHandler: [authenticate, requireAdmin] }, async (_request, reply) => { try { const orphans = getOrphanedFiles(); return reply.code(200).send({ orphans }); } catch (err: any) { return reply.code(500).send({ error: `Failed to list orphaned files: ${err.message}`, statusCode: 500 }); } }); // POST /api/admin/storage/cleanup — delete orphaned files app.post<{ Body: { dryRun?: boolean } }>('/api/admin/storage/cleanup', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => { try { const dryRun = request.body?.dryRun ?? false; const result = cleanupStorage(dryRun); return reply.code(200).send(result); } catch (err: any) { return reply.code(500).send({ error: `Cleanup failed: ${err.message}`, statusCode: 500 }); } }); // 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 }); } }); // POST /api/admin/storage/cleanup-tus — admin-driven sweep of stale tus // upload sessions. Defaults: maxAgeHours=1 (matches the staleTusSessions // display threshold), dryRun=false. Per-file unlink errors are surfaced via // CleanupResult.errors. No DB rows touched — `.tus/` is filesystem-only. app.post<{ Body: { maxAgeHours?: number; dryRun?: boolean } }>('/api/admin/storage/cleanup-tus', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => { try { const rawAge = request.body?.maxAgeHours; const maxAgeHours = rawAge === undefined ? 1 : Number(rawAge); if (!Number.isFinite(maxAgeHours) || maxAgeHours <= 0) { return reply.code(400).send({ error: 'maxAgeHours must be a positive finite number', statusCode: 400 }); } const dryRun = request.body?.dryRun ?? false; const thresholdMs = maxAgeHours * 60 * 60 * 1000; const result = cleanupStaleTusSessions(thresholdMs, dryRun); return reply.code(200).send({ dryRun, deletedFiles: result.deletedFiles, freedBytes: result.freedBytes, deletedAttachmentRecords: 0, errors: result.errors, }); } catch (err: any) { return reply.code(500).send({ error: `Tus cleanup failed: ${err.message}`, statusCode: 500 }); } }); // ─── User Management ──────────────────────────────────────────────────── // GET /api/admin/users — paginated user list with search app.get<{ Querystring: { q?: string; page?: string; pageSize?: string; showDeleted?: string; homeInstance?: string; role?: string; joinedAfter?: string; joinedBefore?: string; sort?: string } }>( '/api/admin/users', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => { const db = getDb(); const q = request.query.q?.trim() || ''; const page = Math.max(1, parseInt(request.query.page || '1', 10) || 1); const pageSize = Math.min(100, Math.max(1, parseInt(request.query.pageSize || '50', 10) || 50)); const showDeleted = request.query.showDeleted === 'true'; const conditions = []; if (!showDeleted) { conditions.push(eq(schema.users.isDeleted, 0)); } if (q) { const pattern = `%${q}%`; conditions.push(or( like(schema.users.username, pattern), like(schema.users.displayName, pattern), )!); } // Instance filter const homeInstanceFilter = request.query.homeInstance; if (homeInstanceFilter === 'local') { conditions.push(isNull(schema.users.homeInstance)); } else if (homeInstanceFilter) { conditions.push(eq(schema.users.homeInstance, homeInstanceFilter)); } // Role filter const roleFilter = request.query.role; if (roleFilter === 'admin') { conditions.push(eq(schema.users.isAdmin, 1)); } else if (roleFilter === 'non-admin') { conditions.push(eq(schema.users.isAdmin, 0)); } // Date range filter const joinedAfter = request.query.joinedAfter; if (joinedAfter) { const ts = new Date(joinedAfter).getTime(); if (!isNaN(ts)) conditions.push(gte(schema.users.createdAt, ts)); } const joinedBefore = request.query.joinedBefore; if (joinedBefore) { const ts = new Date(joinedBefore + 'T23:59:59.999Z').getTime(); if (!isNaN(ts)) conditions.push(lte(schema.users.createdAt, ts)); } const where = conditions.length > 0 ? and(...conditions) : undefined; // Dynamic sort const sortParam = request.query.sort || 'newest'; let orderByClause; switch (sortParam) { case 'oldest': orderByClause = asc(schema.users.createdAt); break; case 'az': orderByClause = asc(schema.users.username); break; case 'za': orderByClause = desc(schema.users.username); break; default: orderByClause = desc(schema.users.createdAt); break; } const countResult = db.select({ count: sql`count(*)` }) .from(schema.users) .where(where) .get(); const total = countResult?.count ?? 0; const rows = db.select() .from(schema.users) .where(where) .orderBy(orderByClause) .limit(pageSize) .offset((page - 1) * pageSize) .all(); const response: AdminUserListResponse = { users: rows.map(toAdminUser), total, page, pageSize, }; return reply.code(200).send(response); }, ); // GET /api/admin/users/instances — distinct home instance domains app.get('/api/admin/users/instances', { preHandler: [authenticate, requireAdmin] }, async (_request, reply) => { const db = getDb(); const rows = db.selectDistinct({ homeInstance: schema.users.homeInstance }) .from(schema.users) .where(isNotNull(schema.users.homeInstance)) .all(); const instances = rows.map(r => r.homeInstance).filter(Boolean) as string[]; return reply.code(200).send({ instances }); }); // PATCH /api/admin/users/:id/role — promote/demote admin app.patch<{ Params: { id: string }; Body: { isAdmin: boolean } }>( '/api/admin/users/:id/role', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => { const { id: targetId } = request.params; const { isAdmin } = request.body; if (typeof isAdmin !== 'boolean') { return reply.code(400).send({ error: 'isAdmin must be a boolean', statusCode: 400 }); } const db = getDb(); const target = db.select().from(schema.users).where(eq(schema.users.id, targetId)).get(); if (!target) { return reply.code(404).send({ error: 'User not found', statusCode: 404 }); } if (target.isDeleted === 1) { return reply.code(400).send({ error: 'Cannot change role of a deleted user', statusCode: 400 }); } // Promote: block federated users if (isAdmin && target.homeInstance) { return reply.code(403).send({ error: 'Federated users cannot be promoted to admin', statusCode: 403 }); } // Demote: prevent removing the last admin if (!isAdmin && target.isAdmin === 1) { const adminCount = db.select({ count: sql`count(*)` }) .from(schema.users) .where(and(eq(schema.users.isAdmin, 1), eq(schema.users.isDeleted, 0))) .get(); if ((adminCount?.count ?? 0) <= 1) { return reply.code(400).send({ error: 'Cannot demote the last admin', statusCode: 400 }); } } db.update(schema.users) .set({ isAdmin: isAdmin ? 1 : 0 }) .where(eq(schema.users.id, targetId)) .run(); const updated = db.select().from(schema.users).where(eq(schema.users.id, targetId)).get()!; // Broadcast user_updated so the target's UI reflects the isAdmin change connectionManager.sendToUser(targetId, { type: 'user_updated', user: sanitizeUser(updated), }); return reply.code(200).send(toAdminUser(updated)); }, ); // POST /api/admin/users/:id/reset-password — generate temporary password app.post<{ Params: { id: string } }>( '/api/admin/users/:id/reset-password', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => { const { id: targetId } = request.params; const db = getDb(); const target = db.select().from(schema.users).where(eq(schema.users.id, targetId)).get(); if (!target) { return reply.code(404).send({ error: 'User not found', statusCode: 404 }); } if (target.isDeleted === 1) { return reply.code(400).send({ error: 'Cannot reset password of a deleted user', statusCode: 400 }); } if (target.homeInstance) { return reply.code(400).send({ error: 'Federated users authenticate via their home instance', statusCode: 400 }); } const temporaryPassword = crypto.randomBytes(12).toString('base64url'); const hash = await hashPassword(temporaryPassword); db.update(schema.users) .set({ passwordHash: hash, passwordChangedAt: Date.now() }) .where(eq(schema.users.id, targetId)) .run(); // Force re-auth by disconnecting all sessions connectionManager.forceDisconnectUser(targetId); const response: AdminResetPasswordResponse = { temporaryPassword }; return reply.code(200).send(response); }, ); // DELETE /api/admin/users/:id — tombstone a user account app.delete<{ Params: { id: string } }>( '/api/admin/users/:id', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => { const { id: targetId } = request.params; const db = getDb(); if (targetId === request.userId) { return reply.code(400).send({ error: 'Use account settings to delete your own account', statusCode: 400 }); } const target = db.select().from(schema.users).where(eq(schema.users.id, targetId)).get(); if (!target) { return reply.code(404).send({ error: 'User not found', statusCode: 404 }); } if (target.isDeleted === 1) { return reply.code(400).send({ error: 'User is already deleted', statusCode: 400 }); } // Check if user owns any spaces const ownedSpaces = db.select({ id: schema.spaces.id, name: schema.spaces.name }) .from(schema.spaces) .where(eq(schema.spaces.ownerId, targetId)) .all(); if (ownedSpaces.length > 0) { return reply.code(400).send({ error: 'User owns spaces — transfer ownership first', statusCode: 400, ownedSpaces, }); } // Collect broadcast targets BEFORE tombstone deletes DB rows const { memberSpaceIds, targetUserIds } = collectDeletionBroadcastTargets(targetId); const filesToDelete = tombstoneUser(targetId); for (const filename of filesToDelete) { deleteUploadFile(filename); } // Broadcast member_left to each space for (const spaceId of memberSpaceIds) { connectionManager.sendToSpace(spaceId, { type: 'member_left', spaceId, userId: targetId, }); } // Broadcast user_updated with sanitized deleted user data const deletedRow = getDb().select().from(schema.users).where(eq(schema.users.id, targetId)).get(); if (deletedRow) { const deletedUser = sanitizeUser(deletedRow); const userUpdatedEvent = { type: 'user_updated' as const, user: deletedUser }; for (const uid of targetUserIds) { connectionManager.sendToUser(uid, userUpdatedEvent); } } connectionManager.forceDisconnectUser(targetId); return reply.code(200).send({ success: true }); }, ); // Test-only: seed federation_peers row directly. STRICTLY gated by NODE_ENV='test' // AND ENABLE_TEST_ROUTES='1'. Returns 404 in any other configuration. Used by // the two-instance integration harness to wire up matching HMAC secrets between // home and remote without running the multi-step approval flow. app.post<{ Body: { origin: string; hmacSecret: string; status?: string; instanceName?: string } }>( '/api/admin/test/seed-peer', async (request, reply) => { if (process.env.NODE_ENV !== 'test' || process.env.ENABLE_TEST_ROUTES !== '1') { return reply.code(404).send({ error: 'Not Found', statusCode: 404 }); } const { origin, hmacSecret, status, instanceName } = request.body; if (typeof origin !== 'string' || !origin.startsWith('http')) { return reply.code(400).send({ error: 'origin must be an http(s) URL', statusCode: 400 }); } if (typeof hmacSecret !== 'string' || hmacSecret.length < 32) { return reply.code(400).send({ error: 'hmacSecret must be a string ≥32 chars', statusCode: 400 }); } const validStatuses = new Set(['active', 'pending', 'awaiting_approval', 'rejected', 'revoked', 'needs_attention', 'unreachable', 'accepted']); if (status !== undefined && !validStatuses.has(status)) { return reply.code(400).send({ error: `status must be one of ${[...validStatuses].join(', ')}`, statusCode: 400 }); } const db = getDb(); const id = crypto.randomUUID(); db.insert(schema.federationPeers) .values({ id, origin, instanceName: instanceName ?? null, hmacSecret, status: status ?? 'active', consecutiveFailures: 0, consecutiveAuthFailures: 0, lastSyncedAt: 0, nonceSupported: 1, autoRotateIntervalDays: 90, createdAt: Date.now(), }) .onConflictDoUpdate({ target: schema.federationPeers.origin, set: { hmacSecret, status: status ?? 'active', instanceName: instanceName ?? null }, }) .run(); return reply.code(200).send({ ok: true, id }); }, ); }