Skip to content
File

Blob: src/worker/api/admin.ts

typescript161 lines
1import { asc, count, eq, inArray } from "drizzle-orm";
2import type { Hono } from "hono";
3import {
4 AdminDomainRequest,
5 AdminDomainStatusRequest,
6 AdminInboxesResponse,
7 AdminTempInboxPage,
8 ErrorResponse,
9 OkResponse,
10} from "@/shared/contracts";
11import { emails, inboxes } from "@/worker/db/schema";
12import { createLogger, errorContext } from "@/worker/logger";
13import { requireAdmin } from "@/worker/middleware/auth";
14import { requireSameOriginForCookieMutations } from "@/worker/middleware/origin";
15import { createAdminDomainsResponse } from "@/worker/serializers/admin";
16import { getPublicErrorMessage } from "@/worker/security";
17import {
18 addDomain,
19 deleteDomainByName,
20 listActiveTemporaryInboxesForAdmin,
21 listDomainsForAdmin,
22 updateDomainStatus,
23} from "@/worker/services/inbox";
24import type { AppBindings } from "@/worker/types";
25 
26const logger = createLogger("admin-api");
27 
28export 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}