diff --git a/.github/scripts/release-guard.cjs b/.github/scripts/release-guard.cjs
new file mode 100644
index 0000000..44e8510
--- /dev/null
+++ b/.github/scripts/release-guard.cjs
@@ -0,0 +1,158 @@
+'use strict';
+// 仅用于本次已授权的 v1.15.0;失败或部分发布必须人工核查,绝不覆盖资产。
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+const crypto = require('node:crypto');
+const { execFileSync } = require('node:child_process');
+const VERSION = '1.15.0';
+const TAG = `v${VERSION}`;
+const ASSETS = ['infinitytime-theme.zip', 'infinitytime-plugin.zip'];
+function insist(ok, message) { if (!ok) throw new Error(message); }
+function guardRun(event, repository, mainSha, run, workflow) {
+ insist(event.repository?.full_name === repository && event.repository?.fork === false, '仓库不匹配或为 fork');
+ for (const r of [event.workflow_run, run]) {
+ insist(r && r.repository?.full_name === repository && r.head_repository?.full_name === repository, 'CI 来自其他仓库');
+ insist(r.event === 'push' && r.head_branch === 'main' && r.status === 'completed' && r.conclusion === 'success', 'CI 必须是 main push 成功');
+ insist(/^[a-f0-9]{40}$/.test(r.head_sha) && r.head_sha === mainSha, 'CI SHA 已过时或不合法');
+ insist(r.name === 'CI' && r.path === '.github/workflows/ci.yml', 'CI 工作流不匹配');
+ }
+ insist(run.id === event.workflow_run.id && run.workflow_id === event.workflow_run.workflow_id, 'CI run 标识不一致');
+ insist(workflow.id === run.workflow_id && workflow.path === '.github/workflows/ci.yml' && workflow.state === 'active', 'CI workflow 标识不匹配');
+ return mainSha;
+}
+function guardVersions(theme, plugin, release) {
+ insist(theme.match(/@version\s+([\d.]+)/)?.[1] === VERSION, '主题版本不是 1.15.0');
+ insist(plugin.match(/const VERSION = '([\d.]+)'/)?.[1] === VERSION, '插件版本不是 1.15.0');
+ insist(JSON.parse(release).tag_name === VERSION, '版本清单不是 1.15.0');
+}
+function guardExisting(tagSha, release, sha, digests) {
+ if (!tagSha && !release) return 'create';
+ insist(tagSha === sha, '已有 tag SHA 冲突或 release 缺 tag');
+ insist(release && release.tag_name === TAG && !release.draft && !release.prerelease, '部分发布或 release 状态冲突,需人工核查');
+ insist(release.assets?.length === ASSETS.length, '发布资产数量不完整或冲突');
+ for (const name of ASSETS) {
+ const matches = release.assets.filter(a => a.name === name);
+ insist(matches.length === 1 && matches[0].state === 'uploaded' && matches[0].size > 0, '发布资产缺失或未完成');
+ insist(matches[0].digest === `sha256:${digests[name]}`, `发布资产摘要冲突:${name}`);
+ }
+ return 'complete';
+}
+function buildArchives(temp, dist) {
+ const digests = {};
+ for (const [kind, asset] of [['themes', ASSETS[0]], ['plugins', ASSETS[1]]]) {
+ const prefix = `usr/${kind}/InfinityTime/`;
+ const files = execFileSync('git', ['ls-files', '-z', '--', prefix], { encoding: 'utf8' }).split('\0').filter(Boolean).sort();
+ const stage = path.join(temp, kind); fs.mkdirSync(stage);
+ const names = [];
+ for (const file of files) {
+ const relative = file.slice(prefix.length);
+ if (path.basename(file) === '.DS_Store' || (kind === 'plugins' && relative.startsWith('data/'))) continue;
+ insist(fs.lstatSync(file).isFile() && !/[\r\n]/.test(file), '发布包包含非普通文件或异常路径');
+ const name = `InfinityTime/${relative}`;
+ const destination = path.join(stage, name);
+ fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.copyFileSync(file, destination);
+ fs.chmodSync(destination, 0o644); fs.utimesSync(destination, 946684800, 946684800); names.push(name);
+ }
+ insist(names.length > 0, '发布包为空');
+ execFileSync('zip', ['-X', '-q', path.join(dist, asset), '-@'], { cwd: stage, input: names.join('\n') + '\n', env: { ...process.env, TZ: 'UTC' } });
+ execFileSync('unzip', ['-t', path.join(dist, asset)]);
+ digests[asset] = crypto.createHash('sha256').update(fs.readFileSync(path.join(dist, asset))).digest('hex');
+ }
+ 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;
+ const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
+ const token = process.env.GH_TOKEN;
+ insist(repo === 'Thregren/InfinityTime' && token, '缺少令牌或目标仓库错误');
+ const root = `https://api.github.com/repos/${repo}`;
+ // 公开仓库 Actions 元数据匿名读取;不增加 actions 权限,限流/拒绝即失败。
+ async function api(endpoint, { method = 'GET', body, optional = false, binary = false } = {}) {
+ const url = endpoint.startsWith('https://') ? endpoint : `${root}${endpoint}`;
+ 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;
+ 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 => {
+ for (const name of ASSETS) {
+ const asset = release.assets.find(item => item.name === name);
+ const expected = `https://github.com/${repo}/releases/download/${TAG}/${name}`;
+ insist(asset.browser_download_url === expected, '下载地址不是目标发布资产');
+ // 公共下载不携带令牌;下载后的真实字节必须匹配本次确定性构建。
+ const response = await fetch(expected, { signal: AbortSignal.timeout(30000) });
+ insist(response.ok, `发布资产无法下载:${name} ${response.status}`);
+ const digest = crypto.createHash('sha256').update(Buffer.from(await response.arrayBuffer())).digest('hex');
+ insist(digest === digests[name], `发布资产下载摘要冲突:${name}`);
+ }
+ };
+ const latestMain = async () => (await api('/git/ref/heads/main')).object.sha;
+ const sha = await latestMain();
+ const run = await api(`/actions/runs/${event.workflow_run.id}`);
+ const workflow = await api(`/actions/workflows/${run.workflow_id}`);
+ guardRun(event, repo, sha, run, workflow);
+ insist(execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() === sha, 'checkout SHA 不匹配');
+ guardVersions(fs.readFileSync('usr/themes/InfinityTime/index.php', 'utf8'), fs.readFileSync('usr/plugins/InfinityTime/Plugin.php', 'utf8'), fs.readFileSync('usr/themes/InfinityTime/releases.json', 'utf8'));
+ // 只打包 git 跟踪的普通文件;固定时间与顺序,支持幂等摘要验证。
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'infinitytime-release-'));
+ const dist = path.join(temp, 'dist'); fs.mkdirSync(dist);
+ const digests = {};
+ try {
+ Object.assign(digests, buildArchives(temp, dist));
+ const getTagSha = async () => {
+ const ref = await api(`/git/ref/tags/${TAG}`, { optional: true });
+ if (!ref) return null;
+ let object = ref.object;
+ for (let depth = 0; object.type === 'tag' && depth < 5; depth++) object = (await api(`/git/tags/${object.sha}`)).object;
+ insist(object.type === 'commit', 'tag 不是 commit'); return object.sha;
+ };
+ let release = await api(`/releases/tags/${TAG}`, { optional: true });
+ const tagSha = await getTagSha();
+ if (guardExisting(tagSha, release, sha, digests) === 'complete') {
+ await verifyDownloads(release);
+ console.log(`已完整发布且 SHA/两包摘要一致:${release.html_url}`); return;
+ }
+ const changelog = fs.readFileSync('usr/themes/InfinityTime/update.md', 'utf8');
+ const notes = changelog.split(`## ${VERSION}\n`)[1]?.split(/^## /m)[0]?.trim();
+ insist(notes, '缺少本版本 changelog');
+ // 所有打包与只读验证完成后,再验证 main,随后才执行不可覆盖的创建操作。
+ insist(await latestMain() === sha, '创建 tag 前 main 已改变');
+ await api('/git/refs', { method: 'POST', body: { ref: `refs/tags/${TAG}`, sha } });
+ release = await api('/releases', { method: 'POST', body: { tag_name: TAG, target_commitish: sha, name: `InfinityTime ${TAG}`, body: notes, draft: true, prerelease: false } });
+ for (const name of ASSETS) {
+ const upload = `${release.upload_url.split('{')[0]}?name=${encodeURIComponent(name)}`;
+ const asset = await api(upload, { method: 'POST', binary: true, body: fs.readFileSync(path.join(dist, name)) });
+ insist(asset.name === name && asset.state === 'uploaded' && asset.digest === `sha256:${digests[name]}`, '上传资产摘要校验失败');
+ }
+ insist(await latestMain() === sha, '发布前 main 已改变,保留 draft 待核查');
+ insist(await getTagSha() === sha, '发布前 tag SHA 改变');
+ release = await api(`/releases/${release.id}`);
+ guardExisting(sha, { ...release, draft: false }, sha, digests);
+ await api(`/releases/${release.id}`, { method: 'PATCH', body: { draft: false } });
+ release = await api(`/releases/${release.id}`);
+ guardExisting(await getTagSha(), release, sha, digests);
+ await verifyDownloads(release);
+ console.log(`发布成功:${release.html_url};SHA=${sha};两份 ZIP SHA-256 已验证`);
+ } finally { fs.rmSync(temp, { recursive: true, force: true }); }
+}
+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/ci.yml b/.github/workflows/ci.yml
index 46ed88b..1a0948e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,12 +11,17 @@ permissions:
jobs:
lint:
runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['7.4', '8.2', '8.4']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
- php-version: '8.2'
+ php-version: ${{ matrix.php }}
+ extensions: pdo_sqlite, gd, exif, dom
- uses: actions/setup-node@v4
with:
@@ -36,9 +41,36 @@ jobs:
php tests/test.php
php tests/sanitizer-fallback.php
php tests/repository.php
+ php tests/migration.php
+ php tests/schema-sqlite.php
+ php tests/field-sync.php
+ php tests/image-delete.php
+ php tests/media-version.php
+ php tests/media-lifetime.php
+ php tests/media-gd.php
+ php tests/media-command.php
+ php tests/media-temp.php
+ php tests/health-diagnostics.php
+ php tests/maintenance-state.php
+ for mode in scan-failure retry-image retry-fields retry-mixed retry-resync retry-repeat retry-finished retry-stale retry-empty retry-unfinished retry-other-job errors-page errors-stale resync-failure rebuild-sync-failure rebuild-success rebuild-batch rebuild-restart rebuild-resume rebuild-start-reuse rebuild-no-original rebuild-missing-original rebuild-conversion-failure stale-poll finished-poll delete-non-plugin delete-rollback lease-conflict; do
+ php tests/maintenance-panel.php "$mode"
+ done
+ php tests/admin-workflow.php
+ php tests/admin-panel.php
+ for mode in get-maintenance post-no-token post-wrong-token post-array-token post-token-unavailable post-non-album post-foreign-album post-contributor-published post-batch-foreign post-cover-foreign post-batch-published post-cover-published; do
+ php tests/admin-panel.php "$mode"
+ done
+ php tests/gallery-discovery.php
+ php tests/gallery-performance.php
php tests/upload-fallback.php
php tests/theme-output.php
node tests/admin-upload.cjs
+ node tests/maintenance-client.cjs
+ node --test tests/release-guards.cjs
+ node tests/gallery.cjs
+ node tests/data-saver.cjs
+ node tests/gallery-loading.cjs
+ node tests/gallery-navigation.cjs
- name: Version consistency
run: |
@@ -53,3 +85,122 @@ jobs:
run: |
THEME_V=$(sed -n 's/.*@version \([0-9][0-9.]*\).*/\1/p' usr/themes/InfinityTime/index.php | head -1)
grep -q "^## $THEME_V$" usr/themes/InfinityTime/update.md
+
+ browser:
+ runs-on: ubuntu-latest
+ timeout-minutes: 12
+ strategy:
+ fail-fast: false
+ matrix:
+ engine: [chromium, firefox, webkit]
+ env:
+ PLAYWRIGHT_BROWSER: ${{ matrix.engine }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ - name: Install isolated browser test dependencies
+ run: |
+ npm install --no-save --package-lock=false playwright@1.62.1
+ npx playwright install --with-deps ${{ matrix.engine }}
+ - name: 图库基础浏览器回归
+ run: node tests/gallery-browser.cjs
+ - name: 图库加载与历史导航回归
+ if: ${{ !cancelled() }}
+ run: node tests/gallery-loading-browser.cjs
+ - name: 后台上传浏览器回归
+ if: ${{ !cancelled() }}
+ run: node tests/admin-upload.cjs --browser
+ - name: 维护失败恢复浏览器回归
+ if: ${{ !cancelled() }}
+ run: node tests/maintenance-browser.cjs
+ - name: 后台分页与批量整理浏览器回归
+ if: ${{ !cancelled() }}
+ run: node tests/admin-batch-browser.cjs
+
+ typecho-integration:
+ runs-on: ubuntu-latest
+ timeout-minutes: 12
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['7.4', '8.2', '8.4']
+ typecho:
+ - version: '1.2.1'
+ ref: '206880ba714d80bfb0638aeacb49c58b8fb1b327'
+ - version: '1.3.0'
+ ref: 'e9f23304bf20c0c355ef1df9634d42148cbc53b5'
+ database: [sqlite, mysql, pgsql]
+ services:
+ mysql:
+ image: mysql:8.0
+ env:
+ MYSQL_DATABASE: infinitytime_test
+ MYSQL_ROOT_PASSWORD: integration-test-only
+ ports: ['3306:3306']
+ options: >-
+ --health-cmd="mysqladmin ping -h localhost -pintegration-test-only"
+ --health-interval=10s --health-timeout=5s --health-retries=10
+ postgres:
+ image: postgres:17
+ env:
+ POSTGRES_DB: infinitytime_test
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: integration-test-only
+ ports: ['5432:5432']
+ options: >-
+ --health-cmd="pg_isready -U postgres -d infinitytime_test"
+ --health-interval=10s --health-timeout=5s --health-retries=10
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/checkout@v4
+ with:
+ repository: typecho/typecho
+ ref: ${{ matrix.typecho.ref }}
+ path: .typecho-test
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: pdo_sqlite, sqlite3, pdo_mysql, mysqli, pdo_pgsql, pgsql
+ coverage: none
+ - name: PHP 代码与集成测试语法
+ run: find usr tests -name '*.php' -print0 | xargs -0 -n1 php -l
+ - name: 真实 Typecho 数据库集成
+ env:
+ TYPECHO_ROOT: ${{ github.workspace }}/.typecho-test
+ IT_DB_HOST: 127.0.0.1
+ IT_DB_NAME: infinitytime_test
+ IT_DB_USER: ${{ matrix.database == 'mysql' && 'root' || 'postgres' }}
+ IT_DB_PASSWORD: integration-test-only
+ run: |
+ case '${{ matrix.database }}' in
+ sqlite) adapters='Pdo_SQLite SQLite' ;;
+ mysql) adapters='Pdo_Mysql Mysqli' ;;
+ pgsql) adapters='Pdo_Pgsql Pgsql' ;;
+ esac
+ for adapter in $adapters; do
+ php tests/typecho-integration.php "$adapter"
+ done
+
+ process-portability:
+ runs-on: windows-latest
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.2', '8.4']
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: gd, fileinfo
+ coverage: none
+ - name: Windows 外部进程与临时资源回归
+ shell: pwsh
+ run: |
+ php tests/media-command.php
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ php tests/media-temp.php
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3d1b3d4..d5b0d7c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,12 +4,20 @@ on:
push:
tags:
- 'v*'
+ workflow_run:
+ workflows: [CI]
+ types: [completed]
+ branches: [main]
permissions:
contents: write
jobs:
release:
+ if: github.event_name == 'push'
+ concurrency:
+ group: release-${{ github.ref_name }}
+ cancel-in-progress: false
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -55,3 +63,85 @@ jobs:
dist/infinitytime-theme.zip
dist/infinitytime-plugin.zip
body_path: dist/RELEASE_NOTES.md
+
+ # 一次性授权范围:只允许目标仓库 main 的完整 CI 发布 1.15.0。
+ release-1-15-0:
+ if: >-
+ github.event_name == 'workflow_run' &&
+ github.repository == 'Thregren/InfinityTime' &&
+ github.event.repository.fork == false &&
+ github.event.workflow_run.event == 'push' &&
+ github.event.workflow_run.head_branch == 'main' &&
+ github.event.workflow_run.conclusion == 'success' &&
+ github.event.workflow_run.head_repository.full_name == github.repository
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ concurrency:
+ group: release-v1.15.0
+ cancel-in-progress: false
+ steps:
+ - name: 在 checkout 前验证可信 main 和 CI 身份
+ id: trusted
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ node <<'NODE'
+ const fs = require('node:fs');
+ (async () => {
+ const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
+ 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' } });
+ 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');
+ const sha = main.object.sha;
+ assert(main.object.type === 'commit' && /^[a-f0-9]{40}$/.test(sha), 'main SHA 无效');
+ const id = event.workflow_run.id;
+ assert(Number.isSafeInteger(id) && id > 0, 'CI run ID 无效');
+ const run = await get(`/actions/runs/${id}`);
+ for (const item of [event.workflow_run, run]) {
+ assert(item.repository.full_name === repo && item.head_repository.full_name === repo, 'CI 仓库不匹配');
+ assert(item.event === 'push' && item.head_branch === 'main' && item.status === 'completed' && item.conclusion === 'success', 'CI 不满足发布条件');
+ assert(item.head_sha === sha, 'CI 不是当前 main');
+ assert(item.name === 'CI' && item.path === '.github/workflows/ci.yml', 'CI path 不匹配');
+ }
+ assert(run.id === id && run.workflow_id === event.workflow_run.workflow_id, 'CI run 不匹配');
+ const workflow = await get(`/actions/workflows/${run.workflow_id}`);
+ assert(workflow.id === run.workflow_id && workflow.path === '.github/workflows/ci.yml' && workflow.state === 'active', 'CI workflow 不匹配');
+ fs.appendFileSync(process.env.GITHUB_OUTPUT, `sha=${sha}\n`);
+ })().catch(error => { console.error(error.message); process.exitCode = 1; });
+ NODE
+
+ - name: Checkout 已验证的精确 main SHA
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ steps.trusted.outputs.sha }}
+ persist-credentials: false
+
+ - name: 离线发布 guard 回归
+ run: node --test tests/release-guards.cjs
+
+ - name: 验证、打包并发布唯一授权版本
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: node .github/scripts/release-guard.cjs
diff --git a/README.md b/README.md
index e25b529..58cda56 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# InfinityTime · 无限时光
-一款面向 [Typecho](https://typecho.org) 的**纯图片分享方案**。上传一张图,自动得到 **WebP 全图 + 缩略图**并读取 **EXIF**;前台是**无限瀑布流图片墙 + 灯箱**,灯箱侧栏展示拍摄参数;**360° 全景图在灯箱内直接可拖拽/缩放浏览**;后台可视化配置一切,且**所有操作均已 AJAX,不整页刷新**。
+一款面向 [Typecho](https://typecho.org) 的**纯图片分享方案**。上传一张图,自动得到 **WebP 全图 + 缩略图**并读取 **EXIF**;前台是**无限瀑布流图片墙 + 灯箱**,灯箱侧栏展示拍摄参数;**360° 全景图在灯箱内直接可拖拽/缩放浏览**;后台可视化配置一切,后台常用编辑与上传无需整页刷新,前台搜索和分页保留原生浏览能力。
> 主题 `InfinityTime` 负责展示,插件 `InfinityTime` 负责处理。**只需安装这两部分,无需改动任何 Typecho 原生文件。**
@@ -8,6 +8,40 @@
## 功能特性
+### v1.15.0 维护恢复与省流浏览
+
+- 维护失败明细按图片或相册保留,支持每页50项查看与仅重试失败项。编码失败和字段同步失败分开恢复;旧任务未保存的明细不会被虚构。
+- 照片加载可跟随系统省流建议或手动切换。省流关闭自动翻页与邻图预取,仍可主动看图和加载更多;隐藏页面暂停额外加载并释放预取引用。
+- 重建后公开派生图带新的缓存标识,稳定照片ID与数据库物理路径保持不变。CDN必须将查询参数纳入缓存键;已打开页面仍需刷新获取新URL。
+- 管理员可查看只读健康检查和隔离恢复演练步骤。它不会自动修改目录、表引擎、服务器保护规则或生产数据;发现编码器不代表该工具已通过真实图片解码验证。
+
+### v1.14.2 异常恢复与编辑一致性加固
+
+分页自动滚动与主动调用共享同一请求结果;无 AbortController 或网络实现忽略 abort 时,超时/取消也会结束等待并隔离迟到响应。后台编辑字段、图集标题、图片元数据和排序使用同一主库保存点协议,字段失败不会留下半套新值,外层事务保持由调用方决定提交。本版在完整 CI 与发布校验通过后提供主题和插件更新包;不会自动部署线上站点。
+
+外部转换用 PHP 7.4+ 的 argv 数组直接启动实际工具,避免只终止中间 shell 而让工具继续写入;日志输出被直接丢弃至系统空设备。Windows 禁止 .bat/.cmd 包装,须配置直接可执行的工具。该管理仅覆盖直接子进程,工具自行创建的后代仍须由主机/工具管理。临时 JPEG 使用随机目录(POSIX 0700;Windows 遵循主机临时目录 ACL),统一清理所有多帧结果;清理被权限或文件系统阻止时记录警告,不掩盖原转换错误。
+
+### v1.14.1 性能与可靠性加固
+
+维护任务身份与原子检查点、聚合字段事务回滚、Mysqli 主库一致性修复、低内存孤儿引用扫描,并扩充官方 Typecho、多 PHP 和多数据库集成验证。1.14.0 的相册工作流与浏览功能全部保留。
+
+### v1.14.0 相册工作流与浏览增强
+
+- **先存草稿,再明确发布**:创建后保持草稿,逐张上传并显示「待上传 / 上传中 / 转换中 / 完成 / 失败」。只有点击发布才会公开;贡献者可管理自己的草稿,公开发布与已发布相册修改需要编辑者或管理员权限。
+- **失败可单独重试**:同一图片操作标识与文件内容/标题描述绑定,服务器已保存的成功项不会因响应丢失而重复入库。上传与维护继续串行使用同一媒体锁,忙时保留队列并提示重试。
+- **刷新后恢复**:已保存的相册与照片保留在服务器,浏览器可恢复上传清单;尚未成功的文件需重新选择。没有跨刷新保存文件字节,也不是分片续传。
+- **已有相册追加**:支持向仓库管理的相册继续添加照片,不覆盖成功项。仅保存在历史文章字段、没有图片仓库记录的旧相册暂不支持直接追加,系统会在写入前阻止;旧照片仍可正常浏览。安全兼容路径是保留旧相册另建新相册,后续如需原地追加,应先备份并单独验证旧字段到仓库的导入,不能靠「重建字段」冒充导入。
+- **后台分页与按需详情**:每页 20 个相册,可按标题搜索并同时看到草稿与已发布状态。列表只取本页摘要和汇总数量,展开后才分页读取照片详情。
+- **严格写入校验**:所有后台变更包括维护任务均使用 POST 和有效会话令牌;管理员也必须操作真实的插件相册。持锁后的权限、去重、字段同步与文件引用检查读取主库,避免读写分离延迟。
+- **公开搜索与筛选**:关键词匹配相册或照片标题/描述;全景、标签、分类、月份条件同时生效。自定义标签与 Typecho 原生标签合并展示、统一筛选,不改写已有原生分类关系。
+- **拍摄月份与稳定链接**:优先使用有效 EXIF 拍摄日历月份;缺失时按照片入库时间 UTC 月份回退,旧字段照片使用相册时间。每次已加载结果按月份分组,可按月份筛选。照片链接使用稳定仓库 ID;旧字段照片使用相册 ID 与图片地址的摘要,修改旧图片地址会产生新链接。
+- **有界浏览与无脚本回退**:相册按 ID 从新到旧扫描,每次最多 16 个照片块、每块最多 60 张,返回最多 12 个匹配块。稀疏筛选达到扫描上限时仍保留继续搜索链接,不误报搜索结束。无需 JavaScript 也可搜索、翻页、打开每张照片。
+- **加载失败可恢复**:图片、缩略图和下一页有显式重试按钮;不支持 WebGL 时保留平面全景照片。邻图使用匹配屏幕的响应式资源,全景仍用完整分辨率;主题色和直方图尽量复用已解码图片或预览。
+
+公开 JSON 地址为站点首页加 `?infinitytime_api=gallery`,使用与页面相同的 `q`、`kind`、`month`、`tag`、`category`、`album`、`photo` 和 `cursor` 参数。只返回已经发布、没有密码且发布时间已到的照片;输出字段白名单不包含原图地址、GPS 坐标、上传去重键或内部错误。原图文件保护仍依赖《部署与可移植性说明》中的 Web 服务器规则,请继续保留这些规则。
+
+数据库升级仅增加可空列与唯一索引,不改写或删除原照片记录;部署前仍建议备份数据库和上传目录。多节点必须使用支持可靠 `flock` 的共享上传卷。发布包需由站点管理员按常规流程更新;本次 GitHub 发版不会自动部署线上站点。
+
### v1.13.4 更新
- 修复嵌套 HTML 白名单及实体编码链接的过滤漏洞;历史关于内容和联系方式在输出时再次校验。
@@ -35,7 +69,7 @@
### 首页与展示(前台体验)
-- **无限瀑布流**:JS 把图片卡按响应式列数分配到弹性列、首行顶对齐;**滚动到底自动加载下一页**(`index.php/page/N`),无分页页码。
+- **无限瀑布流**:JS 把图片卡按响应式列数分配到弹性列、首行顶对齐;**滚动到底自动加载下一页**,使用保留筛选条件的游标,同时提供原生下一页链接。
- **顺序稳定**:缩略图按真实宽高 `aspect-ratio` 预占位,图片懒加载后不再导致列重排、顺序跳变;无限加载的新卡片同样生效。
- **懒加载**:原生 `loading="lazy"` + 滚动检测按需加载。
- **统一边距与圆角**:14px 间距、10px 圆角。
@@ -57,24 +91,68 @@
- **联系方式 / 联系我**:可视化选图标、增删/启停(AJAX)。
- **WebP 转换设置**:按 **通用保留原图 / 缩略图 / 普通图 / 全景图** 分组;宽度、**质量滑块**;转换工具状态;「恢复默认最佳设置」(AJAX)。
- **维护**:重建缩略图/全图、清理孤儿文件、重建尺寸字段、**清理非插件文章**;进度实时刷新,刷新页面后可继续未完成任务,并带并发锁。
-- **安全加固**:写操作带 CSRF token(含 GET 维护任务)、上传单文件大小/像素前置校验、「关于」HTML 危险标签/事件/协议清洗。
+- **安全加固**:所有写操作与维护任务仅接受带 CSRF token 的 POST、上传单文件大小/像素前置校验、「关于」HTML 危险标签/事件/协议清洗。
## 技术栈
### 开发验证
-在仓库根目录运行(测试环境使用 PHP 8.2+、DOM 扩展和 Node.js 20+):
+在仓库根目录运行(测试环境使用 PHP 7.4+、DOM / GD / EXIF / PDO SQLite 扩展和 Node.js 20+):
```sh
php tests/test.php
php tests/sanitizer-fallback.php
php tests/repository.php
+php tests/migration.php
+php tests/schema-sqlite.php
+php tests/field-sync.php
+php tests/image-delete.php
+php tests/media-lifetime.php
+php tests/media-gd.php
+php tests/media-command.php
+php tests/media-temp.php
+php tests/maintenance-state.php
+php tests/maintenance-panel.php
+php tests/admin-workflow.php
+php tests/admin-panel.php
+php tests/gallery-discovery.php
php tests/upload-fallback.php
php tests/theme-output.php
node tests/admin-upload.cjs
+node tests/maintenance-client.cjs
+node tests/gallery.cjs
+node tests/gallery-loading.cjs
+node tests/gallery-navigation.cjs
+```
+
+浏览器回归(Playwright 1.62.1 + Chromium / Firefox / WebKit;页面与资源请求全部由测试拦截,不访问线上站点):
+
+```sh
+npm install --no-save --package-lock=false playwright@1.62.1
+npx playwright install chromium firefox webkit
+node tests/gallery-browser.cjs
+node tests/gallery-loading-browser.cjs
+node tests/admin-upload.cjs --browser
+# 将 PLAYWRIGHT_BROWSER 设为 firefox 或 webkit 可运行同一组其他引擎回归
```
-CI 同时检查 PHP / JS 语法、版本一致性和更新记录。仓库测试使用隔离临时文件及 DB/编码器替身;后台脚本测试模拟 DOM,覆盖预览、拖拽、移除和无 `DataTransfer` 时的提交顺序。这些检查不能替代真实 Typecho 后台及图片编码器的端到端验证。
+CI 同时检查 PHP / JS 语法、版本一致性和更新记录,并在 PHP 7.4 / 8.2 / 8.4、Typecho 1.2.1 / 1.3.0、SQLite / MySQL 8 / PostgreSQL 17 矩阵中使用官方数据库层与 PDO/原生适配器执行集成测试。测试 Typecho 源码固定到官方提交,覆盖草稿、权限、重试、字段回滚、外层事务及旧库升级。数据库以外的 options/编码器边界仍使用隔离替身,不能冒充真实网站、生产主从延迟或完整图片编码器的端到端验证。
+
+真实数据库测试仅在一次性测试库运行,例如:`TYPECHO_ROOT=/path/to/typecho php tests/typecho-integration.php Pdo_SQLite`。其他适配器为 `SQLite`、`Pdo_Mysql`、`Mysqli`、`Pdo_Pgsql`、`Pgsql`,连接参数使用 `IT_DB_HOST`、`IT_DB_PORT`、`IT_DB_NAME`、`IT_DB_USER`、`IT_DB_PASSWORD` 环境变量。测试会创建随机前缀的表并在结束时清理。
+
+字段聚合同步使用主库保存点:失败保留完整旧字段,不提交或回滚调用方的其他事务。MySQL 的 `fields` 表必须使用 InnoDB(单图删除还检查图片表,整图集删除同时检查文章表);非事务引擎会在写入前明确拒绝,需要管理员先备份并自行迁移,本插件不会自动改动站点表引擎。
+
+维护检查点有独立任务标识。未完成的同类任务继续原清单与进度,完成后再次开始会重新收集候选;迟到轮询不能覆盖新任务。磁盘写入失败会明确报错,重建字段失败计入失败数,可运行「重建尺寸字段」重试。首批已完成但响应丢失后,手动再次开始属于新任务,可能重复处理,但不会复用过期快照。
+
+单图删除将图片行和聚合字段放在同一事务中;整图集删除将图片行、图集字段及文章记录放在同一事务中,确认自行提交后才清理无引用实体;调用方外层事务尚未结束时保留文件,由提交后的安全孤儿清理回收。数据库与文件系统没有跨系统原子提交:提交后崩溃、连接结果未知或清理失败可能留下孤儿,优先避免丢失可恢复文件。历史 URL 的查询参数、编码和点目录会保守纳入引用保护。
+
+上传中只更新当前照片卡片,服务器确认成功且返回图片标识后才释放本地文件资源,失败项保留重试。200 张隔离 DOM/XHR 样本的创建节点数从 1,124,203 降至 8,403,全部成功后的 ObjectURL 从 200 降至 0;这仅是测试样本的 DOM/资源数据,完整队列统计与持久化仍有遍历,不代表全部算法线性或线上耗时承诺。
+
+主图与缩略图使用同目录暂存文件,全部编码并校验后再依次替换;编码失败不会提前覆盖旧图。两个文件的 rename 不能形成跨文件原子事务,发布中途磁盘失败仍可能保留不同代但各自完整的输出,可重建恢复。WebP 编码单边上限为 16383px,超限会明确拒绝,不暗中改变用户的全景尺寸设置。AVIF 读取失败先尝试已有 WebP,仍失败才显示重试。
+
+孤儿引用扫描仅读六个路径列,不缓存旧引用;每批删除前仍在媒体锁内核验。可运行 `php tests/orphan-performance.php` 在隔离 SQLite 中对比返回量和查询中位数,测试不使用机器相关的耗时通过阈值。
+
+维护与上传现在通过同一上传根目录内的文件锁互斥,媒体处理串行执行,逐图队列在忙时保留状态并提示重试,不会并发启动多张转换。孤儿清理只处理至少一小时前的文件,恢复任务时重新核验数据库引用与文件身份;旧版任务快照会安全跳过,重新启动清理即可。文件锁需要底层文件系统支持可靠的 `flock`(多节点部署需共用支持锁的上传卷)。
### 后端
- PHP ≥ 7.4(推荐 8.x)+ Typecho 1.3;`php-gd`(WebP/缩放)、`php-exif`、可选 `php-imagick` / ImageMagick / `heif-convert`。
@@ -168,3 +246,8 @@ git tag v1.7.0 && git push origin v1.7.0
- 本项目未引入任何新的第三方运行时库;灯箱 / EXIF / 瀑布流的动效基于 jQuery 内置缓动与 CSS 缓动实现,不依赖 GSAP、Three.js 等额外库。
> 若你基于本主题二次分发,请同时保留上述第三方项目的许可声明。
+
+
+### 本次固定版本发布
+
+1.15.0 使用额外的受控发布入口:只有本仓库 main 的完整 CI 成功且 SHA 仍为当前主线,才可创建 v1.15.0 与两份 ZIP。该入口固定版本,不能自动发布未来版本;已有完整发布只核验,不覆盖。匿名 CI 元数据查询、主线变化、版本不符或部分发布均会停止;任务失败后需核对 tag、draft 及资产状态。原手动 tag 发布流程保持。
diff --git "a/docs/\345\205\254\345\274\200\345\233\276\345\272\223\346\237\245\350\257\242.md" "b/docs/\345\205\254\345\274\200\345\233\276\345\272\223\346\237\245\350\257\242.md"
new file mode 100644
index 0000000..3230a55
--- /dev/null
+++ "b/docs/\345\205\254\345\274\200\345\233\276\345\272\223\346\237\245\350\257\242.md"
@@ -0,0 +1,48 @@
+# 公开图库查询与链接
+
+## 筛选语义
+
+首页表单使用 GET,禁用 JavaScript 后仍能搜索、筛选、浏览照片和继续分页。
+
+- q:相册或照片的标题、描述包含完整关键词即可命中;不区分大小写,不做分词。相册标题或描述命中时,其照片仍须满足下列筛选条件。
+- kind:all(全部)或 pano(全景)。有尺寸时按宽高比 1.98–2.02 判断;旧照片没有尺寸时沿用原全景标记。
+- month:YYYY-MM。使用该张照片有效 EXIF 拍摄日期的年月;没有有效拍摄日期时,仓库照片使用上传时间(UTC),字段历史照片使用相册创建时间(UTC)。日期全缺失时归为“日期未知”。同一图集跨月份时会分到多个月份组。
+- tag:完整标签名称精确匹配。合并插件 fields.tags(英文逗号、中文逗号或换行分隔)和 Typecho 原生标签并去重。原生标签归档也使用此模型。
+- category:Typecho 原生分类的 slug。原生分类归档保留该约束,后续筛选及分页不会丢失分类。
+- 多个条件取交集;全景、月份及照片关键词必须命中同一照片,不能由相册中不同照片分别满足。
+
+月份导航展示当前页的月份;月份输入框可以直接查询全库任意月份。图集按 CID 降序遍历,每页中的照片再按实际月份降序分组;不声称这是跨所有分页的全局拍摄时间排序。
+
+## 公开接口及隐私
+
+站点根地址加 ?infinitytime_api=gallery 返回 JSON,与首页共享同一读取和筛选逻辑。支持上述筛选参数以及 album、photo、cursor、limit。站点部署在子目录时沿用实际 siteUrl,不依赖特定伪静态规则。
+
+仅返回已经发布、到达发布时间、没有密码且类型为 post 的相册。不使用当前登录人的额外读取权限。所有照片通过显式公开字段白名单输出;不查询原图、GPS、哈希列,不将原始数据库行直接返回。EXIF 只保留相机、镜头、ISO、光圈、快门、焦距、闪光和拍摄日期;历史记录的 GPS、文件名、厂商备注等同样会被移除。媒体地址拒绝脚本/data URL、协议相对地址、original 目录与路径穿越。地址说明是用户填写并主动展示的公开文字,不从坐标推算。
+
+响应含 albums、next_cursor、next_url、scan_limited。API 的 next_url 会保留接口参数和全部筛选条件。查询故障返回 503 和不含数据库错误详情的失败状态,不将错误当作空图库。
+
+## 有界分页
+
+每次只读取最多 17 个候选相册,最多扫描 16 个照片分片;每片最多返回 60 张照片,数据库多读 1 行判断是否还有后续。limit 控制有匹配照片的图集分片数,范围 1–12,默认 12。没有 COUNT 全表统计,也没有先加载整个照片仓库。
+
+cursor 是“相册 CID.照片偏移”。大相册会先继续同一相册,再进入较早相册。达到扫描额度但尚有后续时,即使当前批次没有匹配,也返回有效游标,并显示“继续搜索更早的照片”。因此空批次不等于搜索结束。无 JavaScript 时点击下一页继续;JavaScript 加载失败可重试。
+
+历史字段结构本身是相册级 JSON/多行文本,读取它仍需要解码该相册原有字段,但输出同样按 60 张切片,不会默默截断后续照片。查询不做表结构变更。分页反映当前排序,不建立数据库快照;浏览期间编辑、删除或重新排序照片后,建议从第一页重新浏览。稳定照片链接不受排序影响。
+
+## 稳定链接与兼容
+
+- 相册:?album=123
+- 仓库照片:?album=123&photo=p-456,其中 456 为永久图片记录 ID。
+- 仅有字段的历史照片:l- 前缀,加 SHA-256(相册 CID + 换行 + 公开照片 URL)的前 20 位十六进制。排序变化不会改变 ID;更换 URL 会改变该历史 ID。同一相册重复的相同 URL 视为同一历史照片。
+
+直接照片链接可定位首片以外的照片。已删除、未公开或不符合当前筛选条件的照片不会回退展示其他私有内容。原生文章链接会跳转到保留相册 CID 的首页入口。
+
+打开灯箱新增一条历史记录,切换照片更新该记录;后退关闭并恢复原筛选和滚动位置,前进重新打开照片。直接收到的照片链接关闭时只清除选中照片参数,不返回站外。相册链接可用于继续浏览完整相册。
+
+旧图集没有同步 photo_ids 时,也直接使用仓库记录 ID。响应式变体优先按 photo_ids 关联;旧图集按精确图片 URL 关联,避免排序变化后错位,不要求管理员先重建图片。
+
+## 验证
+
+- php tests/gallery-discovery.php:实际 PHP 数据层、分页、隐私白名单、仓库和历史照片、完整 index.php 渲染、无 JavaScript 链接、API 子进程输出。
+- node tests/gallery-navigation.cjs:深链接、后退/前进、关闭、筛选和滚动恢复,以及无效链接状态提示。
+- 既有浏览器回归及 gallery-loading-browser.cjs 继续覆盖灯箱、重试和加载取消流程。
diff --git a/tests/admin-batch-browser.cjs b/tests/admin-batch-browser.cjs
new file mode 100644
index 0000000..ef8c473
--- /dev/null
+++ b/tests/admin-batch-browser.cjs
@@ -0,0 +1,83 @@
+'use strict';
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const engine = process.env.PLAYWRIGHT_BROWSER || 'chromium';
+const origin = 'https://admin.test';
+const plugin = path.resolve(__dirname, '../usr/plugins/InfinityTime');
+function details(page = 1, all = false) {
+ const ids = all ? [1, 2, 3, 4] : (page === 1 ? [1, 2] : [3, 4]);
+ return `
+ ${ids.map(id => ``).join('')}`;
+}
+function html() { return ``; }
+function multipart(body) {
+ const result = {};
+ const pattern = /Content-Disposition: form-data; name="([^"]+)"\r\n\r\n([\s\S]*?)(?=\r\n--)/g;
+ for (const match of body.matchAll(pattern)) (result[match[1]] ||= []).push(match[2]);
+ return result;
+}
+(async () => {
+ const browser = await require('playwright')[engine].launch({ headless: true });
+ try {
+ for (const width of [1280, 390]) {
+ const context = await browser.newContext({ viewport: { width, height: 844 } });
+ const page = await context.newPage(), errors = [], writes = [], reads = [];
+ let failRead = false, failWrite = true, releaseWrite;
+ page.on('pageerror', error => errors.push(error.message));
+ await page.route('**/*', async route => {
+ const url = new URL(route.request().url());
+ if (url.pathname === '/admin.js') return route.fulfill({ path: path.join(plugin, 'assets/admin.js') });
+ if (url.pathname === '/admin.css') return route.fulfill({ path: path.join(plugin, 'assets/admin.css') });
+ if (url.pathname !== '/endpoint') return route.fulfill({ contentType: 'text/html', body: html() });
+ if (route.request().method() === 'POST') {
+ const fields = multipart(route.request().postData() || ''); writes.push(fields);
+ if (writes.length === 1) await new Promise(resolve => { releaseWrite = resolve; });
+ return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: !failWrite, msg: failWrite ? '模拟保存失败' : '保存成功' }) });
+ }
+ reads.push(Object.fromEntries(url.searchParams));
+ if (failRead) return route.fulfill({ status: 503, body: 'fail' });
+ return route.fulfill({ contentType: 'text/html', body: details(Number(url.searchParams.get('image_page') || 1), url.searchParams.get('image_all') === '1') });
+ });
+ await page.goto(origin);
+ for (const input of await page.locator('[name="rowIds[]"]').all()) await input.check();
+ await page.check('[name="apply_desc"]'); await page.fill('textarea[name="desc"]', '批量说明');
+ await page.evaluate(() => { const form = document.querySelector('#pp-batch-7'); form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); });
+ await page.waitForFunction(() => document.querySelector('#pp-batch-7 button').disabled);
+ const deadline = Date.now() + 10000;
+ while (!releaseWrite && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10));
+ assert.ok(releaseWrite, '保存请求必须启动');
+ assert.equal(writes.length, 1, '重复提交只发送一次');
+ assert.deepEqual(writes[0]['rowIds[]'], ['1', '2'], '外部关联复选框全部进入FormData');
+ assert.deepEqual(writes[0]._, ['fixture-token']);
+ assert.equal(writes[0].apply_address, undefined, '未勾选的位置字段不会被应用');
+ releaseWrite();
+ await page.waitForSelector('.notice.error');
+ assert.equal(await page.locator('[name="rowIds[]"]:checked').count(), 2, '保存失败保留选择');
+ failWrite = false;
+ await Promise.all([page.waitForRequest(request => request.method() === 'GET' && new URL(request.url()).pathname === '/endpoint'), page.click('#pp-batch-7 button')]);
+ await page.waitForFunction(() => document.querySelector('.pp-thumbs').getAttribute('aria-busy') === 'false');
+ await page.waitForSelector('.notice.success');
+ assert.equal(writes.length, 2);
+ failRead = true;
+ await page.click('[data-image-page="2"]'); await page.waitForSelector('.pp-detail-retry');
+ assert.equal(await page.locator('.pp-img[data-id="1"]').count(), 1, '下一页失败保留当前内容');
+ failRead = false; await page.click('.pp-detail-retry');
+ await page.waitForSelector('.pp-img[data-id="3"]');
+ assert.equal(await page.locator('.pp-img[data-id="1"]').count(), 0);
+ await page.click('[data-image-missing="desc"]');
+ await page.waitForSelector('.pp-img[data-id="1"]');
+ assert.equal(reads.at(-1).image_missing, 'desc');
+ await Promise.all([page.waitForRequest(request => request.method() === 'GET' && new URL(request.url()).pathname === '/endpoint'), page.locator('.pp-img').first().locator('button').click()]);
+ await page.waitForSelector('.notice.success');
+ await page.waitForFunction(() => document.querySelector('.pp-thumbs').getAttribute('aria-busy') === 'false');
+ assert.deepEqual(writes.at(-1).action, ['set_album_cover']);
+ assert.deepEqual(writes.at(-1).rowId, ['1']);
+ assert.equal(reads.at(-1).image_missing, 'desc', '改封面后保留当前照片筛选');
+ assert.deepEqual(errors, []);
+ await context.close();
+ console.log(`批量整理与照片分页浏览器回归通过:${engine} ${width}px`);
+ }
+ } finally { await browser.close(); }
+})().catch(error => { console.error(error); process.exitCode = 1; });
diff --git a/tests/admin-panel.php b/tests/admin-panel.php
new file mode 100644
index 0000000..5341134
--- /dev/null
+++ b/tests/admin-panel.php
@@ -0,0 +1,113 @@
+adapter = 'Pdo_SQLite';
+ \Utils\Helper::$options = (object)[
+ 'siteUrl' => 'https://example.test/', 'theme' => 'InfinityTime',
+ 'request' => new class { public function get($key, $default = null) { return $default; } },
+ ];
+ define('__TYPECHO_ADMIN__', true);
+ define('__TYPECHO_ROOT_DIR__', dirname(__DIR__));
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+ $_GET = []; $_POST = [];
+ $mode = $argv[1] ?? 'render';
+ if ($mode !== 'render') {
+ $_SERVER['HTTP_HOST'] = 'example.test';
+ $_SERVER['HTTP_REFERER'] = 'https://example.test/admin/';
+ $expected = 403;
+ if ($mode === 'get-maintenance') {
+ $_GET = ['ajax' => '1', 'job' => 'cleanup', '_' => 'fixture-panel-token'];
+ $expected = 405;
+ } else {
+ $_SERVER['REQUEST_METHOD'] = 'POST';
+ $_POST = ['action' => 'update_album', 'ajax' => '1', 'cid' => (int)$draft['cid'], 'title' => '不应被写入', '_' => 'fixture-panel-token'];
+ if ($mode === 'post-no-token') { unset($_POST['_']); }
+ elseif ($mode === 'post-wrong-token') { $_POST['_'] = 'wrong-token'; }
+ elseif ($mode === 'post-array-token') { $_POST['_'] = ['fixture-panel-token']; }
+ elseif ($mode === 'post-token-unavailable') { \Widget\Security::$token = ''; }
+ elseif ($mode === 'post-non-album') { $_POST['cid'] = 500; }
+ elseif ($mode === 'post-foreign-album') { \Widget\User::$administrator = false; $_POST['cid'] = (int)$other['cid']; }
+ elseif ($mode === 'post-contributor-published') { \Widget\User::$administrator = false; }
+ elseif (in_array($mode, ['post-batch-foreign', 'post-cover-foreign', 'post-batch-published', 'post-cover-published'], true)) {
+ \Widget\User::$administrator = false;
+ $_POST['action'] = strpos($mode, 'batch') !== false ? 'batch_image_meta' : 'set_album_cover';
+ $_POST['rowId'] = 1; $_POST['rowIds'] = [1]; $_POST['apply_desc'] = '1'; $_POST['desc'] = '不可写';
+ if (strpos($mode, 'foreign') !== false) { $_POST['cid'] = (int)$other['cid']; }
+ }
+ else { throw new RuntimeException('未知请求测试'); }
+ }
+ $before = \Typecho\Db::get()->fetchAll('SELECT * FROM admin_contents ORDER BY cid');
+ ob_start();
+ register_shutdown_function(static function () use ($mode, $expected, $before) {
+ $output = ob_get_clean();
+ $result = json_decode($output, true);
+ $after = \Typecho\Db::get()->fetchAll('SELECT * FROM admin_contents ORDER BY cid');
+ if (http_response_code() !== $expected || !is_array($result) || ($result['ok'] ?? true) !== false || $before !== $after) {
+ fwrite(STDERR, '后台请求拒绝或数据保持检查失败:' . $mode . ' ' . $output . "\n");
+ exit(1);
+ }
+ echo '后台请求拒绝与数据保持检查通过:' . $mode . "\n";
+ });
+ require __DIR__ . '/../usr/plugins/InfinityTime/panel.php';
+ throw new RuntimeException('应拒绝的请求继续执行了模板');
+ }
+ $adminDir = sys_get_temp_dir() . '/infinity-admin-shell-' . bin2hex(random_bytes(5));
+ mkdir($adminDir, 0700);
+ foreach (['header.php', 'menu.php', 'footer.php'] as $name) { file_put_contents($adminDir . '/' . $name, ''); }
+ $_SERVER['SCRIPT_FILENAME'] = $adminDir . '/extending.php';
+ $dataDir = dirname(__DIR__) . '/usr/plugins/InfinityTime/data';
+ $hadData = is_dir($dataDir);
+ try {
+ ob_start();
+ require __DIR__ . '/../usr/plugins/InfinityTime/panel.php';
+ $html = ob_get_clean();
+ preg_match_all('/