import { timingSafeEqual } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { saveActiveTables } from "../services/state.js"; function safeEqual(a: string, b: string): boolean { const ba = Buffer.from(a || ""); const bb = Buffer.from(b || ""); if (ba.length !== bb.length) return false; return timingSafeEqual(ba, bb); } /** * Admin actions for the /crush33 area — all gated by the same PORTAL_PASSWORD * that unlocks the portal. POST-only so the password never lands in a URL/log. * * POST /api/admin/status -> current event tables + record counts * POST /api/admin/wipe -> delete all ticket + audit records * POST /api/admin/switch-table -> point the app at different event table(s) * POST /api/admin/donor-search -> admin-only donor directory search (PII) */ export async function adminRoutes(app: FastifyInstance): Promise { const cfg = app.ctx.config; const gate = (req: any, reply: any): boolean => { if (!cfg.PORTAL_PASSWORD) { reply.code(404).send({ error: "admin_disabled" }); return false; } const pw = (req.body ?? {}).password; if (typeof pw !== "string" || !safeEqual(pw, cfg.PORTAL_PASSWORD)) { reply.code(401).send({ error: "bad_password" }); return false; } return true; }; const rl = { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }; app.post("/api/admin/status", rl, async (req, reply) => { if (!gate(req, reply)) return; const [tickets, audit] = await Promise.all([ app.ctx.nocodb.count().catch(() => -1), app.ctx.audit.count().catch(() => -1), ]); return { tickets: { tableId: app.ctx.nocodb.tableId, count: tickets }, audit: { tableId: app.ctx.audit.currentTableId, count: audit, enabled: app.ctx.audit.enabled }, // What .env would use if the override were cleared (for reference). defaults: { ticketsTableId: cfg.NOCODB_TABLE_ID, auditTableId: cfg.NOCODB_AUDIT_TABLE_ID ?? null }, }; }); app.post("/api/admin/wipe", rl, async (req, reply) => { if (!gate(req, reply)) return; let ticketsDeleted = 0; let auditDeleted = 0; try { ticketsDeleted = await app.ctx.nocodb.deleteAll(); } catch (e: any) { return reply.code(502).send({ error: "wipe_failed", detail: e?.message }); } try { auditDeleted = await app.ctx.audit.deleteAll(); } catch { // Audit wipe is best-effort; tickets are the important part. } req.log.warn({ ticketsDeleted, auditDeleted }, "admin: wiped slate"); return { ok: true, ticketsDeleted, auditDeleted }; }); app.post("/api/admin/switch-table", rl, async (req, reply) => { if (!gate(req, reply)) return; const b = (req.body ?? {}) as { ticketsTableId?: string; auditTableId?: string }; const ticketsTableId = String(b.ticketsTableId ?? "").trim(); const auditTableId = String(b.auditTableId ?? "").trim(); if (!ticketsTableId) { return reply.code(400).send({ error: "missing_tickets_table" }); } // Validate the new tickets table is reachable and has an Id primary key — // switching to a PK-less table would make check-in updates hit every row. const probe = await app.ctx.nocodb.probeTable(ticketsTableId); if (!probe.ok) { return reply.code(400).send({ error: "tickets_table_unreachable", status: probe.status }); } if (!probe.hasIdPk) { return reply.code(400).send({ error: "tickets_table_no_id_pk" }); } if (auditTableId) { const ap = await app.ctx.nocodb.probeTable(auditTableId); if (!ap.ok) return reply.code(400).send({ error: "audit_table_unreachable", status: ap.status }); } // Hot-swap the live clients, then persist so it survives a redeploy. app.ctx.nocodb.setTableId(ticketsTableId); app.ctx.audit.setTableId(auditTableId || app.ctx.audit.currentTableId); saveActiveTables(cfg.STATE_DIR, { ticketsTableId, auditTableId: auditTableId || app.ctx.audit.currentTableId || undefined, }); req.log.warn({ ticketsTableId, auditTableId }, "admin: switched event table"); return { ok: true, tickets: { tableId: app.ctx.nocodb.tableId }, audit: { tableId: app.ctx.audit.currentTableId }, }; }); app.post("/api/admin/donor-search", rl, async (req, reply) => { if (!gate(req, reply)) return; if (!app.ctx.donors.enabled) return reply.code(404).send({ error: "donors_unavailable" }); const q = String(((req.body ?? {}) as { query?: string }).query ?? "").trim(); if (q.length < 2) return { results: [], query: q }; try { const results = await app.ctx.donors.search(q, 40); return { results, query: q }; } catch (e: any) { req.log.error({ err: e }, "admin: donor search failed"); return reply.code(502).send({ error: "search_failed", detail: e?.message }); } }); }