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);