- 设置新增「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 步
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
/**
|
|
* Cloudflare API 客户端:真实调用 api.cloudflare.com / dash.cloudflare.com。
|
|
*
|
|
* OAuth access token 来自 Cloudflare OAuth 授权(docs/02),Console 用它完成
|
|
* 账号/域名发现、Email Worker 部署与 Email Routing 配置。
|
|
*
|
|
* 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";
|
|
|
|
/** # 成员变量: OAuth token 端点 **/
|
|
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",
|
|
"zone:read",
|
|
"email_routing:edit",
|
|
"workers_scripts:edit",
|
|
].join(" ");
|
|
|
|
/** # 成员变量: 邮件发现的 Cloudflare 资源描述 **/
|
|
export interface CFAccount {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
export interface CFZone {
|
|
id: string;
|
|
name: string;
|
|
account_id: string;
|
|
status: string;
|
|
}
|
|
|
|
/** # 成员变量: Cloudflare API 客户端接口(自动安装所需操作) **/
|
|
export interface CFApi {
|
|
/** 用 code 换 access token */
|
|
exchangeToken(code: string, redirectUri: string): Promise<{ accessToken: string; expiresIn: number }>;
|
|
/** 发现 token 可访问的账户 */
|
|
listAccounts(accessToken: string): Promise<CFAccount[]>;
|
|
/** 发现账户下的域名 Zone */
|
|
listZones(accessToken: string, accountId: string): Promise<CFZone[]>;
|
|
/** 创建或更新账号级 Email Worker(幂等) */
|
|
putEmailWorker(opts: {
|
|
accessToken: string;
|
|
accountId: string;
|
|
scriptName: string;
|
|
/** Console 对外地址(Worker -> Console) **/
|
|
consoleUrl: string;
|
|
/** Worker 身份(与 WorkerCredential.worker_id 一致) **/
|
|
workerId: string;
|
|
/** Worker credential secret(HMAC 签名密钥) **/
|
|
workerSecret: string;
|
|
}): Promise<{ id: string }>;
|
|
/** 开启 Zone 的 Email Routing(幂等) */
|
|
enableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ enabled: boolean }>;
|
|
/** 配置 Zone catch-all 规则指向 Email Worker(幂等) */
|
|
setCatchAllRule(opts: {
|
|
accessToken: string;
|
|
zoneId: string;
|
|
workerName: string;
|
|
}): Promise<{ ok: boolean }>;
|
|
}
|
|
|
|
/** # 函数
|
|
* 构造 Cloudflare API 客户端(内部使用已解析的 OAuth 客户端凭据)
|
|
* 返回值: 未配置 OAuth 客户端时抛错(调用方应先判断 isOAuthConfigured)
|
|
*/
|
|
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 {
|
|
/** # 成员变量: OAuth 客户端凭据(settings 或 env) **/
|
|
constructor(private client: CFOAuthClient) {}
|
|
|
|
async exchangeToken(code: string, redirectUri: string) {
|
|
const resp = await fetch(CF_OAUTH_TOKEN_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
code,
|
|
client_id: this.client.clientId,
|
|
client_secret: this.client.clientSecret,
|
|
redirect_uri: redirectUri,
|
|
}),
|
|
});
|
|
if (!resp.ok) throw new Error(`cloudflare token exchange failed: ${resp.status}`);
|
|
const data = (await resp.json()) as { access_token: string; expires_in?: number };
|
|
return { accessToken: data.access_token, expiresIn: data.expires_in ?? 3600 };
|
|
}
|
|
|
|
private async api<T>(token: string, path: string, init?: RequestInit): Promise<T> {
|
|
const resp = await fetch(`${CF_API_BASE}${path}`, {
|
|
...init,
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
...(init?.headers ?? {}),
|
|
},
|
|
});
|
|
const data = (await resp.json()) as { success: boolean; errors?: { message: string }[]; result: T };
|
|
if (!data.success) throw new Error(`cloudflare api ${path}: ${data.errors?.[0]?.message ?? resp.status}`);
|
|
return data.result;
|
|
}
|
|
|
|
listAccounts(token: string) {
|
|
return this.api<CFAccount[]>(token, "/accounts");
|
|
}
|
|
|
|
async listZones(token: string, accountId: string) {
|
|
// 分页拉全账号下的 Zone(每页 50,最多 20 页)
|
|
const out: CFZone[] = [];
|
|
for (let page = 1; page <= 20; page++) {
|
|
const rows = await this.api<CFZone[]>(
|
|
token,
|
|
`/zones?account.id=${encodeURIComponent(accountId)}&per_page=50&page=${page}`,
|
|
);
|
|
out.push(...rows);
|
|
if (rows.length < 50) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async putEmailWorker(opts: {
|
|
accessToken: string;
|
|
accountId: string;
|
|
scriptName: string;
|
|
consoleUrl: string;
|
|
workerId: string;
|
|
workerSecret: string;
|
|
}) {
|
|
// Email Worker 即带 email handler 的 Worker 脚本;Console 地址与 Worker 凭据以明文常量打进脚本,
|
|
// 避免给 Worker 配置额外的 secrets 绑定(docs/11 §8:凭据本身经 HMAC 保护 Console API)。
|
|
const script = renderEmailWorkerScript(opts.consoleUrl, opts.workerId, opts.workerSecret);
|
|
const form = new FormData();
|
|
form.append(
|
|
"metadata",
|
|
new Blob([JSON.stringify({ main_module: "worker.js", bindings: [] })], { type: "application/json" }),
|
|
);
|
|
form.append("worker.js", new Blob([script], { type: "application/javascript+module" }));
|
|
const resp = await fetch(
|
|
`${CF_API_BASE}/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`,
|
|
{
|
|
method: "PUT",
|
|
headers: { Authorization: `Bearer ${opts.accessToken}` },
|
|
body: form,
|
|
},
|
|
);
|
|
if (!resp.ok) throw new Error(`upload email worker failed: ${resp.status}`);
|
|
return { id: opts.scriptName };
|
|
}
|
|
|
|
async enableEmailRouting(opts: { accessToken: string; zoneId: string }) {
|
|
await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ enabled: true, skip_wizard: true }),
|
|
});
|
|
return { enabled: true };
|
|
}
|
|
|
|
async setCatchAllRule(opts: { accessToken: string; zoneId: string; workerName: string }) {
|
|
await this.api(opts.accessToken, `/zones/${opts.zoneId}/email/routing/rules/catch_all`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({
|
|
matchers: [{ type: "all" }],
|
|
actions: [{ type: "worker", value: [opts.workerName] }],
|
|
enabled: true,
|
|
name: "wmailhub catch-all",
|
|
}),
|
|
});
|
|
return { ok: true };
|
|
}
|
|
}
|