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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user