diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 11e65e5..dee02d8 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -63,7 +63,7 @@ jobs: - name: Verify Cloudflare authentication if: ${{ env.CLOUDFLARE_API_TOKEN != '' && env.CLOUDFLARE_ACCOUNT_ID != '' }} - run: pnpm dlx wrangler@4.118.0 whoami + run: node website/scripts/check-pages-auth.mjs - name: Deploy to Cloudflare Pages if: ${{ env.CLOUDFLARE_API_TOKEN != '' && env.CLOUDFLARE_ACCOUNT_ID != '' }} diff --git a/website/DEPLOYMENT.md b/website/DEPLOYMENT.md index dd7f8ae..70d699a 100644 --- a/website/DEPLOYMENT.md +++ b/website/DEPLOYMENT.md @@ -104,7 +104,7 @@ website/dist 2. 构建 Astro 静态站。 3. 执行 `pnpm check:website`。 4. 执行 Astro 类型检查、七语构建页面检查和下载逻辑测试;PR 阶段也执行相同门禁。 -5. 使用 `wrangler whoami` 验证部署授权,再通过 Direct Upload 部署到 Pages 项目 `goagent`。 +5. 通过 Cloudflare API 读取当前账号下的 `goagent` Pages 项目以验证授权,再通过 Direct Upload 部署。此检查不依赖 `wrangler whoami` 的账号列表权限,不为部署扩大 Token 权限;授权失败仍会阻止上传。 Cloudflare Pages 项目当前是 Direct Upload 项目,不依赖 Pages 控制台的 Git 绑定。自动部署需要 GitHub 仓库 secrets: diff --git a/website/scripts/check-pages-auth.mjs b/website/scripts/check-pages-auth.mjs new file mode 100644 index 0000000..3e6370b --- /dev/null +++ b/website/scripts/check-pages-auth.mjs @@ -0,0 +1,34 @@ +import { pathToFileURL } from 'node:url' + +export async function checkPagesAuth({ accountId, token, fetcher = fetch }) { + if (!/^[a-f0-9]{32}$/i.test(accountId || '') || !token?.trim()) { + throw new Error('Cloudflare deployment credentials are missing or malformed.') + } + let response + let payload + try { + response = await fetcher(`https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects/goagent`, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + redirect: 'error', + signal: AbortSignal.timeout(15000) + }) + payload = await response.json() + } catch { + // Never surface raw network errors or API bodies which can contain account information. + throw new Error('Cloudflare Pages authorization check could not be completed.') + } + if (!response.ok || payload.success !== true || payload.result?.name !== 'goagent') { + throw new Error(`Cloudflare Pages authorization failed (HTTP ${response.status}). Check the existing project-scoped deployment token.`) + } + return true +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + await checkPagesAuth({ accountId: process.env.CLOUDFLARE_ACCOUNT_ID, token: process.env.CLOUDFLARE_API_TOKEN }) + console.log('Cloudflare Pages authorization verified for goagent.') + } catch (error) { + console.error(error.message) + process.exitCode = 1 + } +} diff --git a/website/tests/pages-auth.test.mjs b/website/tests/pages-auth.test.mjs new file mode 100644 index 0000000..d3c9f91 --- /dev/null +++ b/website/tests/pages-auth.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { checkPagesAuth } from '../scripts/check-pages-auth.mjs' + +const credentials = { accountId: 'a'.repeat(32), token: 'test-only-token' } + +test('Pages authorization uses only the configured account and existing project', async () => { + let requests = 0 + assert.equal(await checkPagesAuth({ ...credentials, fetcher: async (url, options) => { + requests++ + assert.equal(url, `https://api.cloudflare.com/client/v4/accounts/${credentials.accountId}/pages/projects/goagent`) + assert.equal(options.headers.Authorization, 'Bearer test-only-token') + assert.equal(options.redirect, 'error') + assert.ok(options.signal instanceof AbortSignal) + return { ok: true, status: 200, json: async () => ({ success: true, result: { name: 'goagent' } }) } + } }), true) + assert.equal(requests, 1) +}) + +test('Pages authorization rejects missing credentials before any request', async () => { + await assert.rejects(checkPagesAuth({ accountId: '', token: '', fetcher: () => assert.fail('must not call') }), /missing or malformed/) +}) + +test('Pages authorization rejects denied, malformed and wrong-project responses', async () => { + for (const [status, payload] of [[403, { success: false }], [200, { success: false }], [200, { success: true, result: { name: 'another-project' } }]]) { + await assert.rejects(checkPagesAuth({ ...credentials, fetcher: async () => ({ ok: status === 200, status, json: async () => payload }) }), /authorization failed/) + } +}) + +test('Pages authorization does not expose raw network errors or response bodies', async () => { + await assert.rejects(checkPagesAuth({ ...credentials, fetcher: async () => { throw new Error(credentials.token) } }), error => !error.message.includes(credentials.token)) + await assert.rejects(checkPagesAuth({ ...credentials, fetcher: async () => ({ ok: false, status: 401, json: async () => ({ success: false, errors: [credentials.token] }) }) }), error => !error.message.includes(credentials.token)) +})