- 账号化:节点更名账号,/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
168 lines
7.1 KiB
TypeScript
168 lines
7.1 KiB
TypeScript
/**
|
|
* 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 { ids } from "../../../shared/src/index.ts";
|
|
import { CF_OAUTH_AUTHORIZE_URL, CF_OAUTH_SCOPES, getCFApi } from "../services/cfapi.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 }>;
|
|
|
|
/** # 成员变量: OAuth state 有效期(秒) **/
|
|
const STATE_TTL_SECONDS = 300;
|
|
|
|
/** # 成员变量: 自动创建账号时的默认 Node Ingress 地址(可在设置中修改) **/
|
|
export const DEFAULT_INGRESS_URL = "http://127.0.0.1:18788/ingress/email";
|
|
|
|
/** # 函数
|
|
* 是否使用真实 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,
|
|
});
|
|
});
|
|
|
|
/** 开始授权:node_id 缺省时自动创建账号;创建 state 并返回跳转 URL */
|
|
app.get("/cloudflare/start", async (c) => {
|
|
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(
|
|
"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 = `/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);
|
|
}
|
|
});
|
|
|
|
/** 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 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, ?, ?, ?)`,
|
|
)
|
|
.bind(ids.oauth(), encrypted, new Date(Date.now() + expiresIn * 1000).toISOString(), nowIso(), nowIso())
|
|
.run();
|
|
}
|