From 6ca8bb4775110e3372a07b762e254de606fdf472 Mon Sep 17 00:00:00 2001 From: Thregren <37279823+Thregren@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:44:15 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=8F=91=E5=B8=83API?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=9A=84=E5=AE=89=E5=85=A8=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=EF=BC=8C=E4=BF=9D=E6=8C=81=E6=9D=83=E9=99=90=E4=B8=8E=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E4=B8=8D=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/release-guard.cjs | 20 ++++++++++++++++++-- .github/workflows/release.yml | 18 +++++++++++++++++- tests/release-guards.cjs | 24 ++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/.github/scripts/release-guard.cjs b/.github/scripts/release-guard.cjs index ec466fa..768c372 100644 --- a/.github/scripts/release-guard.cjs +++ b/.github/scripts/release-guard.cjs @@ -61,6 +61,19 @@ function buildArchives(temp, dist) { } return digests; } +// 仅输出诊断白名单;禁止输出令牌、完整响应头或原始响应正文。 +function formatApiError(method, endpoint, response, body, secrets = []) { + const clean = value => { + let text = String(value ?? ''); + for (const secret of secrets.filter(Boolean)) text = text.split(secret).join('[REDACTED]'); + return text.replace(/Bearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]').replace(/[\r\n\x00-\x1f\x7f]/g, ' '); + }; + const route = endpoint.startsWith('https://') ? new URL(endpoint).pathname : endpoint.split('?')[0]; + const details = ['x-ratelimit-remaining', 'x-ratelimit-reset', 'x-ratelimit-resource', 'retry-after'] + .map(name => `${name}=${clean(response.headers.get(name) ?? 'unknown').slice(0, 100)}`).join(' '); + const message = typeof body?.message === 'string' ? clean(body.message).slice(0, 300) : 'unavailable'; + return `GitHub API ${clean(method)} ${clean(route)}: ${response.status}; ${details}; message=${message}`; +} async function main() { insist(process.env.GITHUB_EVENT_NAME === 'workflow_run', '仅允许 workflow_run'); const repo = process.env.GITHUB_REPOSITORY; @@ -74,7 +87,10 @@ async function main() { insist(url.startsWith(`${root}/`) || url.startsWith(`https://uploads.github.com/repos/${repo}/`), '非预期 API 目标'); const response = await fetch(url, { signal: AbortSignal.timeout(30000), method, headers: { ...(endpoint.startsWith('/actions/') && method === 'GET' ? {} : { Authorization: `Bearer ${token}` }), Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', ...(body ? { 'Content-Type': binary ? 'application/zip' : 'application/json' } : {}) }, body: body ? (binary ? body : JSON.stringify(body)) : undefined }); if (optional && response.status === 404) return null; - insist(response.ok, `GitHub API ${method} ${endpoint}: ${response.status}`); + if (!response.ok) { + const errorBody = await response.json().catch(() => null); + throw new Error(formatApiError(method, endpoint, response, errorBody, [token])); + } return response.json(); } const verifyDownloads = async release => { @@ -138,5 +154,5 @@ async function main() { console.log(`发布成功:${release.html_url};SHA=${sha};两份 ZIP SHA-256 已验证`); } finally { fs.rmSync(temp, { recursive: true, force: true }); } } -module.exports = { guardRun, guardVersions, guardExisting, buildArchives, VERSION, TAG, ASSETS }; +module.exports = { guardRun, guardVersions, guardExisting, buildArchives, formatApiError, VERSION, TAG, ASSETS }; if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2360d68..4c95233 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,9 +92,25 @@ jobs: const repo = process.env.GITHUB_REPOSITORY; const assert = (ok, message) => { if (!ok) throw new Error(message); }; assert(repo === 'Thregren/InfinityTime' && event.repository.full_name === repo && event.repository.fork === false, '目标仓库不匹配'); + // 仅输出诊断白名单;禁止输出令牌、完整响应头或原始响应正文。 + function formatApiError(method, endpoint, response, body, secrets = []) { + const clean = value => { + let text = String(value ?? ''); + for (const secret of secrets.filter(Boolean)) text = text.split(secret).join('[REDACTED]'); + return text.replace(/Bearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]').replace(/[\r\n\x00-\x1f\x7f]/g, ' '); + }; + const route = endpoint.startsWith('https://') ? new URL(endpoint).pathname : endpoint.split('?')[0]; + const details = ['x-ratelimit-remaining', 'x-ratelimit-reset', 'x-ratelimit-resource', 'retry-after'] + .map(name => `${name}=${clean(response.headers.get(name) ?? 'unknown').slice(0, 100)}`).join(' '); + const message = typeof body?.message === 'string' ? clean(body.message).slice(0, 300) : 'unavailable'; + return `GitHub API ${clean(method)} ${clean(route)}: ${response.status}; ${details}; message=${message}`; + } const get = async path => { const response = await fetch(`https://api.github.com/repos/${repo}${path}`, { signal: AbortSignal.timeout(30000), headers: { ...(path.startsWith('/actions/') ? {} : { Authorization: `Bearer ${process.env.GH_TOKEN}` }), Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } }); - assert(response.ok, `GitHub API ${response.status}`); + if (!response.ok) { + const errorBody = await response.json().catch(() => null); + throw new Error(formatApiError('GET', path, response, errorBody, [process.env.GH_TOKEN])); + } return response.json(); }; const main = await get('/git/ref/heads/main'); diff --git a/tests/release-guards.cjs b/tests/release-guards.cjs index af73994..d580fd4 100644 --- a/tests/release-guards.cjs +++ b/tests/release-guards.cjs @@ -88,3 +88,27 @@ test('真实 ZIP 构建确定性、单根目录、排除 data,拒绝 symlink', assert.throws(() => build('unsafe'), /非普通文件/); } finally { process.chdir(old); fs.rmSync(root, { recursive: true, force: true }); } }); + +test('API 错误诊断仅输出白名单和截断 message,隐藏令牌与敏感头', () => { + const { formatApiError } = require('../.github/scripts/release-guard.cjs'); + const response = { status: 403, headers: new Headers({ + 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '1234567890', + 'x-ratelimit-resource': 'core', 'retry-after': '60', + authorization: 'Bearer secret-header', 'set-cookie': 'private-cookie', + }) }; + const result = formatApiError('GET', '/actions/runs/123?secret=query-secret', response, + { message: `rate limited known-token Bearer unknown-token\n${'x'.repeat(500)}`, secret: 'raw-body-secret' }, ['known-token']); + assert.match(result, /GET \/actions\/runs\/123: 403/); + for (const detail of ['x-ratelimit-remaining=0', 'x-ratelimit-reset=1234567890', 'x-ratelimit-resource=core', 'retry-after=60']) assert(result.includes(detail)); + for (const secret of ['known-token', 'unknown-token', 'secret-header', 'private-cookie', 'raw-body-secret', 'query-secret', '\n']) assert(!result.includes(secret)); + assert(result.includes('[REDACTED]')); + assert(result.split('; message=')[1].length <= 300); + assert.match(formatApiError('POST', 'https://uploads.github.com/repos/a/b/releases/1/assets?name=test', response, null), /POST \/repos\/a\/b\/releases\/1\/assets: 403/); +}); +test('checkout 前 inline API 错误格式与共享测试函数完全一致', () => { + const fs = require('node:fs'); + const { formatApiError } = require('../.github/scripts/release-guard.cjs'); + const workflow = fs.readFileSync('.github/workflows/release.yml', 'utf8'); + const lines = workflow.split('\n').map(line => line.startsWith(' ') ? line.slice(12) : line).join('\n'); + assert(lines.includes(formatApiError.toString())); +}); From a0b914e161e8c0a64c3f68a294e64f13410ea993 Mon Sep 17 00:00:00 2001 From: Thregren <37279823+Thregren@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:51:07 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=E9=9A=94=E7=A6=BB=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E6=B5=8B=E8=AF=95=E4=B8=8E=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E6=97=B6=E5=BA=8F=EF=BC=8C=E4=BF=9D=E7=95=99=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=B0=BE=E6=89=B9=E6=AC=A1=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/gallery-loading-browser.cjs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/gallery-loading-browser.cjs b/tests/gallery-loading-browser.cjs index 1aecdd7..76b9bda 100644 --- a/tests/gallery-loading-browser.cjs +++ b/tests/gallery-loading-browser.cjs @@ -166,8 +166,24 @@ const pixel = ' assert.equal(await page.locator('#stale').count(), 0); assert.equal(await page.locator('#fresh').count(), 1); assert.equal(await page.locator('.pp-load-retry').isVisible(), false); - await page.evaluate(() => InfinityWaterfall.reset({ nextUrl: '/empty' })); - assert.equal(await page.evaluate(() => InfinityWaterfall.loadMore()), true, '合法的搜索末尾空批次能正常结束'); + // 同一浏览器任务内重置并发起明确请求,避免原生滚动事件在两次 evaluate 间抢先加载。 + assert.equal(await page.evaluate(() => { + InfinityWaterfall.reset({ nextUrl: '/empty' }); + return InfinityWaterfall.loadMore(); + }), true, '合法的搜索末尾空批次能正常结束'); + assert.equal(await page.locator('.pp-load-retry').isVisible(), false); + // 自动滚动另行通过真实事件路径验证,不依赖上面的手动调用时序。 + const emptyRequests = requests.filter(p => p === '/empty').length; + await page.evaluate(() => { + InfinityWaterfall.reset({ nextUrl: '/empty' }); + window.scrollTo(0, document.documentElement.scrollHeight); + window.dispatchEvent(new Event('scroll')); + }); + await page.waitForFunction(() => { + const more = document.querySelector('#load-more'); + return more.getAttribute('data-next-url') === '' && more.getAttribute('aria-busy') === 'false'; + }); + assert.equal(requests.filter(p => p === '/empty').length, emptyRequests + 1, '自动滚动恰好加载一次合法末尾空批次'); assert.equal(await page.locator('.pp-load-retry').isVisible(), false); // AVIF 请求和解码失败均必须尝试可用 WebP,不能仅显示永久失败。 await page.evaluate(html => {