/** * 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 { 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 { 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 { 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 { 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; /** 发现账户下的域名 Zone */ listZones(accessToken: string, accountId: string): Promise; /** 创建或更新账号级 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 { 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(token: string, path: string, init?: RequestInit): Promise { 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(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( 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 }; } }