feat: v2.3.0 设置内配置 Cloudflare OAuth 客户端 + 全列表批量删除

- 设置新增「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 步
This commit is contained in:
bigcode
2026-09-30 15:53:18 +08:00
parent e2c3c353cf
commit 3be6e483b4
19 changed files with 1001 additions and 79 deletions
+20 -14
View File
@@ -18,7 +18,13 @@ 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, isOAuthConfigured } from "../services/cfapi.ts";
import {
CF_OAUTH_AUTHORIZE_URL,
CF_OAUTH_SCOPES,
getCFApi,
getOAuthClient,
oauthRedirectUri,
} from "../services/cfapi.ts";
import { provisionAccount, createAccount } from "../services/provision.ts";
import { encryptConnectionToken } from "../db.ts";
import { nowIso } from "../util.ts";
@@ -42,18 +48,20 @@ export function oauthRoutes(): App {
});
/** 授权模式与入口信息(前端 Connect Cloudflare Account 向导用) */
app.get("/mode", (c) => {
const configured = isOAuthConfigured(c.env);
app.get("/mode", async (c) => {
const client = await getOAuthClient(c.env);
return c.json({
mode: configured ? "cloudflare" : "unconfigured",
authorize_url_base: configured ? CF_OAUTH_AUTHORIZE_URL : null,
mode: client ? "cloudflare" : "unconfigured",
authorize_url_base: client ? CF_OAUTH_AUTHORIZE_URL : null,
source: client?.source ?? "none",
});
});
/** 开始授权:每次自动创建账号记录;创建 state 并返回跳转 URL */
app.get("/cloudflare/start", async (c) => {
if (!isOAuthConfigured(c.env)) {
return c.json({ error: "cloudflare oauth not configured (CF_OAUTH_CLIENT_ID/CF_OAUTH_CLIENT_SECRET)" }, 503);
const client = await getOAuthClient(c.env);
if (!client) {
return c.json({ error: "cloudflare oauth not configured (在设置 → Cloudflare 接入 中填写 Client ID / Secret)" }, 503);
}
const created = await createAccount(c.env, {
name: `Cloudflare 账号 ${new Date().toISOString().slice(0, 10)}`,
@@ -66,11 +74,10 @@ export function oauthRoutes(): App {
.bind(state, created.accountId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString())
.run();
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 redirectUri = oauthRedirectUri(c.env, new URL(c.req.url).origin);
const url =
`${CF_OAUTH_AUTHORIZE_URL}?response_type=code` +
`&client_id=${encodeURIComponent(c.env.CF_OAUTH_CLIENT_ID!)}` +
`&client_id=${encodeURIComponent(client.clientId)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&scope=${encodeURIComponent(CF_OAUTH_SCOPES)}` +
`&state=${encodeURIComponent(state)}`;
@@ -79,7 +86,7 @@ export function oauthRoutes(): App {
/** 授权回调(浏览器重定向):校验 state -> 兑换 token -> 自动接入 -> 跳回控制台 */
app.get("/cloudflare/callback", async (c) => {
if (!isOAuthConfigured(c.env)) {
if (!(await getOAuthClient(c.env))) {
return c.text("Cloudflare OAuth not configured", 503);
}
const url = new URL(c.req.url);
@@ -89,10 +96,9 @@ export function oauthRoutes(): App {
if (!verdict) {
return c.text("OAuth state invalid or expired (possible CSRF)", 400);
}
const cf = getCFApi(c.env);
const cf = await getCFApi(c.env);
try {
const base = c.env.PUBLIC_BASE_URL ?? url.origin;
const redirectUri = c.env.CF_OAUTH_REDIRECT_URI ?? `${base}/api/oauth/cloudflare/callback`;
const redirectUri = oauthRedirectUri(c.env, url.origin);
const token = await cf.exchangeToken(code, redirectUri);
// 加密入库(不进前端/日志)
await saveConnection(c.env, token.accessToken, token.expiresIn);
+148 -20
View File
@@ -6,6 +6,9 @@
* - POST /domains/:id/setup 为域名配置 Email Workers(Worker + Routing + catch-all)
* - POST /accounts/:id/rotate 轮换账号的 Worker credential(secret 仅一次可见)
* - 邮箱/域名级 Webhook 配置(PUT/DELETE .../webhook)
* - 批量操作(POST .../batch-delete):四个资源列表的复选框批量删除
* 账号=软删(撤销 Worker 凭据并标记 revoked);域名/邮箱=级联删除其下邮件;
* 邮件=连同 R2 原文与附件一起删除
*/
import { Hono } from "hono";
@@ -56,6 +59,80 @@ async function parseWebhookBody(
return { url, headers, key: rawKey !== undefined && rawKey.trim() === "" ? "" : rawKey };
}
/** # 成员变量: 批量操作请求体 **/
interface BatchBody {
ids?: unknown;
}
/** # 成员变量: 批量删除单次 id 上限(避免一次请求打爆 Worker) **/
const BATCH_LIMIT = 500;
/** # 函数
* 解析批量操作请求体中的 id 列表(过滤非字符串、去重、限长)
*/
async function readIds(c: { req: { raw: Request } }): Promise<string[]> {
const body = await readJson<BatchBody>(c.req.raw);
const list = Array.isArray(body.ids) ? body.ids : [];
return [...new Set(list.filter((v): v is string => typeof v === "string" && v.length > 0))].slice(0, BATCH_LIMIT);
}
/** # 函数
* 删除一封邮件:R2 原文与附件 -> 附件行 -> webhook delivery 行 -> 邮件行
* 返回值: 该邮件存在并被删除时 true
*/
async function deleteEmailById(env: Env, id: string): Promise<boolean> {
const email = await env.DB.prepare("SELECT raw_storage_key FROM emails WHERE id = ?")
.bind(id)
.first<{ raw_storage_key: string }>();
if (!email) return false;
const atts = await env.DB.prepare("SELECT storage_key FROM email_attachments WHERE email_id = ?")
.bind(id)
.all<{ storage_key: string | null }>();
const keys = [email.raw_storage_key, ...atts.results.map((a) => a.storage_key)].filter(
(k): k is string => Boolean(k),
);
if (keys.length) await env.R2.delete(keys);
await env.DB.prepare("DELETE FROM email_attachments WHERE email_id = ?").bind(id).run();
await env.DB.prepare("DELETE FROM webhook_deliveries WHERE email_id = ?").bind(id).run();
await env.DB.prepare("DELETE FROM emails WHERE id = ?").bind(id).run();
return true;
}
/** # 函数
* 删除某邮箱下的全部邮件(批量删除邮箱/域名时级联调用)
* 返回值: 删除的邮件数量
*/
async function deleteEmailsByMailbox(env: Env, mailboxId: string): Promise<number> {
const emails = await env.DB.prepare("SELECT id FROM emails WHERE mailbox_id = ?")
.bind(mailboxId)
.all<{ id: string }>();
let n = 0;
for (const e of emails.results) {
if (await deleteEmailById(env, e.id)) n++;
}
return n;
}
/** # 函数
* 软删账号:撤销 Worker credential + 标记 revoked(与 DELETE /accounts/:id 同一语义)
* 返回值: 账号存在且未撤销时 true
*/
async function revokeAccount(env: Env, id: string): Promise<boolean> {
const row = await env.DB.prepare("SELECT id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'")
.bind(id)
.first<{ id: string }>();
if (!row) return false;
await env.DB.prepare(
"UPDATE worker_credentials SET status = 'revoked', revoked_at = ? WHERE cloudflare_account_id = ? AND status = 'active'",
)
.bind(nowIso(), id)
.run();
await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'revoked', updated_at = ? WHERE id = ?")
.bind(nowIso(), id)
.run();
return true;
}
/** # 函数
* Account(账号)管理与安装状态
*/
@@ -117,17 +194,22 @@ function installAccountRoutes(app: App) {
/** 删除账号(软删:撤销 Worker credential + 标记 revoked) */
app.delete("/accounts/:id", async (c) => {
const id = c.req.param("id");
await c.env.DB.prepare(
"UPDATE worker_credentials SET status = 'revoked', revoked_at = ? WHERE cloudflare_account_id = ? AND status = 'active'",
)
.bind(nowIso(), id)
.run();
await c.env.DB.prepare("UPDATE cloudflare_accounts SET status = 'revoked', updated_at = ? WHERE id = ?")
.bind(nowIso(), id)
.run();
await revokeAccount(c.env, id);
return c.json({ ok: true });
});
/** 批量删除账号(软删 + 撤销 Worker credential);域名数据保留 */
app.post("/accounts/batch-delete", async (c) => {
const ids = await readIds(c);
const failed: { id: string; reason: string }[] = [];
let deleted = 0;
for (const id of ids) {
if (await revokeAccount(c.env, id)) deleted++;
else failed.push({ id, reason: "账号不存在或已删除" });
}
return c.json({ ok: true, deleted, failed });
});
/** 轮换 Worker credential:撤销旧 credential 并签发新 secret(仅本次响应可见);
* 已配置 Email Workers 的域名自动重新部署脚本以携带新 secret。 */
app.post("/accounts/:id/rotate", async (c) => {
@@ -247,6 +329,35 @@ function installDomainRoutes(app: App) {
await c.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(id).run();
return c.json({ ok: true });
});
/** 批量删除域名:级联删除该域名下所有邮箱(含 catch-all)及其邮件
* ——单条删除为了防误操作会在有邮箱时返回 409,批量场景下 catch-all 邮箱总是存在,
* 因此批量入口显式级联,前端确认框会写明影响范围。 */
app.post("/domains/batch-delete", async (c) => {
const ids = await readIds(c);
const failed: { id: string; reason: string }[] = [];
let deleted = 0;
let mailboxes = 0;
let emails = 0;
for (const id of ids) {
const boxes = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE domain_id = ?")
.bind(id)
.all<{ id: string }>();
for (const b of boxes.results) {
emails += await deleteEmailsByMailbox(c.env, b.id);
}
await c.env.DB.prepare("DELETE FROM mailboxes WHERE domain_id = ?").bind(id).run();
const res = await c.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(id).run();
const changes = (res as unknown as { meta?: { changes?: number } })?.meta?.changes ?? 0;
if (changes > 0) {
deleted++;
mailboxes += boxes.results.length;
} else {
failed.push({ id, reason: "域名不存在" });
}
}
return c.json({ ok: true, deleted, mailboxes, emails, failed });
});
}
/** # 函数
@@ -338,6 +449,22 @@ function installMailboxRoutes(app: App) {
await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
return c.json({ ok: true });
});
/** 批量删除邮箱:级联删除邮箱下的邮件(R2 原文与附件一并清理) */
app.post("/mailboxes/batch-delete", async (c) => {
const ids = await readIds(c);
const failed: { id: string; reason: string }[] = [];
let deleted = 0;
let emails = 0;
for (const id of ids) {
emails += await deleteEmailsByMailbox(c.env, id);
const res = await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
const changes = (res as unknown as { meta?: { changes?: number } })?.meta?.changes ?? 0;
if (changes > 0) deleted++;
else failed.push({ id, reason: "邮箱不存在" });
}
return c.json({ ok: true, deleted, emails, failed });
});
}
/** # 函数
@@ -432,18 +559,19 @@ function installEmailRoutes(app: App) {
app.delete("/emails/:id", async (c) => {
const id = c.req.param("id");
const email = await c.env.DB.prepare("SELECT raw_storage_key FROM emails WHERE id = ?")
.bind(id)
.first<{ raw_storage_key: string }>();
if (!email) return c.json({ error: "not found" }, 404);
const atts = await c.env.DB.prepare("SELECT storage_key FROM email_attachments WHERE email_id = ?")
.bind(id)
.all<{ storage_key: string | null }>();
const keys = [email.raw_storage_key, ...atts.results.map((a) => a.storage_key)].filter(Boolean) as string[];
if (keys.length) await c.env.R2.delete(keys);
await c.env.DB.prepare("DELETE FROM email_attachments WHERE email_id = ?").bind(id).run();
await c.env.DB.prepare("DELETE FROM webhook_deliveries WHERE email_id = ?").bind(id).run();
await c.env.DB.prepare("DELETE FROM emails WHERE id = ?").bind(id).run();
if (!(await deleteEmailById(c.env, id))) return c.json({ error: "not found" }, 404);
return c.json({ ok: true });
});
/** 批量删除邮件:R2 原文与附件 + 附件行 + delivery 行一并清理 */
app.post("/emails/batch-delete", async (c) => {
const ids = await readIds(c);
const failed: { id: string; reason: string }[] = [];
let deleted = 0;
for (const id of ids) {
if (await deleteEmailById(c.env, id)) deleted++;
else failed.push({ id, reason: "邮件不存在或已过期清理" });
}
return c.json({ ok: true, deleted, failed });
});
}
+41 -5
View File
@@ -1,11 +1,13 @@
/**
* 设置路由(v2.1.0):
* GET /api/settings 读取:全局 Webhook / API Key 元信息 / 当前用户
* PUT /api/settings 保存:全局 Webhook
* 设置路由(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 表(不再单独建表)。
* API 与 MCP 共用同一 Key,凭据存 settings 表(不再单独建表);
* Cloudflare OAuth Client Secret 同样只存加密密文,接口只回 last4。
*/
import { Hono } from "hono";
@@ -19,6 +21,13 @@ import {
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,
@@ -70,13 +79,17 @@ export function settingsRoutes(): App {
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 */
/** 保存设置:全局 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) {
@@ -100,6 +113,23 @@ export function settingsRoutes(): App {
});
}
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 });
});
@@ -122,6 +152,12 @@ export function settingsRoutes(): App {
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);