File
Blob: src/worker/api/admin.ts
| 1 | import { asc, count, eq, inArray } from "drizzle-orm"; |
| 2 | import type { Hono } from "hono"; |
| 3 | import { |
| 4 | AdminDomainRequest, |
| 5 | AdminDomainStatusRequest, |
| 6 | AdminInboxesResponse, |
| 7 | AdminTempInboxPage, |
| 8 | ErrorResponse, |
| 9 | OkResponse, |
| 10 | } from "@/shared/contracts"; |
| 11 | import { emails, inboxes } from "@/worker/db/schema"; |
| 12 | import { createLogger, errorContext } from "@/worker/logger"; |
| 13 | import { requireAdmin } from "@/worker/middleware/auth"; |
| 14 | import { requireSameOriginForCookieMutations } from "@/worker/middleware/origin"; |
| 15 | import { createAdminDomainsResponse } from "@/worker/serializers/admin"; |
| 16 | import { getPublicErrorMessage } from "@/worker/security"; |
| 17 | import { |
| 18 | addDomain, |
| 19 | deleteDomainByName, |
| 20 | listActiveTemporaryInboxesForAdmin, |
| 21 | listDomainsForAdmin, |
| 22 | updateDomainStatus, |
| 23 | } from "@/worker/services/inbox"; |
| 24 | import type { AppBindings } from "@/worker/types"; |
| 25 | |
| 26 | const logger = createLogger("admin-api"); |
| 27 | |
| 28 | export function registerAdminRoutes(app: Hono<AppBindings>) { |
| 29 | app.get("/api/protected/admin/domains", requireAdmin, async (c) => { |
| 30 | const db = c.get("db"); |
| 31 | const items = await listDomainsForAdmin(c.env, db); |
| 32 | return c.json(createAdminDomainsResponse(items)); |
| 33 | }); |
| 34 | |
| 35 | app.get("/api/protected/admin/temp-inboxes", requireAdmin, async (c) => { |
| 36 | const page = Number.parseInt(c.req.query("page") ?? "0", 10); |
| 37 | const hasEmails = c.req.query("hasEmails") === "true"; |
| 38 | const results = await listActiveTemporaryInboxesForAdmin(c.env, page, undefined, c.get("db"), hasEmails); |
| 39 | |
| 40 | return c.json( |
| 41 | AdminTempInboxPage.create({ |
| 42 | page: results.page, |
| 43 | pageSize: results.pageSize, |
| 44 | total: results.total, |
| 45 | inboxes: results.items.map((item) => ({ |
| 46 | address: item.address, |
| 47 | domain: item.domain, |
| 48 | createdAt: item.createdAt.toISOString(), |
| 49 | expiresAt: item.expiresAt?.toISOString() ?? null, |
| 50 | ttlHours: item.ttlHours, |
| 51 | emailCount: item.emailCount, |
| 52 | })), |
| 53 | }), |
| 54 | ); |
| 55 | }); |
| 56 | |
| 57 | app.post("/api/protected/admin/domains", requireSameOriginForCookieMutations, requireAdmin, async (c) => { |
| 58 | let body; |
| 59 | |
| 60 | try { |
| 61 | body = AdminDomainRequest.assertDecode(await c.req.json()); |
| 62 | } catch { |
| 63 | return c.json(ErrorResponse.create({ error: "A valid domain is required" }), 400); |
| 64 | } |
| 65 | |
| 66 | try { |
| 67 | const db = c.get("db"); |
| 68 | await addDomain(c.env, body.domain, body.isActive ?? true, db); |
| 69 | const items = await listDomainsForAdmin(c.env, db); |
| 70 | return c.json(createAdminDomainsResponse(items), 201); |
| 71 | } catch (error) { |
| 72 | logger.warn("domain_add_failed", "Could not add domain", { |
| 73 | domain: body.domain, |
| 74 | ...errorContext(error), |
| 75 | }); |
| 76 | return c.json(ErrorResponse.create({ error: getPublicErrorMessage(error, "Could not add domain") }), 400); |
| 77 | } |
| 78 | }); |
| 79 | |
| 80 | app.patch("/api/protected/admin/domains/:domain", requireSameOriginForCookieMutations, requireAdmin, async (c) => { |
| 81 | let body; |
| 82 | |
| 83 | try { |
| 84 | body = AdminDomainStatusRequest.assertDecode(await c.req.json()); |
| 85 | } catch { |
| 86 | return c.json(ErrorResponse.create({ error: "A valid active status is required" }), 400); |
| 87 | } |
| 88 | |
| 89 | const domainName = decodeURIComponent(c.req.param("domain")); |
| 90 | |
| 91 | try { |
| 92 | await updateDomainStatus(c.env, domainName, body.isActive, c.get("db")); |
| 93 | return c.json(OkResponse.create({ ok: true })); |
| 94 | } catch (error) { |
| 95 | logger.warn("domain_status_update_failed", "Could not update domain status", { |
| 96 | domain: domainName, |
| 97 | isActive: body.isActive, |
| 98 | ...errorContext(error), |
| 99 | }); |
| 100 | return c.json(ErrorResponse.create({ error: getPublicErrorMessage(error, "Could not update domain") }), 400); |
| 101 | } |
| 102 | }); |
| 103 | |
| 104 | app.delete("/api/protected/admin/domains/:domain", requireSameOriginForCookieMutations, requireAdmin, async (c) => { |
| 105 | const domainName = decodeURIComponent(c.req.param("domain")); |
| 106 | |
| 107 | try { |
| 108 | await deleteDomainByName(c.env, domainName, c.get("db")); |
| 109 | return c.json(OkResponse.create({ ok: true })); |
| 110 | } catch (error) { |
| 111 | const message = getPublicErrorMessage(error, "Could not delete domain"); |
| 112 | logger.warn("domain_delete_failed", "Could not delete domain", { |
| 113 | domain: domainName, |
| 114 | ...errorContext(error), |
| 115 | }); |
| 116 | const status = message.includes("still has inboxes") ? 409 : 400; |
| 117 | return c.json(ErrorResponse.create({ error: message }), status); |
| 118 | } |
| 119 | }); |
| 120 | |
| 121 | app.get("/api/protected/admin/inboxes", requireAdmin, async (c) => { |
| 122 | const db = c.get("db"); |
| 123 | |
| 124 | try { |
| 125 | const items = await db.query.inboxes.findMany({ |
| 126 | where: eq(inboxes.isPermanent, true), |
| 127 | orderBy: [asc(inboxes.domain), asc(inboxes.localPart)], |
| 128 | }); |
| 129 | |
| 130 | const emailCounts = items.length |
| 131 | ? await db |
| 132 | .select({ inboxId: emails.inboxId, emailCount: count() }) |
| 133 | .from(emails) |
| 134 | .where( |
| 135 | inArray( |
| 136 | emails.inboxId, |
| 137 | items.map((item) => item.id), |
| 138 | ), |
| 139 | ) |
| 140 | .groupBy(emails.inboxId) |
| 141 | : []; |
| 142 | |
| 143 | const emailCountByInboxId = new Map(emailCounts.map((row) => [row.inboxId, row.emailCount])); |
| 144 | |
| 145 | return c.json( |
| 146 | AdminInboxesResponse.create({ |
| 147 | inboxes: items.map((item) => ({ |
| 148 | address: item.fullAddress, |
| 149 | domain: item.domain, |
| 150 | localPart: item.localPart, |
| 151 | emailCount: emailCountByInboxId.get(item.id) ?? 0, |
| 152 | })), |
| 153 | }), |
| 154 | ); |
| 155 | } catch (error) { |
| 156 | logger.error("admin_inbox_list_failed", "Could not list permanent inboxes", errorContext(error)); |
| 157 | return c.json(ErrorResponse.create({ error: "Could not list permanent inboxes" }), 500); |
| 158 | } |
| 159 | }); |
| 160 | } |