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
-36
View File
@@ -1,36 +0,0 @@
/**
* 前端构建:bun build 打包 React SPA → console/public/
* 产物:index.html + assets/main.js + logo/favicon。
*/
import { cpSync, mkdirSync } from "node:fs";
import path from "node:path";
const ROOT = path.resolve(import.meta.dir, "..");
const FRONTEND = path.join(ROOT, "console/frontend");
const PUBLIC = path.join(ROOT, "console/public");
mkdirSync(path.join(PUBLIC, "assets"), { recursive: true });
const result = await Bun.build({
entrypoints: [path.join(FRONTEND, "src/main.tsx")],
outdir: path.join(PUBLIC, "assets"),
naming: "[name].[ext]",
target: "browser",
minify: true,
splitting: false,
sourcemap: process.env.WMAILHUB_DEV ? "linked" : "none",
});
if (!result.success) {
for (const log of result.logs) console.error(log);
console.error("[build] frontend build failed");
process.exit(1);
}
cpSync(path.join(FRONTEND, "index.html"), path.join(PUBLIC, "index.html"));
cpSync(path.join(ROOT, "assets/logo.svg"), path.join(PUBLIC, "logo.svg"));
cpSync(path.join(ROOT, "assets/favicon.svg"), path.join(PUBLIC, "favicon.svg"));
cpSync(path.join(ROOT, "assets/favicon.ico"), path.join(PUBLIC, "favicon.ico"));
console.log("[build] frontend -> console/public OK");
+30 -33
View File
@@ -4,9 +4,9 @@
* bun run dev -- --reset # 清空本地数据后全新安装
*
* 流程:
* 1. 构建前端
* 1. 构建前端(vite build)
* 2. 启动 Console(:18787,自动迁移 + admin/admin 种子)
* 3. 确保存在本地 Node(无则创建)并完成 sim 模式 OAuth 自动安装(幂等)
* 3. 确保存在本地账号(无则 OAuth sim 自动创建账号 + 同步域名),并逐域名配置 Email Workers
* 4. 启动 Node(:18788,enrollment 换取身份,config.json 0600 落盘)
* 5. 启动 Email Worker(:18789,转发到 Node)
* 6. Ctrl-C 一起退出
@@ -79,54 +79,51 @@ if (!loginResp.ok) throw new Error("admin 登录失败");
const cookie = (loginResp.headers.get("set-cookie") ?? "").split(";")[0];
const authHeaders = { Cookie: cookie, "Content-Type": "application/json" };
// 3. 确保本地 Node 存在
const nodesResp = await fetch(`${BASE}/api/nodes`, { headers: authHeaders });
const existing = ((await nodesResp.json()) as { items: { id: string; name: string }[] }).items;
// 3. 确保本地账号存在(无则走 OAuth 自动创建);拿到 ingress secret 与 enrollment token
const accountsResp = await fetch(`${BASE}/api/accounts`, { headers: authHeaders });
const existing = ((await accountsResp.json()) as { items: { id: string; name: string }[] }).items;
let nodeId: string;
let ingressSecret: string;
let enrollmentToken: string | null = null;
if (existing.length > 0) {
nodeId = existing[0].id;
ingressSecret = ((await (await fetch(`${BASE}/api/nodes/${nodeId}/ingress-secret`, { headers: authHeaders })).json()) as { ingress_secret: string }).ingress_secret;
ingressSecret = ((await (await fetch(`${BASE}/api/accounts/${nodeId}/ingress-secret`, { headers: authHeaders })).json()) as { ingress_secret: string }).ingress_secret;
// 无论 config.json 是否存在,都发一个新 token:有身份的 Node 会忽略它,无身份的用它 enrollment
const rotated = (await (
await fetch(`${BASE}/api/nodes/${nodeId}/rotate`, { method: "POST", headers: authHeaders })
await fetch(`${BASE}/api/accounts/${nodeId}/rotate`, { method: "POST", headers: authHeaders })
).json()) as { enrollment_token: string };
enrollmentToken = rotated.enrollment_token;
console.log(`[dev] 复用既有节点 ${nodeId}`);
console.log(`[dev] 复用既有账号 ${nodeId}`);
} else {
const created = (await (
await fetch(`${BASE}/api/nodes`, {
method: "POST",
headers: authHeaders,
body: JSON.stringify({ name: "Local Node", ingress_url: `http://127.0.0.1:${NODE_PORT}/ingress/email` }),
})
).json()) as { node_id: string; enrollment_token: string; ingress_secret: string };
nodeId = created.node_id;
ingressSecret = created.ingress_secret;
enrollmentToken = created.enrollment_token;
console.log(`[dev] 已创建节点 ${nodeId}`);
}
// 4. sim OAuth 自动安装(幂等;节点已 online 时跳过)
const nodeDetail = (await (await fetch(`${BASE}/api/nodes/${nodeId}`, { headers: authHeaders })).json()) as {
node: { status: string };
steps: Record<string, { status: string }>;
};
if (nodeDetail.node.status === "pending_oauth") {
const startResp = await fetch(`${BASE}/api/oauth/cloudflare/start?node_id=${nodeId}`, { headers: authHeaders });
// 添加账号:OAuth start 不带 node_id,服务端自动创建账号
const startResp = await fetch(`${BASE}/api/oauth/cloudflare/start`, { headers: authHeaders });
const { state } = (await startResp.json()) as { state: string };
const approve = await fetch(`${BASE}/api/oauth/cloudflare/sim/approve`, {
method: "POST",
headers: authHeaders,
body: JSON.stringify({ state }),
});
const approveRes = (await approve.json()) as { ok: boolean; error?: string };
if (!approveRes.ok) throw new Error(`自动安装失败:${approveRes.error}`);
console.log("[dev] Cloudflare OAuth(sim)自动安装完成:Email Worker / Routing / Domain / Mailbox 就绪");
} else {
console.log(`[dev] 节点已安装(status=${nodeDetail.node.status}),跳过安装`);
const approveRes = (await approve.json()) as { ok: boolean; error?: string; node_id: string };
if (!approveRes.ok) throw new Error(`自动创建账号失败:${approveRes.error}`);
nodeId = approveRes.node_id;
ingressSecret = ((await (await fetch(`${BASE}/api/accounts/${nodeId}/ingress-secret`, { headers: authHeaders })).json()) as { ingress_secret: string }).ingress_secret;
const rotated = (await (
await fetch(`${BASE}/api/accounts/${nodeId}/rotate`, { method: "POST", headers: authHeaders })
).json()) as { enrollment_token: string };
enrollmentToken = rotated.enrollment_token;
console.log(`[dev] OAuth(sim)已创建账号 ${nodeId} 并同步域名`);
}
// 4. 域名同步后逐域名配置 Email Workers(sim 模式记录;幂等)
const domainsResp = await fetch(`${BASE}/api/domains`, { headers: authHeaders });
const domains = ((await domainsResp.json()) as { items: { id: string; name: string; status: string }[] }).items;
for (const d of domains.filter((x) => x.status !== "active")) {
const setup = (await (
await fetch(`${BASE}/api/domains/${d.id}/setup`, { method: "POST", headers: authHeaders })
).json()) as { ok: boolean; error?: string };
if (!setup.ok) throw new Error(`域名 ${d.name} 配置 Email Workers 失败:${setup.error}`);
console.log(`[dev] 域名 ${d.name} Email Workers 已配置`);
}
// 5. Node(首次用 enrollment token 换身份;之后 config.json 已有身份)