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
+110 -16
View File
@@ -3,12 +3,17 @@
*
* OAuth access token 来自 Cloudflare OAuth 授权(docs/02),Console 用它完成
* 账号/域名发现、Email Worker 部署与 Email Routing 配置。
* 未配置 CF_OAUTH_CLIENT_ID/SECRET 时不提供任何降级通道——
* /api/oauth/cloudflare/start 直接返回 503,避免出现"看似接入成功"的假数据。
*
* OAuth 客户端凭据(client id / secret)有两个来源,settings 表优先、env 兜底:
* 1. settings 表 —— 在「设置 → Cloudflare 接入」里填写;secret 用 AES-GCM 加密存储
* (cf_oauth_client_secret_enc),只回 last4。保存后立即生效,无需重新部署。
* 2. env 变量 CF_OAUTH_CLIENT_ID / CF_OAUTH_CLIENT_SECRET —— 部署时注入的兜底。
* 两者都没有时不提供任何降级通道,接入接口直接返回 503,避免出现"看似接入成功"的假数据。
*/
import { renderEmailWorkerScript } from "../../../shared/src/index.ts";
import type { Env } from "../env.ts";
import { decryptSecret, encryptSecret, getSetting, setSetting } from "../db.ts";
/** # 成员变量: OAuth 授权端点 **/
export const CF_OAUTH_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth";
@@ -19,6 +24,99 @@ export const CF_OAUTH_TOKEN_URL = "https://dash.cloudflare.com/oauth2/token";
/** # 成员变量: Cloudflare API 基址 **/
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
/** # 成员变量: 授权回调路径 **/
export const CF_OAUTH_CALLBACK_PATH = "/api/oauth/cloudflare/callback";
/** # 成员变量: settings 表中保存 OAuth 客户端凭据的 key **/
export const CF_OAUTH_SETTING_KEYS = {
clientId: "cf_oauth_client_id",
secretEnc: "cf_oauth_client_secret_enc",
secretLast4: "cf_oauth_client_secret_last4",
} as const;
/** # 成员变量: 已解析的 Cloudflare OAuth 客户端凭据 **/
export interface CFOAuthClient {
clientId: string;
clientSecret: string;
/** 凭据来源:settings(后台设置)优先,env(部署变量)兜底 */
source: "settings" | "env";
}
/** # 函数
* 解析当前生效的 Cloudflare OAuth 客户端凭据(settings 优先,env 兜底)
* 返回值: 未配置时 null
*/
export async function getOAuthClient(env: Env): Promise<CFOAuthClient | null> {
const [clientId, secretEnc] = await Promise.all([
getSetting(env, CF_OAUTH_SETTING_KEYS.clientId),
getSetting(env, CF_OAUTH_SETTING_KEYS.secretEnc),
]);
if (clientId && secretEnc) {
try {
const secret = await decryptSecret(env, secretEnc);
if (secret) return { clientId, clientSecret: secret, source: "settings" };
} catch {
// 密文损坏(主密钥变更等)时继续尝试 env 兜底
}
}
if (env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET) {
return { clientId: env.CF_OAUTH_CLIENT_ID, clientSecret: env.CF_OAUTH_CLIENT_SECRET, source: "env" };
}
return null;
}
/** # 函数
* Cloudflare OAuth 是否已配置(settings 或 env 任一来源可用)
*/
export async function isOAuthConfigured(env: Env): Promise<boolean> {
return (await getOAuthClient(env)) !== null;
}
/** # 函数
* OAuth 客户端凭据的展示用元信息(不含明文 secret)
*/
export async function oauthClientMeta(
env: Env,
): Promise<{ client_id: string; has_secret: boolean; secret_last4: string; source: "settings" | "env" | "none" }> {
const client = await getOAuthClient(env);
if (!client) return { client_id: "", has_secret: false, secret_last4: "", source: "none" };
const last4 = await getSetting(env, CF_OAUTH_SETTING_KEYS.secretLast4);
return {
client_id: client.clientId,
has_secret: true,
secret_last4: client.source === "settings" ? (last4 ?? "") : client.clientSecret.slice(-4),
source: client.source,
};
}
/** # 函数
* 保存 OAuth 客户端凭据到 settings(secret 加密存储,只留 last4 供展示)
*/
export async function saveOAuthClient(env: Env, clientId: string, secret: string): Promise<void> {
await setSetting(env, CF_OAUTH_SETTING_KEYS.clientId, clientId);
await setSetting(env, CF_OAUTH_SETTING_KEYS.secretEnc, await encryptSecret(env, secret));
await setSetting(env, CF_OAUTH_SETTING_KEYS.secretLast4, secret.slice(-4));
}
/** # 函数
* 清除 settings 中的 OAuth 客户端凭据(env 兜底不受影响)
*/
export async function clearOAuthClient(env: Env): Promise<void> {
for (const key of Object.values(CF_OAUTH_SETTING_KEYS)) {
await env.DB.prepare("DELETE FROM settings WHERE key = ?").bind(key).run();
}
}
/** # 函数
* 计算 OAuth 授权回调地址(优先 CF_OAUTH_REDIRECT_URI,其次 PUBLIC_BASE_URL,最后请求 origin)
*/
export function oauthRedirectUri(env: Env, requestOrigin: string): string {
const base = env.CF_OAUTH_REDIRECT_URI ?? env.PUBLIC_BASE_URL ?? requestOrigin;
return base.endsWith(CF_OAUTH_CALLBACK_PATH)
? base
: `${base.replace(/\/$/, "")}${CF_OAUTH_CALLBACK_PATH}`;
}
/** # 成员变量: OAuth 申请权限(docs/08:最小权限——账户与 Zone 读、Email Routing 读写、Workers 脚本写) **/
export const CF_OAUTH_SCOPES = [
"account:read",
@@ -71,23 +169,19 @@ export interface CFApi {
}
/** # 函数
* Cloudflare OAuth 是否已配置(client id + secret 齐备)
* 构造 Cloudflare API 客户端(内部使用已解析的 OAuth 客户端凭据)
* 返回值: 未配置 OAuth 客户端时抛错(调用方应先判断 isOAuthConfigured)
*/
export function isOAuthConfigured(env: Env): boolean {
return Boolean(env.CF_OAUTH_CLIENT_ID && env.CF_OAUTH_CLIENT_SECRET);
}
/** # 函数
* 构造 Cloudflare API 客户端
*/
export function getCFApi(env: Env): CFApi {
return new RealCFApi(env);
export async function getCFApi(env: Env): Promise<CFApi> {
const client = await getOAuthClient(env);
if (!client) throw new Error("cloudflare oauth not configured");
return new RealCFApi(client);
}
/** # 成员变量: Cloudflare API 实现 **/
export class RealCFApi implements CFApi {
/** # 成员变量 **/
constructor(private env: Env) {}
/** # 成员变量: OAuth 客户端凭据(settings 或 env) **/
constructor(private client: CFOAuthClient) {}
async exchangeToken(code: string, redirectUri: string) {
const resp = await fetch(CF_OAUTH_TOKEN_URL, {
@@ -96,8 +190,8 @@ export class RealCFApi implements CFApi {
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: this.env.CF_OAUTH_CLIENT_ID ?? "",
client_secret: this.env.CF_OAUTH_CLIENT_SECRET ?? "",
client_id: this.client.clientId,
client_secret: this.client.clientSecret,
redirect_uri: redirectUri,
}),
});
+3 -3
View File
@@ -109,7 +109,7 @@ export interface ProvisionInput {
* 部署账号级 Email Worker + 签发 Worker credential。幂等,可重复调用;返回是否全部成功。
*/
export async function provisionAccount(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> {
const cf: CFApi = getCFApi(env);
const cf: CFApi = await getCFApi(env);
const account = await env.DB.prepare("SELECT id, name, status FROM cloudflare_accounts WHERE id = ?")
.bind(input.accountId)
.first<{ id: string; name: string; status: string }>();
@@ -249,7 +249,7 @@ async function putWorkerScript(
workerId: string,
workerSecret: string,
): Promise<void> {
const cf: CFApi = getCFApi(env);
const cf: CFApi = await getCFApi(env);
const consoleUrl = env.PUBLIC_BASE_URL ?? "";
if (!consoleUrl) throw new Error("console public url unknown (PUBLIC_BASE_URL)");
await cf.putEmailWorker({ accessToken, accountId: cfAccountId, scriptName: workerId, consoleUrl, workerId, workerSecret });
@@ -278,7 +278,7 @@ export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok
const accessToken = await decryptConnectionToken(env);
if (!accessToken) return { ok: false, error: "no active Cloudflare OAuth connection" };
const cf: CFApi = getCFApi(env);
const cf: CFApi = await getCFApi(env);
try {
const workerId = await deployAccountWorker(env, account.id, accessToken);
await cf.enableEmailRouting({ accessToken, zoneId: domain.zone_id });