feat: v1.1.0 账号化重构 + 三级 Webhook + 设置中心 + Vite 工具链

- 账号化:节点更名账号,/nodes -> /accounts;添加账号直达 Cloudflare OAuth,自动创建账号并同步账号下全部域名(catch-all 邮箱随同步创建)
- 域名:移除手工添加;点击域名『设置 Email Workers』= 部署账号级 Email Worker + Email Routing + catch-all;支持按账号筛选
- Webhook 三级模型:邮箱 -> 域名 -> 全局,收信取第一个命中者;支持自定义请求头与 key(默认 Authorization: Bearer <key>,兼做 X-WmailHub-Signature 签名);独立 webhooks 实体与 /webhooks、/keys 页面删除,Key 管理收进设置模态窗口
- 设置中心:全局 Webhook / 默认 Node Ingress 地址 / 修改密码 / API & MCP Keys(生成、撤销、删除)
- UI:Header 标题副标题紧凑化(修复溢出),LOGO 加版本号 tag(v1.1.0 取自 package.json),各列表页搜索框
- 工具链:前端构建 Bun.build -> Vite(vite 8 + @vitejs/plugin-react)
- 后端:/api/settings、POST /api/domains/:id/setup、0002 迁移(投递行固化三级目标配置);MCP 去 list_webhooks(5 工具);迁移执行器加 _migrations 记录
- 测试:44/44(新增 settings 套件、webhook 三级优先级/SSRF/签名);UI 冒烟 11/11
This commit is contained in:
bigcode
2026-09-30 11:37:38 +08:00
parent d4c1334143
commit 421cfb2f04
47 changed files with 2316 additions and 1431 deletions
+6 -3
View File
@@ -28,13 +28,16 @@ export function adminRoutes(): App {
(await c.env.DB.prepare(sql).first<{ n: number }>())?.n ?? 0;
const now = nowIso();
const stats = {
nodes_online: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status = 'online'`),
nodes_total: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'`),
accounts_online: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status = 'online'`),
accounts_total: await count(`SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'`),
domains: await count("SELECT COUNT(*) AS n FROM domains"),
domains_active: await count(`SELECT COUNT(*) AS n FROM domains WHERE status = 'active'`),
mailboxes: await count("SELECT COUNT(*) AS n FROM mailboxes"),
emails: await count(`SELECT COUNT(*) AS n FROM emails WHERE expires_at > '${now}'`),
emails_expired: await count(`SELECT COUNT(*) AS n FROM emails WHERE expires_at <= '${now}'`),
webhooks: await count("SELECT COUNT(*) AS n FROM webhooks"),
global_webhook: await count(
`SELECT COUNT(*) AS n FROM settings WHERE key = 'global_webhook' AND value != '{"url":"","headers":{}}' AND value != ''`,
),
deliveries_pending: await count(
`SELECT COUNT(*) AS n FROM webhook_deliveries WHERE status IN ('pending','failed')`,
),
-8
View File
@@ -111,13 +111,5 @@ export function apiV1Routes(): App {
return c.json({ email, attachments: atts.results });
});
/** Webhook 列表 */
app.get("/webhooks", async (c) => {
const rows = await c.env.DB.prepare(
"SELECT id, name, endpoint, enabled, created_at FROM webhooks ORDER BY created_at DESC",
).all();
return c.json({ items: rows.results });
});
return app;
}
+1 -12
View File
@@ -1,7 +1,7 @@
/**
* /api/v1/mcp :MCP over Streamable HTTP(JSON-RPC 2.0),MCP Key(wmh_)认证。
*
* 工具:list_mailboxes / list_emails / get_email / create_mailbox / delete_mailbox / list_webhooks
* 工具:list_mailboxes / list_emails / get_email / create_mailbox / delete_mailbox
* 权限:基于 Console credential(docs/09 Phase 9);邮件强制检查未过期。
* 实现说明:POST JSON-RPC -> application/json 响应(Streamable HTTP 允许服务器
* 返回普通 JSON);GET 返回 405(无 SSE 流)。
@@ -72,11 +72,6 @@ const TOOLS = [
additionalProperties: false,
},
},
{
name: "list_webhooks",
description: "列出 Webhook(不含 secret)",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
] as const;
/** # 函数
@@ -239,12 +234,6 @@ async function dispatchTool(env: Env, name: string, args: Record<string, unknown
if (!res.meta?.changes) throw new McpToolError("mailbox not found", -32002);
return { ok: true };
}
case "list_webhooks": {
const rows = await env.DB.prepare(
"SELECT id, name, endpoint, enabled, created_at FROM webhooks ORDER BY created_at DESC",
).all();
return { webhooks: rows.results };
}
default:
throw new McpToolError(`unknown tool: ${name}`, -32601);
}
+25 -12
View File
@@ -12,10 +12,10 @@
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { aesEncrypt, deriveAesKey, ids } from "../../../shared/src/index.ts";
import { ids } from "../../../shared/src/index.ts";
import { CF_OAUTH_AUTHORIZE_URL, CF_OAUTH_SCOPES, getCFApi } from "../services/cfapi.ts";
import { provisionNode } from "../services/provision.ts";
import { getSetting } from "../db.ts";
import { provisionNode, createNode } from "../services/provision.ts";
import { getSetting, encryptConnectionToken } from "../db.ts";
import { nowIso, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
@@ -23,6 +23,9 @@ type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 成员变量: OAuth state 有效期(秒) **/
const STATE_TTL_SECONDS = 300;
/** # 成员变量: 自动创建账号时的默认 Node Ingress 地址(可在设置中修改) **/
export const DEFAULT_INGRESS_URL = "http://127.0.0.1:18788/ingress/email";
/** # 函数
* 是否使用真实 Cloudflare OAuth
*/
@@ -45,13 +48,24 @@ export function oauthRoutes(): App {
});
});
/** 开始授权:创建 state 并返回跳转 URL */
/** 开始授权:node_id 缺省时自动创建账号;创建 state 并返回跳转 URL */
app.get("/cloudflare/start", async (c) => {
const nodeId = new URL(c.req.url).searchParams.get("node_id") ?? "";
const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status = 'pending_oauth' OR id = ?")
.bind(nodeId, nodeId)
.first<{ id: string }>();
if (!node) return c.json({ error: "node not found" }, 404);
let nodeId = new URL(c.req.url).searchParams.get("node_id") ?? "";
if (nodeId) {
const node = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'")
.bind(nodeId)
.first<{ id: string }>();
if (!node) return c.json({ error: "account not found" }, 404);
} else {
// 添加账号:直接进入 OAuth,授权成功后自动落地账号(identity + 域名同步)
const ingressUrl =
(await getSetting(c.env, "default_ingress_url"))?.trim() || DEFAULT_INGRESS_URL;
const created = await createNode(c.env, {
name: `Cloudflare 账号 ${new Date().toISOString().slice(0, 10)}`,
ingressUrl,
});
nodeId = created.nodeId;
}
const state = "cfstate_" + ids.oauthState();
await c.env.DB.prepare(
@@ -92,7 +106,7 @@ export function oauthRoutes(): App {
// 加密入库(不进前端/日志)
await saveConnection(c.env, token.accessToken, token.expiresIn);
const result = await provisionNode(c.env, { nodeId: verdict.nodeId, accessToken: token.accessToken });
const target = `/nodes?provision=${encodeURIComponent(verdict.nodeId)}&ok=${result.ok ? 1 : 0}`;
const target = `/accounts?provision=${encodeURIComponent(verdict.nodeId)}&ok=${result.ok ? 1 : 0}`;
return c.redirect(target, 302);
} catch (err) {
return c.text(`OAuth provisioning failed: ${err instanceof Error ? err.message : String(err)}`, 500);
@@ -143,8 +157,7 @@ async function consumeState(
* OAuth access token 加密入库
*/
async function saveConnection(env: Env, accessToken: string, expiresIn: number): Promise<void> {
const key = await deriveAesKey(await getSetting(env, "app_secret") ?? "wmailhub");
const encrypted = await aesEncrypt(key, accessToken);
const encrypted = await encryptConnectionToken(env, accessToken);
await env.DB.prepare(
`INSERT INTO oauth_connections (id, provider, account_id, encrypted_access_token, refresh_token, expires_at, created_at, updated_at)
VALUES (?, 'cloudflare', NULL, ?, NULL, ?, ?, ?)`,
+132 -75
View File
@@ -1,28 +1,31 @@
/**
* Console 管理路由(Node / Domain / Mailbox / Email)。
* 全部要求会话登录。
* Console 管理路由(Account(账号) / Domain / Mailbox / Email)。
* 全部要求会话登录。v1.1.0:
* - 节点在管理面更名「账号」,路由 /api/accounts(内部 Node 协议 /api/internal/nodes 不变)
* - 删除手工添加域名;域名由 Cloudflare OAuth 自动同步
* - POST /domains/:id/setup 为域名配置 Email Workers(Worker + Routing + catch-all)
* - 邮箱/域名级 Webhook 配置(PUT/DELETE .../webhook)
*/
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 { loadSteps, setupDomainEmail, upsertMailbox } from "../services/provision.ts";
import { decryptSecret } from "../db.ts";
import { ids } from "../../../shared/src/index.ts";
import { checkWebhookUrl } from "../../../shared/src/index.ts";
import { normalizeWebhookHeaders, saveScopedWebhook } from "../services/webhook_delivery.ts";
import { nowIso, pageParams, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 函数
* 装配 Node / Domain / Mailbox / Email 管理路由
* 装配 Account / Domain / Mailbox / Email 管理路由
*/
export function resourceRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireSession);
installNodeRoutes(app);
installAccountRoutes(app);
installDomainRoutes(app);
installMailboxRoutes(app);
installEmailRoutes(app);
@@ -30,11 +33,34 @@ export function resourceRoutes(): App {
}
/** # 函数
* Node CRUD 与安装状态
* 解析并校验 webhook 配置请求体
* 返回值: { error } 或规整后的 { url, headers, key }(key 为 undefined 表示保留现有 key)
*/
function installNodeRoutes(app: App) {
/** Node 列表(不含任何 secret) */
app.get("/nodes", async (c) => {
async function parseWebhookBody(
c: { env: Env; req: { raw: Request } },
): Promise<{ error: string } | { url: string; headers: Record<string, string>; key: string | undefined }> {
const body = await readJson<{ url?: string; headers?: unknown; key?: string }>(c.req.raw);
const url = (body.url ?? "").trim();
let headers: Record<string, string>;
try {
headers = normalizeWebhookHeaders(body.headers);
} catch (err) {
return { error: err instanceof Error ? err.message : "invalid headers" };
}
if (url) {
const unsafe = checkWebhookUrl(url, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1");
if (unsafe) return { error: unsafe };
}
const rawKey = typeof body.key === "string" ? body.key : undefined;
return { url, headers, key: rawKey !== undefined && rawKey.trim() === "" ? "" : rawKey };
}
/** # 函数
* Account(账号)管理与安装状态
*/
function installAccountRoutes(app: App) {
/** 账号列表(不含任何 secret) */
app.get("/accounts", 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,
@@ -47,38 +73,17 @@ function installNodeRoutes(app: App) {
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) => {
/** 账号详情:含安装步骤状态与 OAuth 连接状态 */
app.get("/accounts/:id", async (c) => {
const id = c.req.param("id");
const node = await c.env.DB.prepare(
const account = 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);
if (!account) 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",
@@ -86,11 +91,38 @@ function installNodeRoutes(app: App) {
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 });
return c.json({ account, steps, oauth: oauth ?? null, domains: domains.results });
});
/** 删除 Node(软删:撤销 credential + 标记 revoked) */
app.delete("/nodes/:id", async (c) => {
/** 更新账号:名称 / Node Ingress 地址(Email Worker 回源地址) */
app.patch("/accounts/:id", async (c) => {
const id = c.req.param("id");
const body = await readJson<{ name?: string; ingress_url?: string }>(c.req.raw);
const account = await c.env.DB.prepare("SELECT id FROM nodes WHERE id = ? AND status != 'revoked'")
.bind(id)
.first();
if (!account) return c.json({ error: "not found" }, 404);
if (body.name !== undefined) {
const name = body.name.trim();
if (!name) return c.json({ error: "name required" }, 400);
await c.env.DB.prepare("UPDATE nodes SET name = ?, updated_at = ? WHERE id = ?").bind(name, nowIso(), id).run();
}
if (body.ingress_url !== undefined) {
const ingressUrl = body.ingress_url.trim();
try {
new URL(ingressUrl);
} catch {
return c.json({ error: "invalid ingress_url" }, 400);
}
await c.env.DB.prepare("UPDATE nodes SET ingress_url = ?, updated_at = ? WHERE id = ?")
.bind(ingressUrl, nowIso(), id)
.run();
}
return c.json({ ok: true });
});
/** 删除账号(软删:撤销 credential + 标记 revoked) */
app.delete("/accounts/: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'",
@@ -104,12 +136,12 @@ function installNodeRoutes(app: App) {
});
/** 重新生成 enrollment token(credential 轮换入口) */
app.post("/nodes/:id/rotate", async (c) => {
app.post("/accounts/: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'")
const account = 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);
if (!account) 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 = ?",
@@ -120,24 +152,26 @@ function installNodeRoutes(app: App) {
});
/** 本地/运维用途:查看 ingress secret(会话保护;真实部署由 provisioning 自动注入 Worker) */
app.get("/nodes/:id/ingress-secret", async (c) => {
app.get("/accounts/: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 = ?")
const account = 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) });
if (!account) return c.json({ error: "not found" }, 404);
return c.json({ ingress_secret: await decryptSecret(c.env, account.ingress_secret_enc) });
});
}
/** # 函数
* Domain CRUD
* Domain 查询 / Email Workers 配置 / Webhook 配置
*/
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 d.id, d.name, d.status, d.setup_error, d.worker_id, d.zone_id, d.node_id,
d.webhook_url, d.webhook_headers, (d.webhook_key_enc IS NOT NULL) AS webhook_has_key,
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 ?`,
@@ -148,19 +182,35 @@ function installDomainRoutes(app: App) {
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.get("/domains/filters/accounts", async (c) => {
const rows = await c.env.DB.prepare(
"SELECT DISTINCT n.id, n.name FROM nodes n JOIN domains d ON d.node_id = n.id ORDER BY n.name",
).all();
return c.json({ items: rows.results });
});
/** 为域名配置 Email Workers(部署 Worker + Email Routing + catch-all) */
app.post("/domains/:id/setup", async (c) => {
const result = await setupDomainEmail(c.env, c.req.param("id"));
return c.json(result, result.ok ? 200 : 400);
});
/** 保存域名级 Webhook(url 为空表示清除) */
app.put("/domains/:id/webhook", async (c) => {
const id = c.req.param("id");
const domain = await c.env.DB.prepare("SELECT id FROM domains WHERE id = ?").bind(id).first();
if (!domain) return c.json({ error: "not found" }, 404);
const parsed = await parseWebhookBody(c);
if ("error" in parsed) return c.json({ error: parsed.error }, 400);
await saveScopedWebhook(c.env, "domains", id, parsed);
return c.json({ ok: true });
});
/** 清除域名级 Webhook */
app.delete("/domains/:id/webhook", async (c) => {
await saveScopedWebhook(c.env, "domains", c.req.param("id"), { url: "" });
return c.json({ ok: true });
});
app.delete("/domains/:id", async (c) => {
@@ -175,7 +225,7 @@ function installDomainRoutes(app: App) {
}
/** # 函数
* Mailbox CRUD
* Mailbox CRUD 与邮箱级 Webhook 配置
*/
function installMailboxRoutes(app: App) {
app.get("/mailboxes", async (c) => {
@@ -183,7 +233,9 @@ function installMailboxRoutes(app: App) {
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 m.id, m.address, m.status, m.is_catch_all, m.retention_seconds, m.domain_id,
m.webhook_url, m.webhook_headers, (m.webhook_key_enc IS NOT NULL) AS webhook_has_key,
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 ?`,
@@ -239,6 +291,23 @@ function installMailboxRoutes(app: App) {
return c.json({ ok: true });
});
/** 保存邮箱级 Webhook(url 为空表示清除) */
app.put("/mailboxes/:id/webhook", async (c) => {
const id = c.req.param("id");
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);
const parsed = await parseWebhookBody(c);
if ("error" in parsed) return c.json({ error: parsed.error }, 400);
await saveScopedWebhook(c.env, "mailboxes", id, parsed);
return c.json({ ok: true });
});
/** 清除邮箱级 Webhook */
app.delete("/mailboxes/:id/webhook", async (c) => {
await saveScopedWebhook(c.env, "mailboxes", c.req.param("id"), { url: "" });
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();
@@ -348,15 +417,3 @@ function installEmailRoutes(app: App) {
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);
}
+123
View File
@@ -0,0 +1,123 @@
/**
* 设置路由(v1.1.0):
* GET /api/settings 读取:全局 Webhook / 默认 Node Ingress 地址 / 当前用户
* PUT /api/settings 保存:全局 Webhook + 默认 Node Ingress 地址
* PUT /api/settings/password 修改登录密码
* API/MCP Key 管理沿用 /api/keys(前端设置模态窗口内调用)。
*/
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { checkWebhookUrl, hashPassword, verifyPassword } from "../../../shared/src/index.ts";
import { getSetting, setSetting } from "../db.ts";
import {
getGlobalWebhookKey,
normalizeWebhookHeaders,
saveGlobalWebhook,
} from "../services/webhook_delivery.ts";
import { nowIso, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 函数
* 装配设置路由(会话保护)
*/
export function settingsRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireSession);
/** 读取设置(key 只回 last4,不回明文) */
app.get("/", async (c) => {
const userId = c.get("userId") ?? "";
const user = await c.env.DB.prepare("SELECT username FROM users WHERE id = ?").bind(userId).first<{ username: string }>();
const raw = await getSetting(c.env, "global_webhook");
let webhook: Record<string, unknown> = { url: "", headers: {}, has_key: false, key_last4: "" };
if (raw) {
try {
const cfg = JSON.parse(raw) as { url?: string; headers?: Record<string, string> };
const key = await getGlobalWebhookKey(c.env);
webhook = {
url: cfg.url ?? "",
headers: cfg.headers ?? {},
has_key: Boolean(key),
key_last4: key.slice(-4),
};
} catch {
// settings 损坏时按未配置处理
}
}
return c.json({
username: user?.username ?? "",
default_ingress_url: (await getSetting(c.env, "default_ingress_url")) ?? "",
webhook,
});
});
/** 保存设置:全局 Webhook + 默认 Node Ingress 地址 */
app.put("/", async (c) => {
const body = await readJson<{
default_ingress_url?: string;
webhook?: { url?: string; headers?: unknown; key?: string };
}>(c.req.raw);
if (body.default_ingress_url !== undefined) {
const ingressUrl = body.default_ingress_url.trim();
if (ingressUrl) {
try {
new URL(ingressUrl);
} catch {
return c.json({ error: "invalid default_ingress_url" }, 400);
}
await setSetting(c.env, "default_ingress_url", ingressUrl);
} else {
await c.env.DB.prepare("DELETE FROM settings WHERE key = 'default_ingress_url'").run();
}
}
if (body.webhook) {
const url = (body.webhook.url ?? "").trim();
let headers: Record<string, string>;
try {
headers = normalizeWebhookHeaders(body.webhook.headers);
} catch (err) {
return c.json({ error: err instanceof Error ? err.message : "invalid headers" }, 400);
}
if (url) {
const unsafe = checkWebhookUrl(url, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1");
if (unsafe) return c.json({ error: unsafe }, 400);
}
// key:undefined 保留现有;空白串(含 "")清除;非空设置新 key
const rawKey = typeof body.webhook.key === "string" ? body.webhook.key : undefined;
await saveGlobalWebhook(c.env, {
url,
headers,
key: rawKey !== undefined && rawKey.trim() === "" ? "" : rawKey,
});
}
return c.json({ ok: true });
});
/** 修改登录密码 */
app.put("/password", async (c) => {
const body = await readJson<{ old_password?: string; new_password?: string }>(c.req.raw);
const oldPassword = body.old_password ?? "";
const newPassword = body.new_password ?? "";
if (newPassword.length < 6) return c.json({ error: "新密码至少 6 位" }, 400);
const userId = c.get("userId") ?? "";
const user = await c.env.DB.prepare("SELECT id, password_hash FROM users WHERE id = ?")
.bind(userId)
.first<{ id: string; password_hash: string }>();
if (!user) return c.json({ error: "user not found" }, 404);
if (!(await verifyPassword(oldPassword, user.password_hash))) {
return c.json({ error: "原密码不正确" }, 400);
}
await c.env.DB.prepare("UPDATE users SET password_hash = ? WHERE id = ?")
.bind(await hashPassword(newPassword), userId)
.run();
return c.json({ ok: true });
});
return app;
}
-161
View File
@@ -1,161 +0,0 @@
/**
* Console Webhook 管理路由(docs/07):CRUD、secret、投递日志、手动测试/重投。
*/
import { Hono } from "hono";
import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { ids, checkWebhookUrl } from "../../../shared/src/index.ts";
import { attemptDelivery, buildWebhookPayload } from "../services/webhook_delivery.ts";
import { pageParams, readJson, nowIso } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 函数
* 装配 Webhook 管理路由(会话保护)
*/
export function webhookRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireSession);
/** 列表(不含 secret 明文) */
app.get("/", async (c) => {
const { limit, offset } = pageParams(new URL(c.req.url));
const rows = await c.env.DB.prepare(
`SELECT w.id, w.name, w.endpoint, w.enabled, w.created_at, w.updated_at,
(SELECT COUNT(*) FROM webhook_deliveries d WHERE d.webhook_id = w.id) AS delivery_count,
(SELECT COUNT(*) FROM webhook_deliveries d WHERE d.webhook_id = w.id AND d.status = 'success') AS success_count
FROM webhooks w ORDER BY w.created_at DESC LIMIT ? OFFSET ?`,
)
.bind(limit, offset)
.all();
const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM webhooks").first<{ n: number }>();
return c.json({ items: rows.results, total: total?.n ?? 0 });
});
/** 创建:secret 生成后明文仅返回一次 */
app.post("/", async (c) => {
const body = await readJson<{ name?: string; endpoint?: string }>(c.req.raw);
const name = (body.name ?? "").trim();
const endpoint = (body.endpoint ?? "").trim();
if (!name || !endpoint) return c.json({ error: "name and endpoint required" }, 400);
const unsafe = checkWebhookUrl(endpoint, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1");
if (unsafe) return c.json({ error: `endpoint rejected: ${unsafe}` }, 400);
const id = ids.webhook();
const secret = "whsec_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
const now = nowIso();
await c.env.DB.prepare(
"INSERT INTO webhooks (id, name, endpoint, secret, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)",
)
.bind(id, name, endpoint, secret, now, now)
.run();
return c.json({ ok: true, id, secret }, 201);
});
/** 更新(name/endpoint/enabled);endpoint 变更时重新过 SSRF 校验 */
app.patch("/:id", async (c) => {
const id = c.req.param("id");
const body = await readJson<{ name?: string; endpoint?: string; enabled?: boolean }>(c.req.raw);
const hook = await c.env.DB.prepare("SELECT id FROM webhooks WHERE id = ?").bind(id).first();
if (!hook) return c.json({ error: "not found" }, 404);
if (body.endpoint !== undefined) {
const unsafe = checkWebhookUrl(body.endpoint, c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1");
if (unsafe) return c.json({ error: `endpoint rejected: ${unsafe}` }, 400);
await c.env.DB.prepare("UPDATE webhooks SET endpoint = ?, updated_at = ? WHERE id = ?")
.bind(body.endpoint, nowIso(), id)
.run();
}
if (body.name !== undefined) {
await c.env.DB.prepare("UPDATE webhooks SET name = ?, updated_at = ? WHERE id = ?")
.bind(body.name, nowIso(), id)
.run();
}
if (body.enabled !== undefined) {
await c.env.DB.prepare("UPDATE webhooks SET enabled = ?, updated_at = ? WHERE id = ?")
.bind(body.enabled ? 1 : 0, nowIso(), id)
.run();
}
return c.json({ ok: true });
});
/** 轮换 secret(明文仅返回一次) */
app.post("/:id/rotate", async (c) => {
const id = c.req.param("id");
const secret = "whsec_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
const res = await c.env.DB.prepare("UPDATE webhooks SET secret = ?, updated_at = ? WHERE id = ?")
.bind(secret, nowIso(), id)
.run();
if (!res.meta?.changes) return c.json({ error: "not found" }, 404);
return c.json({ ok: true, secret });
});
app.delete("/:id", async (c) => {
const id = c.req.param("id");
await c.env.DB.prepare("DELETE FROM webhook_deliveries WHERE webhook_id = ?").bind(id).run();
await c.env.DB.prepare("DELETE FROM webhooks WHERE id = ?").bind(id).run();
return c.json({ ok: true });
});
/** 投递日志 */
app.get("/:id/deliveries", async (c) => {
const id = c.req.param("id");
const { limit, offset } = pageParams(new URL(c.req.url));
const rows = await c.env.DB.prepare(
`SELECT d.*, e.subject, e.from_address
FROM webhook_deliveries d LEFT JOIN emails e ON e.id = d.email_id
WHERE d.webhook_id = ? ORDER BY d.created_at DESC LIMIT ? OFFSET ?`,
)
.bind(id, limit, offset)
.all();
return c.json({ items: rows.results });
});
/** 手动测试投递(使用最近一封邮件,若无则发空壳测试事件) */
app.post("/:id/test", async (c) => {
const id = c.req.param("id");
const hook = await c.env.DB.prepare("SELECT id, endpoint, secret, enabled FROM webhooks WHERE id = ?")
.bind(id)
.first<{ id: string; endpoint: string; secret: string; enabled: number }>();
if (!hook) return c.json({ error: "not found" }, 404);
const lastEmail = await c.env.DB.prepare(
"SELECT id, mailbox_id, from_address, to_addresses, subject, text_body, received_at FROM emails ORDER BY received_at DESC LIMIT 1",
).first<{ id: string; mailbox_id: string | null; from_address: string | null; to_addresses: string; subject: string | null; text_body: string | null; received_at: string }>();
const mailbox = lastEmail?.mailbox_id
? (await c.env.DB.prepare("SELECT address FROM mailboxes WHERE id = ?").bind(lastEmail.mailbox_id).first<{ address: string }>())?.address ?? null
: null;
const payload = lastEmail
? buildWebhookPayload({ id: lastEmail.id, mailboxAddress: mailbox, fromAddress: lastEmail.from_address, toAddresses: lastEmail.to_addresses, subject: lastEmail.subject, textBody: lastEmail.text_body, receivedAt: lastEmail.received_at })
: {
event: "email.received",
id: "test_" + crypto.randomUUID().slice(0, 8),
mailbox: "test@example.com",
from: "console@test.local",
to: ["test@example.com"],
subject: "WmailHub webhook test",
text: "This is a manual test delivery.",
received_at: nowIso(),
};
const result = await attemptDelivery(
{ id: hook.id, endpoint: hook.endpoint, secret: hook.secret },
"test",
payload,
undefined,
c.env.WMAILHUB_ALLOW_PRIVATE_WEBHOOKS === "1",
);
return c.json(result);
});
/** 重投:把失败 delivery 重置为立即重试 */
app.post("/deliveries/:deliveryId/redeliver", async (c) => {
const deliveryId = c.req.param("deliveryId");
const res = await c.env.DB.prepare(
"UPDATE webhook_deliveries SET status = 'pending', next_retry_at = ?, last_error = NULL WHERE id = ?",
)
.bind(nowIso(), deliveryId)
.run();
if (!res.meta?.changes) return c.json({ error: "not found" }, 404);
return c.json({ ok: true });
});
return app;
}