162 lines
8.7 KiB
TypeScript
162 lines
8.7 KiB
TypeScript
import type { TomlTable } from 'smol-toml';
|
|
|
|
export const runtimeSecretNames = ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD', 'GH_CLIENT_SECRET'] as const;
|
|
export type RuntimeSecrets = Record<(typeof runtimeSecretNames)[number], string>;
|
|
|
|
export interface DeploymentSettings {
|
|
accountId: string;
|
|
apiToken: string;
|
|
workerName: string;
|
|
databaseName: string;
|
|
databaseId?: string;
|
|
customDomain?: string;
|
|
previewDomain?: string;
|
|
deployPreview?: boolean;
|
|
authProvider: 'cloudflare' | 'github';
|
|
githubClientId?: string;
|
|
githubAdmin?: string;
|
|
githubAdminId?: string;
|
|
adminEmail?: string;
|
|
identityProviderIds: string[];
|
|
secrets: Partial<RuntimeSecrets>;
|
|
}
|
|
|
|
export interface Database {
|
|
uuid: string;
|
|
name: string;
|
|
}
|
|
|
|
export function readDeploymentSettings(
|
|
template: TomlTable,
|
|
env: NodeJS.ProcessEnv,
|
|
): DeploymentSettings {
|
|
const authProvider = env.AUTH_PROVIDER?.trim() || 'cloudflare';
|
|
if (authProvider !== 'cloudflare' && authProvider !== 'github') throw new Error('AUTH_PROVIDER 只能是 cloudflare 或 github。');
|
|
const required = ['CLOUDFLARE_API_TOKEN', ...(authProvider === 'github' ? ['GH_CLIENT_ID', 'GH_CLIENT_SECRET'] : [])];
|
|
const missing = required.filter((name) => !env[name]?.trim());
|
|
if (missing.length) throw new Error(`缺少部署配置:${missing.join(', ')}。请在 GitHub Actions 中配置。`);
|
|
|
|
const accountId = env.CLOUDFLARE_ACCOUNT_ID?.trim() || '';
|
|
if (accountId && !/^[a-f0-9]{32}$/i.test(accountId)) throw new Error('CLOUDFLARE_ACCOUNT_ID 必须是 32 位十六进制账户 ID。');
|
|
const workerName = env.WORKER_NAME?.trim() || String(template.name);
|
|
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(workerName)) throw new Error('WORKER_NAME 必须是 1–63 位小写字母、数字或连字符,且不能以连字符开头。');
|
|
const deployPreviewValue = env.DEPLOY_PREVIEW_WORKER?.trim().toLowerCase() || 'false';
|
|
if (deployPreviewValue !== 'true' && deployPreviewValue !== 'false') throw new Error('DEPLOY_PREVIEW_WORKER 只能是 true 或 false。');
|
|
const deployPreview = deployPreviewValue === 'true';
|
|
if (deployPreview && `${workerName}-preview`.length > 63) throw new Error('WORKER_NAME 加上 -preview 后不能超过 63 位。');
|
|
|
|
const databases = template.d1_databases as TomlTable[] | undefined;
|
|
const database = databases?.find((binding) => binding.binding === 'DB');
|
|
if (!database) throw new Error('wrangler.toml 缺少 DB 数据库绑定。');
|
|
// 改 Worker 名时默认隔离数据库;只有显式指定名称或 ID 才复用另一实例的数据。
|
|
const databaseName = env.D1_DATABASE_NAME?.trim()
|
|
|| (workerName === template.name ? String(database.database_name) : `${workerName}-accounts`);
|
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(databaseName)) throw new Error('D1_DATABASE_NAME 必须是 1–64 位字母、数字、下划线或连字符,且以字母或数字开头。');
|
|
const databaseId = env.D1_DATABASE_ID?.trim() || (database.database_id as string | undefined);
|
|
if (databaseId && !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(databaseId)) {
|
|
throw new Error('D1_DATABASE_ID 必须是有效的数据库 UUID。');
|
|
}
|
|
const customDomain = env.CUSTOM_DOMAIN?.trim();
|
|
if (customDomain && !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(customDomain)) {
|
|
throw new Error('CUSTOM_DOMAIN 只能填写完整域名,不能包含协议、路径或通配符。');
|
|
}
|
|
if (customDomain?.toLowerCase().endsWith('.workers.dev')) {
|
|
throw new Error('CUSTOM_DOMAIN 不能填写 workers.dev 地址;使用 Worker 自带域名时请删除或留空该配置。');
|
|
}
|
|
const previewDomain = deployPreview ? env.PREVIEW_DOMAIN?.trim() : undefined;
|
|
if (deployPreview && previewDomain && !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(previewDomain)) {
|
|
throw new Error('PREVIEW_DOMAIN 只能填写完整域名,不能包含协议、路径或通配符。');
|
|
}
|
|
if (deployPreview && previewDomain?.toLowerCase().endsWith('.workers.dev')) {
|
|
throw new Error('PREVIEW_DOMAIN 不能填写 workers.dev 地址;请使用自定义跨站域名,或留空自动生成预览地址。');
|
|
}
|
|
|
|
const adminEmail = authProvider === 'cloudflare' ? env.ADMIN_EMAIL?.trim().toLowerCase() : undefined;
|
|
if (adminEmail && !/^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/.test(adminEmail)) {
|
|
throw new Error('ADMIN_EMAIL 必须是一个完整的管理员邮箱,不能填写多个邮箱或整个域名。');
|
|
}
|
|
const identityProviderIds = authProvider === 'cloudflare'
|
|
? [...new Set((env.ACCESS_IDP_IDS || '').split(',').map((id) => id.trim()).filter(Boolean))] : [];
|
|
if (identityProviderIds.some((id) => !/^[a-f0-9-]{36}$/i.test(id))) {
|
|
throw new Error('ACCESS_IDP_IDS 必须是逗号分隔的 Cloudflare 身份提供程序 UUID。');
|
|
}
|
|
const githubClientId = authProvider === 'github' ? env.GH_CLIENT_ID!.trim() : undefined;
|
|
const githubAdmin = authProvider === 'github' ? env.GH_ADMIN?.trim().toLowerCase() || undefined : undefined;
|
|
if (githubAdmin && !/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/.test(githubAdmin)) {
|
|
throw new Error('GH_ADMIN 必须是一个 GitHub 用户名。');
|
|
}
|
|
const githubAdminId = authProvider === 'github' ? env.GH_ADMIN_ID?.trim() || undefined : undefined;
|
|
if (githubAdminId && !/^[1-9]\d*$/.test(githubAdminId)) {
|
|
throw new Error('GH_ADMIN_ID 必须是 GitHub 个人账号的数字用户 ID。');
|
|
}
|
|
const selectedSecrets = authProvider === 'github'
|
|
? ['ENCRYPTION_KEY', 'GH_CLIENT_SECRET'] : ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD'];
|
|
const secrets = Object.fromEntries(selectedSecrets
|
|
.filter((name) => env[name]?.trim())
|
|
.map((name) => [name, env[name]!.trim()])) as Partial<RuntimeSecrets>;
|
|
if (secrets.ENCRYPTION_KEY) {
|
|
const key = Buffer.from(secrets.ENCRYPTION_KEY, 'base64');
|
|
if (!/^[A-Za-z0-9+/]{43}=?$/.test(secrets.ENCRYPTION_KEY)
|
|
|| key.length !== 32
|
|
|| key.toString('base64').replace(/=+$/, '') !== secrets.ENCRYPTION_KEY.replace(/=+$/, '')) {
|
|
throw new Error('ENCRYPTION_KEY 必须是 Base64 编码的 32 字节密钥;已有数据时必须继续使用原密钥。');
|
|
}
|
|
}
|
|
if (secrets.ACCESS_TEAM_DOMAIN && !/^[a-z0-9-]+\.cloudflareaccess\.com$/.test(secrets.ACCESS_TEAM_DOMAIN)) {
|
|
throw new Error('ACCESS_TEAM_DOMAIN 必须形如 team.cloudflareaccess.com,不包含 https://。');
|
|
}
|
|
if (runtimeSecretNames.some((name) => name in ((template.vars as TomlTable | undefined) ?? {}))) {
|
|
throw new Error('运行时 Secret 不得放在 wrangler.toml 的 vars 中,请使用 Worker Secrets。');
|
|
}
|
|
|
|
return {
|
|
accountId, apiToken: env.CLOUDFLARE_API_TOKEN!.trim(), workerName,
|
|
databaseName, databaseId, customDomain, previewDomain, deployPreview, authProvider, githubClientId, githubAdmin, githubAdminId,
|
|
adminEmail, identityProviderIds, secrets,
|
|
};
|
|
}
|
|
|
|
export function createDeploymentConfig(
|
|
template: TomlTable,
|
|
settings: DeploymentSettings,
|
|
database: Database,
|
|
runtime?: { hostname: string; previewOrigin?: string; githubAdminId?: string },
|
|
): TomlTable {
|
|
return {
|
|
...template,
|
|
account_id: settings.accountId,
|
|
name: settings.workerName,
|
|
// 自定义入口启用时关闭备用公网入口,避免绕过对应的 Access 登录页。
|
|
workers_dev: !settings.customDomain,
|
|
preview_urls: false,
|
|
vars: {
|
|
...template.vars as TomlTable,
|
|
AUTH_PROVIDER: settings.authProvider,
|
|
...(runtime ? { APP_ORIGIN: `https://${runtime.hostname}` } : {}),
|
|
...(runtime?.previewOrigin ? { PREVIEW_ORIGIN: runtime.previewOrigin } : {}),
|
|
...(settings.authProvider === 'github' ? {
|
|
GH_CLIENT_ID: settings.githubClientId!,
|
|
...(runtime?.githubAdminId ? { GH_ADMIN_ID: runtime.githubAdminId } : {}),
|
|
} : {}),
|
|
},
|
|
...(settings.customDomain ? { routes: [{ pattern: settings.customDomain, custom_domain: true }] } : {}),
|
|
d1_databases: (template.d1_databases as TomlTable[]).map((binding) => binding.binding === 'DB'
|
|
? { ...binding, database_name: database.name, database_id: database.uuid }
|
|
: binding),
|
|
};
|
|
}
|
|
|
|
export function createPreviewDeploymentConfig(template: TomlTable, settings: DeploymentSettings, previewOrigin: string): TomlTable {
|
|
const previewTemplate = {
|
|
name: `${settings.workerName}-preview`,
|
|
main: 'src/preview-worker.ts',
|
|
compatibility_date: template.compatibility_date,
|
|
workers_dev: !settings.previewDomain,
|
|
preview_urls: false,
|
|
vars: { PREVIEW_ORIGIN: previewOrigin },
|
|
durable_objects: { bindings: [{ name: 'SSH_SESSIONS', class_name: 'SSHSessionDO', script_name: settings.workerName }] },
|
|
...(settings.previewDomain ? { routes: [{ pattern: settings.previewDomain, custom_domain: true }] } : {}),
|
|
};
|
|
return previewTemplate as TomlTable;
|
|
}
|