feat: WmailHub v1.0.0 —— Cloudflare Email Workers 邮件接收与转发平台(本地可用版本)

- Console:React 19 + antd 6.6.5 前端(登录 admin/admin)+ Hono 管理 API
  (Nodes/Domains/Mailboxes/Emails/Webhooks/Keys/OAuth/Admin)
- Node:Bun 转发节点(HMAC 签名、有限重试、心跳、结构化日志、优雅退出)
- Email Worker:Cloudflare email handler + 本地联调入口
- shared:HMAC 协议(docs/04)、wmh_ Key(32 位)、AES-GCM 加密、SSRF 防护
- 邮件管线:postal-mime 解析、附件、catch-all、限额、malformed 降级、TTL 1 天
- Webhook:签名投递、指数退避重试、SSRF 校验、投递日志
- API v1 + MCP(JSON-RPC,六工具,凭据 kind 隔离)
- Cloudflare OAuth:真实 + 本地 sim 模式,自动安装管线(幂等,无需手工命令)
- 测试:bun test 41 例(协议安全/管线/TTL/Webhook/SSRF/MCP/OAuth/鉴权)+ Playwright UI 冒烟
- LOGO/favicon 由 codex CLI 生成
This commit is contained in:
BigCode
2026-09-30 03:34:37 +08:00
commit d4c1334143
87 changed files with 9268 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
/**
* Cloudflare OAuth 路由(docs/02):
* GET /api/oauth/cloudflare/start?node_id= 生成 state,返回授权跳转 URL
* GET /api/oauth/cloudflare/callback 授权回调:state 校验、token 兑换、自动安装
* POST /api/oauth/cloudflare/sim/approve sim 模式模拟用户授权(仅本地)
* GET /api/oauth/connections 已建立的 OAuth 连接
*
* 安全:state 一次性(用后即删)、5 分钟过期、绑定 node_id;token AES-GCM 加密入库,
* 不进前端与日志;Account ownership 以 token 实际可访问的 accounts 为准。
*/
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 { CF_OAUTH_AUTHORIZE_URL, CF_OAUTH_SCOPES, getCFApi } from "../services/cfapi.ts";
import { provisionNode } from "../services/provision.ts";
import { getSetting } from "../db.ts";
import { nowIso, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
/** # 成员变量: OAuth state 有效期(秒) **/
const STATE_TTL_SECONDS = 300;
/** # 函数
* 是否使用真实 Cloudflare OAuth
*/
export function isRealOAuth(env: Env): boolean {
return Boolean(env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET && !env.WMAILHUB_SIM_CF);
}
/** # 函数
* 装配 OAuth 路由
*/
export function oauthRoutes(): App {
const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
app.use("*", requireSession);
/** 授权模式与入口信息(前端 Add Node 向导用) */
app.get("/mode", (c) => {
return c.json({
mode: isRealOAuth(c.env) ? "cloudflare" : "sim",
authorize_url_base: isRealOAuth(c.env) ? CF_OAUTH_AUTHORIZE_URL : null,
});
});
/** 开始授权:创建 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);
const state = "cfstate_" + ids.oauthState();
await c.env.DB.prepare(
"INSERT INTO oauth_states (state, node_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
)
.bind(state, nodeId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString())
.run();
if (isRealOAuth(c.env)) {
const base = c.env.PUBLIC_BASE_URL ?? new URL(c.req.url).origin;
const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`;
const url =
`${CF_OAUTH_AUTHORIZE_URL}?response_type=code` +
`&client_id=${encodeURIComponent(c.env.CF_OAUTH_CLIENT_ID!)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&scope=${encodeURIComponent(CF_OAUTH_SCOPES)}` +
`&state=${encodeURIComponent(state)}`;
return c.json({ url, state });
}
// sim 模式:前端内置模拟授权页
return c.json({ url: `/oauth/sim/authorize?state=${encodeURIComponent(state)}`, state });
});
/** 授权回调(浏览器重垒):校验 state -> 兑换 token -> 自动安装 -> 跳回控制台 */
app.get("/cloudflare/callback", async (c) => {
const url = new URL(c.req.url);
const code = url.searchParams.get("code") ?? "";
const state = url.searchParams.get("state") ?? "";
const verdict = await consumeState(c.env, state);
if (!verdict) {
return c.text("OAuth state invalid or expired (possible CSRF)", 400);
}
const cf = getCFApi(c.env, c.env.DB);
try {
const base = c.env.PUBLIC_BASE_URL ?? url.origin;
const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`;
const token = await cf.exchangeToken(code, redirectUri);
// 加密入库(不进前端/日志)
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}`;
return c.redirect(target, 302);
} catch (err) {
return c.text(`OAuth provisioning failed: ${err instanceof Error ? err.message : String(err)}`, 500);
}
});
/** sim 模式:模拟授权页的确认动作(本地开发用,需会话) */
app.post("/cloudflare/sim/approve", async (c) => {
const body = await readJson<{ state?: string }>(c.req.raw);
const verdict = await consumeState(c.env, body.state ?? "");
if (!verdict) return c.json({ error: "state invalid or expired" }, 400);
const cf = getCFApi(c.env, c.env.DB);
const token = await cf.exchangeToken("sim-code", "urn:unused");
await saveConnection(c.env, token.accessToken, token.expiresIn);
const result = await provisionNode(c.env, { nodeId: verdict.nodeId, accessToken: token.accessToken });
return c.json({ ok: result.ok, error: result.error, node_id: verdict.nodeId });
});
/** 已有连接列表(不含 token) */
app.get("/connections", async (c) => {
const rows = await c.env.DB.prepare(
"SELECT id, provider, account_id, expires_at, created_at FROM oauth_connections ORDER BY created_at DESC",
).all();
return c.json({ items: rows.results });
});
return app;
}
/** # 函数
* 消费一次性 state:存在且未过期则删除并返回 node_id,否则 null(CSRF 防护核心)
*/
async function consumeState(
env: Env,
state: string,
): Promise<{ nodeId: string } | null> {
if (!state) return null;
const row = await env.DB.prepare("SELECT node_id, expires_at FROM oauth_states WHERE state = ?")
.bind(state)
.first<{ node_id: string; expires_at: string }>();
if (!row) return null;
await env.DB.prepare("DELETE FROM oauth_states WHERE state = ?").bind(state).run();
if (row.expires_at < nowIso()) return null;
return { nodeId: row.node_id };
}
/** # 函数
* 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);
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, ?, ?, ?)`,
)
.bind(ids.oauth(), encrypted, new Date(Date.now() + expiresIn * 1000).toISOString(), nowIso(), nowIso())
.run();
}