/** * Console 管理路由(Node / Domain / Mailbox / Email)。 * 全部要求会话登录。 */ import { Hono } from "hono"; import type { Env, AppVars } from "../env.ts"; import { requireSession } from "../middleware.ts"; import { createNode, emailWorkerNameFor, loadSteps } from "../services/provision.ts"; import { upsertMailbox, upsertDomain } from "../services/provision.ts"; import { decryptSecret } from "../db.ts"; import { ids } from "../../../shared/src/index.ts"; import { checkWebhookUrl } from "../../../shared/src/index.ts"; import { nowIso, pageParams, readJson } from "../util.ts"; type App = Hono<{ Bindings: Env; Variables: AppVars }>; /** # 函数 * 装配 Node / Domain / Mailbox / Email 管理路由 */ export function resourceRoutes(): App { const app = new Hono<{ Bindings: Env; Variables: AppVars }>(); app.use("*", requireSession); installNodeRoutes(app); installDomainRoutes(app); installMailboxRoutes(app); installEmailRoutes(app); return app; } /** # 函数 * Node CRUD 与安装状态 */ function installNodeRoutes(app: App) { /** Node 列表(不含任何 secret) */ app.get("/nodes", async (c) => { const { limit, offset } = pageParams(new URL(c.req.url)); const rows = await c.env.DB.prepare( `SELECT id, name, cloudflare_account_id, credential_id, status, version, last_seen_at, ingress_url, created_at, updated_at FROM nodes WHERE status != 'revoked' ORDER BY created_at DESC LIMIT ? OFFSET ?`, ) .bind(limit, offset) .all(); const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'").first<{ n: number }>(); return c.json({ items: rows.results, total: total?.n ?? 0 }); }); /** Add Node:创建节点,返回 enrollment token(仅此一次展示)与 OAuth 授权入口信息 */ app.post("/nodes", async (c) => { const body = await readJson<{ name?: string; ingress_url?: string }>(c.req.raw); const name = (body.name ?? "").trim(); if (!name) return c.json({ error: "name required" }, 400); const ingressUrl = (body.ingress_url ?? "http://127.0.0.1:8788/ingress/email").trim(); try { new URL(ingressUrl); } catch { return c.json({ error: "invalid ingress_url" }, 400); } const created = await createNode(c.env, { name, ingressUrl }); return c.json({ ok: true, node_id: created.nodeId, enrollment_token: created.enrollmentToken, ingress_secret: created.ingressSecret, worker_name: emailWorkerNameFor(created.nodeId), }, 201); }); /** Node 详情:含安装步骤状态与 OAuth 连接状态 */ app.get("/nodes/:id", async (c) => { const id = c.req.param("id"); const node = await c.env.DB.prepare( `SELECT id, name, cloudflare_account_id, credential_id, status, version, last_seen_at, ingress_url, enrollment_expires_at, created_at, updated_at FROM nodes WHERE id = ?`, ) .bind(id) .first(); if (!node) return c.json({ error: "not found" }, 404); const steps = await loadSteps(c.env, id); const oauth = await c.env.DB.prepare( "SELECT id, provider, account_id, expires_at, created_at FROM oauth_connections ORDER BY created_at DESC LIMIT 1", ).first(); const domains = await c.env.DB.prepare("SELECT id, name, status, worker_id FROM domains WHERE node_id = ?") .bind(id) .all(); return c.json({ node, steps, oauth: oauth ?? null, domains: domains.results }); }); /** 删除 Node(软删:撤销 credential + 标记 revoked) */ app.delete("/nodes/:id", async (c) => { const id = c.req.param("id"); await c.env.DB.prepare( "UPDATE node_credentials SET status = 'revoked', revoked_at = ? WHERE node_id = ? AND status = 'active'", ) .bind(nowIso(), id) .run(); await c.env.DB.prepare("UPDATE nodes SET status = 'revoked', updated_at = ? WHERE id = ?") .bind(nowIso(), id) .run(); return c.json({ ok: true }); }); /** 重新生成 enrollment token(credential 轮换入口) */ app.post("/nodes/:id/rotate", async (c) => { const id = c.req.param("id"); const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'") .bind(id) .first(); if (!node) return c.json({ error: "not found" }, 404); const token = "wenr_" + crypto.randomUUID().replace(/-/g, ""); await c.env.DB.prepare( "UPDATE nodes SET enrollment_token = ?, enrollment_expires_at = ?, updated_at = ? WHERE id = ?", ) .bind(token, new Date(Date.now() + 24 * 3600 * 1000).toISOString(), nowIso(), id) .run(); return c.json({ ok: true, enrollment_token: token }); }); /** 本地/运维用途:查看 ingress secret(会话保护;真实部署由 provisioning 自动注入 Worker) */ app.get("/nodes/:id/ingress-secret", async (c) => { const id = c.req.param("id"); const node = await c.env.DB.prepare("SELECT ingress_secret_enc FROM nodes WHERE id = ?") .bind(id) .first<{ ingress_secret_enc: string }>(); if (!node) return c.json({ error: "not found" }, 404); return c.json({ ingress_secret: await decryptSecret(c.env, node.ingress_secret_enc) }); }); } /** # 函数 * Domain CRUD */ function installDomainRoutes(app: App) { app.get("/domains", async (c) => { const { limit, offset } = pageParams(new URL(c.req.url)); const rows = await c.env.DB.prepare( `SELECT d.*, n.name AS node_name, (SELECT COUNT(*) FROM mailboxes m WHERE m.domain_id = d.id) AS mailbox_count FROM domains d LEFT JOIN nodes n ON n.id = d.node_id ORDER BY d.created_at DESC LIMIT ? OFFSET ?`, ) .bind(limit, offset) .all(); const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM domains").first<{ n: number }>(); return c.json({ items: rows.results, total: total?.n ?? 0 }); }); /** 手工添加 Domain(供无 OAuth 场景;OAuth 安装自动创建) */ app.post("/domains", async (c) => { const body = await readJson<{ name?: string; node_id?: string; cloudflare_account_id?: string }>(c.req.raw); const name = (body.name ?? "").trim().toLowerCase(); if (!name || !name.includes(".")) return c.json({ error: "invalid domain name" }, 400); const id = await upsertDomain(c.env, { cloudflareAccountId: body.cloudflare_account_id ?? "manual", nodeId: body.node_id ?? null as unknown as string, name, zoneId: "manual", workerId: null as unknown as string, }); return c.json({ ok: true, id }, 201); }); app.delete("/domains/:id", async (c) => { const id = c.req.param("id"); const used = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes WHERE domain_id = ?") .bind(id) .first<{ n: number }>(); if ((used?.n ?? 0) > 0) return c.json({ error: "domain has mailboxes" }, 409); await c.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(id).run(); return c.json({ ok: true }); }); } /** # 函数 * Mailbox CRUD */ function installMailboxRoutes(app: App) { app.get("/mailboxes", async (c) => { const { limit, offset } = pageParams(new URL(c.req.url)); const domainId = new URL(c.req.url).searchParams.get("domain_id"); const where = domainId ? "WHERE m.domain_id = ?" : ""; const rows = await c.env.DB.prepare( `SELECT m.*, d.name AS domain_name, (SELECT COUNT(*) FROM emails e WHERE e.mailbox_id = m.id) AS email_count FROM mailboxes m JOIN domains d ON d.id = m.domain_id ${where} ORDER BY m.created_at DESC LIMIT ? OFFSET ?`, ); const bound = domainId ? rows.bind(domainId, limit, offset) : rows.bind(limit, offset); const res = await bound.all(); const totalRows = domainId ? await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes WHERE domain_id = ?").bind(domainId).first<{ n: number }>() : await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes").first<{ n: number }>(); return c.json({ items: res.results, total: totalRows?.n ?? 0 }); }); app.post("/mailboxes", async (c) => { const body = await readJson<{ domain_id?: string; address?: string; retention_seconds?: number; catch_all?: boolean }>(c.req.raw); if (!body.domain_id || !body.address) return c.json({ error: "domain_id and address required" }, 400); const domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?") .bind(body.domain_id) .first<{ id: string; name: string }>(); if (!domain) return c.json({ error: "domain not found" }, 404); let address = body.address.trim().toLowerCase(); if (body.catch_all) address = `*@${domain.name}`; else if (!address.includes("@")) address = `${address}@${domain.name}`; else if (!address.endsWith(`@${domain.name}`)) return c.json({ error: `address must be @${domain.name}` }, 400); const exists = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE address = ?").bind(address).first(); if (exists) return c.json({ error: "mailbox already exists" }, 409); const id = await upsertMailbox(c.env, { domainId: body.domain_id, address, isCatchAll: Boolean(body.catch_all), retentionSeconds: body.retention_seconds ? Number(body.retention_seconds) : undefined, }); return c.json({ ok: true, id, address }, 201); }); app.patch("/mailboxes/:id", async (c) => { const id = c.req.param("id"); const body = await readJson<{ status?: string; retention_seconds?: number | null }>(c.req.raw); if (body.status && !["active", "disabled"].includes(body.status)) { return c.json({ error: "invalid status" }, 400); } const mailbox = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE id = ?").bind(id).first(); if (!mailbox) return c.json({ error: "not found" }, 404); if (body.status) { await c.env.DB.prepare("UPDATE mailboxes SET status = ?, updated_at = ? WHERE id = ?") .bind(body.status, nowIso(), id) .run(); } if (body.retention_seconds !== undefined) { await c.env.DB.prepare("UPDATE mailboxes SET retention_seconds = ?, updated_at = ? WHERE id = ?") .bind(body.retention_seconds ? Number(body.retention_seconds) : null, nowIso(), id) .run(); } return c.json({ ok: true }); }); app.delete("/mailboxes/:id", async (c) => { const id = c.req.param("id"); await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run(); return c.json({ ok: true }); }); } /** # 函数 * Email 查询 / 详情 / 附件 / 手动删除 */ function installEmailRoutes(app: App) { app.get("/emails", async (c) => { const url = new URL(c.req.url); const { limit, offset } = pageParams(url); const mailboxId = url.searchParams.get("mailbox_id"); const q = url.searchParams.get("q"); const conds: string[] = []; const binds: unknown[] = []; if (mailboxId) { conds.push("mailbox_id = ?"); binds.push(mailboxId); } if (q) { conds.push("(subject LIKE ? OR from_address LIKE ?)"); binds.push(`%${q}%`, `%${q}%`); } const where = conds.length ? `WHERE ${conds.join(" AND ")}` : ""; const rows = await c.env.DB.prepare( `SELECT e.id, e.mailbox_id, e.message_id, e.from_address, e.to_addresses, e.subject, e.size, e.received_at, e.expires_at, m.address AS mailbox_address, (SELECT COUNT(*) FROM email_attachments a WHERE a.email_id = e.id) AS attachment_count FROM emails e LEFT JOIN mailboxes m ON m.id = e.mailbox_id ${where} ORDER BY e.received_at DESC LIMIT ? OFFSET ?`, ); const res = await (binds.length ? rows.bind(...binds, limit, offset) : rows.bind(limit, offset)).all(); const totalRows = binds.length ? await c.env.DB.prepare(`SELECT COUNT(*) AS n FROM emails ${where}`).bind(...binds).first<{ n: number }>() : await c.env.DB.prepare("SELECT COUNT(*) AS n FROM emails").first<{ n: number }>(); return c.json({ items: res.results, total: totalRows?.n ?? 0 }); }); app.get("/emails/:id", async (c) => { const id = c.req.param("id"); const email = await c.env.DB.prepare( `SELECT e.*, m.address AS mailbox_address FROM emails e LEFT JOIN mailboxes m ON m.id = e.mailbox_id WHERE e.id = ?`, ) .bind(id) .first(); if (!email) return c.json({ error: "not found" }, 404); const atts = await c.env.DB.prepare( "SELECT id, filename, content_type, size, content_id FROM email_attachments WHERE email_id = ?", ) .bind(id) .all(); return c.json({ email, attachments: atts.results }); }); /** 附件下载 */ app.get("/emails/:id/attachments/:attId", async (c) => { const attId = c.req.param("attId"); const att = await c.env.DB.prepare("SELECT filename, content_type, storage_key FROM email_attachments WHERE id = ? AND email_id = ?") .bind(attId, c.req.param("id")) .first<{ filename: string; content_type: string; storage_key: string }>(); if (!att) return c.json({ error: "not found" }, 404); const obj = await c.env.R2.get(att.storage_key); if (!obj) return c.json({ error: "object missing" }, 404); const buf = await obj.arrayBuffer(); return new Response(buf, { headers: { "Content-Type": att.content_type || "application/octet-stream", "Content-Disposition": `attachment; filename="${encodeURIComponent(att.filename)}"`, }, }); }); /** raw MIME 下载(.eml) */ app.get("/emails/:id/raw", async (c) => { const email = await c.env.DB.prepare("SELECT raw_storage_key FROM emails WHERE id = ?") .bind(c.req.param("id")) .first<{ raw_storage_key: string }>(); if (!email) return c.json({ error: "not found" }, 404); const obj = await c.env.R2.get(email.raw_storage_key); if (!obj) return c.json({ error: "object missing" }, 404); return new Response(await obj.arrayBuffer(), { headers: { "Content-Type": "message/rfc822", "Content-Disposition": `attachment; filename="${c.req.param("id")}.eml"`, }, }); }); app.delete("/emails/:id", async (c) => { const id = c.req.param("id"); const email = await c.env.DB.prepare("SELECT raw_storage_key FROM emails WHERE id = ?") .bind(id) .first<{ raw_storage_key: string }>(); if (!email) return c.json({ error: "not found" }, 404); const atts = await c.env.DB.prepare("SELECT storage_key FROM email_attachments WHERE email_id = ?") .bind(id) .all<{ storage_key: string | null }>(); const keys = [email.raw_storage_key, ...atts.results.map((a) => a.storage_key)].filter(Boolean) as string[]; if (keys.length) await c.env.R2.delete(keys); await c.env.DB.prepare("DELETE FROM email_attachments WHERE email_id = ?").bind(id).run(); await c.env.DB.prepare("DELETE FROM webhook_deliveries WHERE email_id = ?").bind(id).run(); await c.env.DB.prepare("DELETE FROM emails WHERE id = ?").bind(id).run(); return c.json({ ok: true }); }); } /** # 函数 * webhook endpoint 的基本格式校验(创建时调用) */ export function validateEndpoint(endpoint: string): string | null { try { new URL(endpoint); } catch { return "invalid url"; } return checkWebhookUrl(endpoint); }