diff --git a/.github/workflows/deploy-worker.yml b/.github/workflows/deploy-worker.yml index 75eb2e1..ceb1a2c 100644 --- a/.github/workflows/deploy-worker.yml +++ b/.github/workflows/deploy-worker.yml @@ -26,8 +26,17 @@ on: # 可选: # R2_BUCKET_NAME - R2 存储桶名称 # WORKER_NAME - Worker 名称(默认 cloudflare-imgbed,手动触发时可在界面覆盖) -# WORKER_VARS - JSON 格式的业务环境变量(非必要不建议配置, -# 部署完成后请在管理面板中配置所有设置) +# WORKER_VARS - JSON 格式的业务环境变量,兼容直接值写法。 +# 对象写法支持 value 和可选 type(text/secret,默认 text)。 +# text 随 Worker 部署,secret 在部署后统一上传为 Cloudflare Secret。 +# 非必要不建议配置,业务设置请在管理面板中配置。 +# 示例(请替换密钥,迁移现有配置时保持密钥值不变): +# { +# "CUSTOM_DOMAIN": "https://img.example.com", +# "TG_CHAT_ID": { "value": "123456", "type": "text" }, +# "AI_CONFIG_SECRET": { "value": "至少32个字符的随机密钥", "type": "secret" }, +# "TG_BOT_TOKEN": { "value": "你的Bot Token", "type": "secret" } +# } jobs: deploy: @@ -65,6 +74,7 @@ jobs: run: node deploy/worker/generate-routes.js - name: Generate wrangler config + id: config if: steps.check.outputs.skip != 'true' env: WORKER_NAME: ${{ inputs.worker_name || secrets.WORKER_NAME }} @@ -82,3 +92,18 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} wranglerVersion: '4' command: deploy --config deploy/worker/wrangler.toml + + - name: Upload Worker secrets + if: steps.check.outputs.skip != 'true' && steps.config.outputs.has_secrets == 'true' + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + wranglerVersion: '4' + command: secret bulk "${{ steps.config.outputs.secrets_path }}" --config deploy/worker/wrangler.toml + + - name: Clean up Worker secrets file + if: always() && steps.config.outputs.has_secrets == 'true' + env: + WORKER_SECRETS_FILE: ${{ steps.config.outputs.secrets_path }} + run: rm -f -- "$WORKER_SECRETS_FILE" diff --git a/.gitignore b/.gitignore index 05296a6..d939d8f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ data/* node_modules /wrangler.toml +/deploy/worker/worker-secrets.json .DS_Store *.sqlite @@ -10,4 +11,4 @@ node_modules .kiro/ .vscode/ .claude/ -.agents/ \ No newline at end of file +.agents/ diff --git a/deploy/worker/generate-toml.js b/deploy/worker/generate-toml.js index 555e79f..fabc686 100644 --- a/deploy/worker/generate-toml.js +++ b/deploy/worker/generate-toml.js @@ -1,16 +1,17 @@ /** * 根据环境变量生成 deploy/worker/wrangler.toml - * 用于 GitHub Actions 部署,从 Secrets/Variables 读取配置 + * 用于 GitHub Actions 部署,从 GitHub Secrets 读取配置 * * 环境变量: * WORKER_NAME - Worker 名称(默认 cloudflare-imgbed) * D1_DATABASE_ID - D1 数据库 ID * KV_NAMESPACE_ID - KV 命名空间 ID * R2_BUCKET_NAME - R2 存储桶名称 - * WORKER_VARS - JSON 格式的业务环境变量 + * WORKER_VARS - JSON 对象,值可直接填写或使用 { value, type: 'text' | 'secret' } + * type 默认 text;secret 单独生成文件供部署后批量上传 */ -import { writeFileSync } from 'fs'; +import { appendFileSync, writeFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; @@ -20,6 +21,45 @@ const outputPath = join(__dirname, 'wrangler.toml'); const env = process.env; const name = env.WORKER_NAME || 'cloudflare-imgbed'; +// 先验证全部配置,避免错误的 secret 配置被降级成普通变量或被静默跳过。 +const textVars = Object.create(null); +const secretVars = Object.create(null); +if (env.WORKER_VARS) { + let vars; + try { + vars = JSON.parse(env.WORKER_VARS); + } catch { + // JSON 解析错误可能包含原始密钥片段,不输出解析器的错误信息。 + console.error('Error: WORKER_VARS must be a valid JSON object.'); + process.exit(1); + } + if (vars === null || typeof vars !== 'object' || Array.isArray(vars)) { + console.error('Error: WORKER_VARS must be a JSON object.'); + process.exit(1); + } + for (const [key, entry] of Object.entries(vars)) { + let type = 'text'; + let value = entry; + if (entry !== null && typeof entry === 'object') { + if (Array.isArray(entry) || !Object.hasOwn(entry, 'value')) { + console.error('Error: Each WORKER_VARS descriptor must contain value and an optional text/secret type.'); + process.exit(1); + } + type = Object.hasOwn(entry, 'type') ? entry.type : 'text'; + value = entry.value; + } + if (type !== 'text' && type !== 'secret') { + console.error('Error: WORKER_VARS type must be text or secret.'); + process.exit(1); + } + if (value !== null && !['string', 'number', 'boolean'].includes(typeof value)) { + console.error('Error: WORKER_VARS value must be a string, number, boolean, or null.'); + process.exit(1); + } + (type === 'secret' ? secretVars : textVars)[key] = String(value); + } +} + let toml = `name = "${name}" main = "index.js" compatibility_date = "2024-08-21" @@ -64,28 +104,30 @@ bucket_name = "${env.R2_BUCKET_NAME}" toml += ` [vars] -# AI_CONFIG_SECRET = "" # 至少 32 个字符的随机密钥,用于加密保存 AI 供应商 API Key +# Secret 类型的变量在部署后单独上传,不写入此配置。 `; -// 业务环境变量(从 JSON 解析) -if (env.WORKER_VARS) { - try { - const vars = JSON.parse(env.WORKER_VARS); - const entries = Object.entries(vars); - if (entries.length > 0) { - for (const [key, value] of entries) { - toml += `${key} = "${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"\n`; - } - } - } catch (e) { - console.error('Warning: WORKER_VARS is not valid JSON, skipping:', e.message); - } +// JSON 字符串转义适用于 TOML 基本字符串,另外转义 TOML 禁止的 DEL 字符。 +const tomlString = (value) => JSON.stringify(value).replace(/\u007f/g, '\\u007f'); +const configWithoutVars = toml; +for (const [key, value] of Object.entries(textVars)) { + toml += `${tomlString(key)} = ${tomlString(value)}\n`; } writeFileSync(outputPath, toml, 'utf8'); -// 打印配置(隐藏敏感值) -const safeToml = toml +const hasSecrets = Object.keys(secretVars).length > 0; +let secretsPath; +if (hasSecrets) { + secretsPath = join(env.RUNNER_TEMP || __dirname, 'worker-secrets.json'); + writeFileSync(secretsPath, JSON.stringify(secretVars), { encoding: 'utf8', mode: 0o600 }); +} +if (env.GITHUB_OUTPUT) { + appendFileSync(env.GITHUB_OUTPUT, `has_secrets=${hasSecrets}\n${hasSecrets ? `secrets_path=${secretsPath}\n` : ''}`); +} + +// 所有业务变量的值都隐藏,避免依赖变量命名规则判断是否敏感。 +const safeToml = configWithoutVars .replace(/database_id = ".*"/g, 'database_id = "***"') .replace(/(id = )".*"/g, '$1"***"') .replace(/(TOKEN.*= )".*"/gi, '$1"***"') @@ -94,3 +136,7 @@ const safeToml = toml console.log('Generated deploy/worker/wrangler.toml:'); console.log(safeToml); +for (const key of Object.keys(textVars)) { + console.log(`${JSON.stringify(key)} = "***"`); +} +console.log(`Prepared ${Object.keys(secretVars).length} secret(s) for upload after deployment.`); diff --git a/deploy/worker/wrangler.toml b/deploy/worker/wrangler.toml index f4e2ec0..e10273d 100644 --- a/deploy/worker/wrangler.toml +++ b/deploy/worker/wrangler.toml @@ -5,9 +5,11 @@ # 1. D1 数据库 - 取消注释并填入你的 database_id # 2. R2 存储桶 - 取消注释并填入你的 bucket_name # 3. KV 命名空间 - 如果使用 KV 而非 D1,取消注释并填入 id -# 4. 环境变量 - 在 [vars] 中配置 +# 4. 普通环境变量 - 在 [vars] 中配置;敏感值请使用 wrangler secret put/bulk # -# GitHub Actions 部署时会自动从 Secrets/Variables 生成此文件, +# GitHub Actions 部署时会自动从 GitHub Secrets 生成此文件, +# WORKER_VARS 支持 { "变量名": { "value": "值", "type": "text或secret" } }, +# type 默认为 text,secret 会在部署后统一上传,不写入 [vars]。 # 无需手动编辑。详见 .github/workflows/deploy-worker.yml name = "cloudflare-imgbed" @@ -27,7 +29,7 @@ not_found_handling = "single-page-application" binding = "IMAGES" [vars] -# AI_CONFIG_SECRET = "" # 至少 32 个字符的随机密钥,用于加密保存 AI 供应商 API Key +# AI_CONFIG_SECRET 请配置为 Secret,值为至少 32 个字符的随机密钥。 # TG_BOT_TOKEN = "" # TG_CHAT_ID = ""