- 设置新增「Cloudflare 接入」tab:Client ID / Client Secret(Secret AES-GCM 加密入库,
只回显末 4 位),并展示可复制的授权回调地址;settings 优先、env 变量兜底,
保存后立即生效无需重新部署;DELETE /api/settings/cf-oauth 清除
- 四个资源列表(账号/域名/邮箱/收件箱)加复选框批量选择与批量删除
- 新增 POST /api/{accounts,domains,mailboxes,emails}/batch-delete:
账号软删+撤销凭据;域名/邮箱级联删除其下邮件;邮件连 R2 原文与附件一并清理
- 抽出 deleteEmailById / deleteEmailsByMailbox / revokeAccount 供单条与批量共用
- 点击复选框不再触发行点击(避免误开详情抽屉)
- 新增 tests/batch.test.ts(6 例)与 settings OAuth 凭据用例(2 例);ui-smoke 扩到 17 步
183 lines
7.0 KiB
TypeScript
183 lines
7.0 KiB
TypeScript
/**
|
|
* 设置路由(v2.3.0):
|
|
* GET /api/settings 读取:全局 Webhook / API Key 元信息 / Cloudflare OAuth 客户端 / 当前用户
|
|
* PUT /api/settings 保存:全局 Webhook、Cloudflare OAuth 客户端
|
|
* POST /api/settings/api-key 生成(轮换)单一 API/MCP Key,明文仅返回一次
|
|
* DEL /api/settings/api-key 撤销并删除 API Key
|
|
* DEL /api/settings/cf-oauth 清除后台保存的 Cloudflare OAuth 客户端凭据
|
|
* PUT /api/settings/password 修改登录密码
|
|
* API 与 MCP 共用同一 Key,凭据存 settings 表(不再单独建表);
|
|
* Cloudflare OAuth Client Secret 同样只存加密密文,接口只回 last4。
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import type { Env, AppVars } from "../env.ts";
|
|
import { requireSession } from "../middleware.ts";
|
|
import {
|
|
checkWebhookUrl,
|
|
generateWmailKey,
|
|
hashPassword,
|
|
isValidWmailKey,
|
|
verifyPassword,
|
|
} from "../../../shared/src/index.ts";
|
|
import { getSetting, keyHash, setSetting } from "../db.ts";
|
|
import {
|
|
CF_OAUTH_SETTING_KEYS,
|
|
clearOAuthClient,
|
|
oauthClientMeta,
|
|
oauthRedirectUri,
|
|
saveOAuthClient,
|
|
} from "../services/cfapi.ts";
|
|
import {
|
|
getGlobalWebhookKey,
|
|
normalizeWebhookHeaders,
|
|
saveGlobalWebhook,
|
|
} from "../services/webhook_delivery.ts";
|
|
import { nowIso, readJson } from "../util.ts";
|
|
|
|
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
|
|
|
|
/** # 函数
|
|
* 读取 API Key 元信息(只有 last4,无明文)
|
|
*/
|
|
async function apiKeyMeta(env: Env): Promise<{ has_key: boolean; key_last4: string; created_at: string | null }> {
|
|
const [last4, createdAt] = await Promise.all([
|
|
getSetting(env, "api_key_last4"),
|
|
getSetting(env, "api_key_created_at"),
|
|
]);
|
|
return { has_key: Boolean(last4), key_last4: last4 ?? "", created_at: createdAt ?? null };
|
|
}
|
|
|
|
/** # 函数
|
|
* 装配设置路由(会话保护)
|
|
*/
|
|
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 ?? "",
|
|
webhook,
|
|
api_key: await apiKeyMeta(c.env),
|
|
cf_oauth: await oauthClientMeta(c.env),
|
|
// 供前端展示:需要在 Cloudflare Dashboard 登记的回调地址
|
|
redirect_uri: oauthRedirectUri(c.env, new URL(c.req.url).origin),
|
|
});
|
|
});
|
|
|
|
/** 保存设置:全局 Webhook、Cloudflare OAuth 客户端 */
|
|
app.put("/", async (c) => {
|
|
const body = await readJson<{
|
|
webhook?: { url?: string; headers?: unknown; key?: string };
|
|
cf_oauth?: { client_id?: string; secret?: string };
|
|
}>(c.req.raw);
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
if (body.cf_oauth) {
|
|
const clientId = (body.cf_oauth.client_id ?? "").trim();
|
|
// secret:undefined 保留现有;空白串(含 "")清除;非空设置新 secret
|
|
const rawSecret = typeof body.cf_oauth.secret === "string" ? body.cf_oauth.secret : undefined;
|
|
if (rawSecret === undefined) {
|
|
// 只改 client id:必须已存在 secret,否则保存后仍是未配置状态
|
|
const current = await getSetting(c.env, CF_OAUTH_SETTING_KEYS.secretEnc);
|
|
if (!current) return c.json({ error: "请填写 Client Secret" }, 400);
|
|
if (clientId) await setSetting(c.env, CF_OAUTH_SETTING_KEYS.clientId, clientId);
|
|
} else if (rawSecret.trim() === "") {
|
|
await clearOAuthClient(c.env);
|
|
} else {
|
|
if (!clientId) return c.json({ error: "请填写 Client ID" }, 400);
|
|
await saveOAuthClient(c.env, clientId, rawSecret.trim());
|
|
}
|
|
}
|
|
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
/** 生成(轮换)API/MCP Key:明文仅本次返回;旧 Key 立即失效 */
|
|
app.post("/api-key", async (c) => {
|
|
const key = generateWmailKey();
|
|
if (!isValidWmailKey(key)) return c.json({ error: "key generator broken" }, 500);
|
|
const createdAt = nowIso();
|
|
await setSetting(c.env, "api_key_hash", await keyHash(key));
|
|
await setSetting(c.env, "api_key_last4", key.slice(-4));
|
|
await setSetting(c.env, "api_key_created_at", createdAt);
|
|
return c.json({ ok: true, key, created_at: createdAt }, 201);
|
|
});
|
|
|
|
/** 撤销并删除 API Key */
|
|
app.delete("/api-key", async (c) => {
|
|
for (const k of ["api_key_hash", "api_key_last4", "api_key_created_at"]) {
|
|
await c.env.DB.prepare("DELETE FROM settings WHERE key = ?").bind(k).run();
|
|
}
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
/** 清除后台保存的 Cloudflare OAuth 客户端凭据(env 兜底仍可用) */
|
|
app.delete("/cf-oauth", async (c) => {
|
|
await clearOAuthClient(c.env);
|
|
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;
|
|
}
|