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 `
相册
${details()}
`; } +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('/]*method="post"[^>]*>(.*?)<\/form>/s', $html, $forms); + if (count($forms[1]) < 10) { throw new RuntimeException('应渲染足够的原生 POST 表单'); } + foreach ($forms[1] as $body) { + if (substr_count($body, 'name="_" value="fixture-panel-token"') !== 1) { + throw new RuntimeException('每个原生 POST 表单必须且只能携带一个有效令牌'); + } + } + if (substr_count($html, 'class="pp-album"') !== 20 || strpos($html, 'name="action" value="publish_album"') === false + || strpos($html, 'data-page="1"') === false || strpos($html, 'name="operation_key"') === false) { + throw new RuntimeException('分页、明确发布或原生上传幂等字段缺失'); + } + if (strpos($html, 'data-loaded="1"') !== false) { throw new RuntimeException('摘要首屏不应加载图片详情'); } + if (strpos($html, '只读健康检查') === false || strpos($html, '在隔离环境做一次恢复演练') === false) { throw new RuntimeException('管理员健康检查与恢复步骤缺失'); } + echo '后台模板:' . count($forms[1]) . " 个原生表单令牌、分页、草稿与按需详情检查通过\n"; + } finally { + foreach (['header.php', 'menu.php', 'footer.php'] as $name) { unlink($adminDir . '/' . $name); } + rmdir($adminDir); + if (!$hadData && is_dir($dataDir)) { rmdir($dataDir); } + } +} diff --git a/tests/admin-upload.cjs b/tests/admin-upload.cjs index 0a45a04..0869bd7 100644 --- a/tests/admin-upload.cjs +++ b/tests/admin-upload.cjs @@ -1,64 +1,489 @@ -// 在无 DataTransfer 的环境运行真实后台脚本,验证预览、移除、排序和提交。 +// 使用可控的 DOM、网络与存储替身运行真实后台脚本。 +// 无需浏览器依赖;同时覆盖成功、中断和重复操作。 const assert = require('node:assert/strict'); const vm = require('node:vm'); const fs = require('node:fs'); +const source = fs.readFileSync(require.resolve('../usr/plugins/InfinityTime/assets/admin.js'), 'utf8'); + +let createdNodes = 0; class Element { - constructor() { this.children = []; this.dataset = {}; this.style = {}; this.listeners = {}; this.classList = { add() {}, remove() {} }; } - addEventListener(name, fn) { this.listeners[name] = fn; } - appendChild(child) { child.parentNode = this; this.children.push(child); } - removeChild(child) { this.children.splice(this.children.indexOf(child), 1); } - set innerHTML(value) { this.children = []; } - querySelector() { return null; } + constructor(tag = 'div') { + createdNodes++; + this.tagName = tag.toUpperCase(); this.children = []; this.dataset = {}; this.style = {}; + this.listeners = {}; this.attributes = {}; this.className = ''; this.value = ''; this.disabled = false; + this.classList = { + contains: name => this.className.split(/\s+/).includes(name), + add: (...names) => { this.className = [...new Set(this.className.split(/\s+/).filter(Boolean).concat(names))].join(' '); }, + remove: (...names) => { this.className = this.className.split(/\s+/).filter(n => !names.includes(n)).join(' '); }, + toggle: (name, force) => { const yes = force === undefined ? !this.classList.contains(name) : force; this.classList[yes ? 'add' : 'remove'](name); return yes; } + }; + } + set value(value) { this._value = value; if (value === '' && this.tagName === 'INPUT' && this.type === 'file') this.files = []; } + get value() { return this._value; } + addEventListener(name, fn) { (this.listeners[name] ||= []).push(fn); } + emit(name, extra = {}) { + const event = { target: this, preventDefault() { this.defaultPrevented = true; }, stopPropagation() {}, ...extra }; + for (const fn of this.listeners[name] || []) fn(event); + return event; + } + click() { if (!this.disabled) this.emit('click'); } + appendChild(child) { if (child.parentNode) child.parentNode.removeChild(child); child.parentNode = this; this.children.push(child); return child; } + removeChild(child) { this.children.splice(this.children.indexOf(child), 1); child.parentNode = null; } + insertBefore(child, next) { child.parentNode = this; const i = this.children.indexOf(next); this.children.splice(i < 0 ? this.children.length : i, 0, child); } + get firstElementChild() { return this.children[0] || null; } + get nextSibling() { if (!this.parentNode) return null; return this.parentNode.children[this.parentNode.children.indexOf(this) + 1] || null; } + set textContent(value) { this._text = String(value); this.children = []; } + get textContent() { return (this._text || '') + this.children.map(c => c.textContent).join(''); } + set innerHTML(value) { this.children = []; this._text = String(value); } + get innerHTML() { return this._text || ''; } + set outerHTML(value) { this.replacedHTML = value; } + get outerHTML() { return '
'; } + setAttribute(name, value) { + this.attributes[name] = String(value); + if (name.startsWith('data-')) this.dataset[name.slice(5).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = String(value); + else if (name === 'class') this.className = value; + else if (['id', 'name', 'type'].includes(name)) this[name] = value; + } + getAttribute(name) { + if (name.startsWith('data-')) return this.dataset[name.slice(5).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] ?? null; + return this.attributes[name] ?? this[name] ?? null; + } + matches(selector) { + const attributes = [...selector.matchAll(/\[([^=\]]+)(?:=["']?([^\]"']+)["']?)?\]/g)]; + selector = selector.replace(/\[[^\]]+\]/g, ''); + const id = selector.match(/#([\w-]+)/), tag = selector.match(/^[\w-]+/), classes = [...selector.matchAll(/\.([\w-]+)/g)]; + return (!id || this.id === id[1]) && (!tag || this.tagName.toLowerCase() === tag[0]) && classes.every(m => this.classList.contains(m[1])) && + attributes.every(m => this.getAttribute(m[1]) !== null && (m[2] === undefined || this.getAttribute(m[1]) === m[2])); + } querySelectorAll(selector) { - if (selector === '.pp-up-thumb') { return this.children.map(c => c.children[0]); } - return []; + if (selector.startsWith(':scope > ')) return this.children.filter(c => c.matches(selector.slice(9))); + const selectors = selector.split(',').map(s => s.trim()), out = []; + const visit = child => { if (selectors.some(s => child.matches(s))) out.push(child); child.children.forEach(visit); }; + this.children.forEach(visit); return out; } + querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } + closest(selector) { return selector.split(',').some(s => this.matches(s.trim())) ? this : this.parentNode?.closest(selector) || null; } + scrollIntoView() {} + reset() { this.querySelectorAll('input,textarea').forEach(el => { el.value = el.defaultValue || ''; }); } } -const form = new Element(), input = new Element(), previews = new Element(); -input.name = 'files[]'; -const selectors = { '#pp-upload-form': form, '#pp-files-input': input, '#pp-upload-previews': previews }; -const document = new Element(); -document.querySelector = selector => selectors[selector] || null; -document.createElement = () => new Element(); -let serial = 0, sent, endpoint; -const activeUrls = new Set(); -const urlApi = { - createObjectURL() { const url = 'blob:test-' + ++serial; activeUrls.add(url); return url; }, - revokeObjectURL(url) { assert.ok(activeUrls.delete(url), 'URL 仅回收一次'); } -}; class FormDataMock { - constructor() { this.entries = []; } + constructor(form) { + this.entries = []; + if (form) form.querySelectorAll('input,textarea,select').filter(el => el.name && !el.disabled).forEach(el => this.append(el.name, el.value)); + } set(key, value) { this.delete(key); this.append(key, value); } + get(key) { return this.entries.find(entry => entry[0] === key)?.[1] ?? null; } delete(key) { this.entries = this.entries.filter(entry => entry[0] !== key); } append(key, value) { this.entries.push([key, value]); } } -const context = { - window: { PP_ADMIN: { url: '/admin/endpoint', token: 'test' }, URL: urlApi }, - URL: urlApi, document, FormData: FormDataMock, - XMLHttpRequest: class { - constructor() { this.upload = {}; } - open(method, url) { endpoint = url; } - send(data) { sent = data; } - } -}; -vm.runInNewContext(fs.readFileSync(require.resolve('../usr/plugins/InfinityTime/assets/admin.js'), 'utf8'), context); -document.listeners.DOMContentLoaded(); +const flush = async () => { for (let i = 0; i < 15; i++) await new Promise(resolve => setImmediate(resolve)); }; +const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; +const response = (data, ok = true) => ({ ok, json: async () => data, text: async () => typeof data === 'string' ? data : JSON.stringify(data) }); +function harness({ storage = new Map(), fetchHandler, initialCid = '', initialTarget = null, canPublish = true, withDataTransfer = false } = {}) { + const document = new Element('document'); + const make = (tag, props, parent = document) => { const element = new Element(tag); Object.assign(element, props); parent.appendChild(element); return element; }; + const container = make('div', { className: 'container typecho-page-main' }); + const form = make('form', { id: 'pp-upload-form' }, container); + ['title', 'address', 'device', 'tags', 'operation_key'].forEach(name => make('input', { name, type: name === 'operation_key' ? 'hidden' : 'text', value: name === 'title' ? '测试图集' : '' }, form)); + const cid = make('input', { id: 'pp-upload-cid', name: 'cid', type: 'hidden', value: initialCid }, form); + const input = make('input', { id: 'pp-files-input', name: 'files[]', type: 'file', files: [] }, form); + const previews = make('div', { id: 'pp-upload-previews' }, form); + const target = make('div', { id: 'pp-upload-target' }, form); + const summary = make('div', { id: 'pp-upload-summary' }, form); + const dropzone = make('div', { id: 'pp-dropzone' }, form); + const progress = make('div', { id: 'pp-upload-progress' }, form); + make('div', { id: 'pp-upload-bar' }, progress); make('span', { className: 'pp-msg' }, progress); + const submit = make('button', { id: 'pp-upload-submit', type: 'submit' }, form); + const retry = make('button', { id: 'pp-upload-retry', type: 'button' }, form); + const publish = make('button', { id: 'pp-upload-publish', type: 'button' }, form); + const fresh = make('button', { id: 'pp-upload-new', type: 'button' }, form); + const albums = make('div', { id: 'pp-albums-card', dataset: { page: '3', query: '旅行 & 海' } }); + const append = make('a', { dataset: { appendCid: '90', appendTitle: '已有图集' } }, albums); + const details = make('details', { className: 'pp-album', open: false }, albums); + const thumbs = make('div', { className: 'pp-thumbs', dataset: { cid: '90', loaded: '0' } }, details); + const maintenance = make('button', { dataset: { run: 'rebuild' } }); + const maintenanceMsg = make('div', { id: 'pp-msg-rebuild' }); + document.createElement = tag => new Element(tag); + const activeUrls = new Set(); let serial = 0; + const URLMock = { createObjectURL() { const url = 'blob:test-' + ++serial; activeUrls.add(url); return url; }, revokeObjectURL(url) { assert.ok(activeUrls.delete(url), '每个预览 URL 最多回收一次'); } }; + const requests = [], uploads = [], window = new Element('window'); + Object.assign(window, { PP_ADMIN: { url: '/admin/endpoint', token: 'secret-csrf', userId: 7, uploadTarget: initialTarget, canPublish: canPublish }, URL: URLMock, + sessionStorage: { getItem: key => storage.get(key), setItem: (key, value) => storage.set(key, value) } }); + let serverCount = 0, serverStatus = 'draft'; + const context = { window, document, URL: URLMock, FormData: FormDataMock, Promise, setTimeout, + confirm: () => true, location: { hash: '' }, history: { replaceState() {} }, + DOMParser: class { parseFromString() { const doc = new Element('document'); make('div', { id: 'pp-albums-card' }, doc); return doc; } }, + fetch: async (url, options = {}) => { + requests.push({ url, ...options }); + if (fetchHandler) { const handled = fetchHandler(url, options); if (handled !== undefined) return handled; } + if (options.body?.get('action') === 'create_draft') return response({ ok: true, cid: 42, status: 'draft' }); + if (options.body?.get('action') === 'publish_album') { serverStatus = 'publish'; return response({ ok: true }); } + if (options.body?.get('action') === 'maintenance') return response({ ok: true, finished: true, msg: '完成' }); + if (url.includes('job=album_status')) return response({ ok: true, cid: 42, title: '测试图集', count: serverCount, status: serverStatus }); + return response('
'); + }, + XMLHttpRequest: class { + constructor() { this.upload = {}; } + open(method, url) { this.method = method; this.url = url; } + send(data) { this.data = data; uploads.push(this); } + reply(data, status = 200) { if (data.ok && !data.replayed) serverCount++; this.status = status; this.responseText = JSON.stringify(data); this.onload(); } + } + }; + if (withDataTransfer) context.DataTransfer = class { + constructor() { this.files = []; this.items = { add: file => this.files.push(file) }; } + }; + vm.runInNewContext(source, context); document.emit('DOMContentLoaded'); + return { document, form, input, previews, target, summary, submit, retry, publish, fresh, cid, dropzone, append, details, thumbs, maintenance, maintenanceMsg, + activeUrls, requests, uploads, storage, window, + select(files) { input.files = files; input.emit('change'); }, + submitForm() { form.emit('submit'); }, + setCount(count) { serverCount = count; }, + setStatus(status) { serverStatus = status; }, + states: () => previews.children.map(c => c.dataset.state), + field: name => form.querySelector('[name="' + name + '"]') }; +} const files = ['a.jpg', 'b.jpg', 'c.jpg'].map(name => ({ name, size: 10, lastModified: 1 })); -input.files = files; -input.listeners.change(); -assert.equal(previews.children.length, 3); -assert.equal(activeUrls.size, 3, '必须生成真实预览 URL'); -assert.equal(input.required, false, '文件队列由 JS 校验'); -const drop = value => ({ preventDefault() {}, dataTransfer: { getData() { return value; } } }); -previews.children[0].listeners.drop(drop('999')); -assert.equal(previews.children.length, 3, '越界拖拽不破坏队列'); -previews.children[0].listeners.drop(drop('2')); -assert.deepEqual(previews.children.map(c => c.children[0].alt), ['c.jpg', 'a.jpg', 'b.jpg']); -assert.equal(activeUrls.size, 3, '重排回收旧 URL'); -previews.children[1].children[1].listeners.click(); -assert.deepEqual(previews.children.map(c => c.children[0].alt), ['c.jpg', 'b.jpg']); -assert.equal(activeUrls.size, 2, '移除回收旧 URL'); -form.listeners.submit({ preventDefault() {} }); -assert.equal(endpoint, '/admin/endpoint', '提交地址不受原生 URL API 影响'); -assert.deepEqual(sent.entries.filter(([key]) => key === 'files[]').map(([, file]) => file.name), ['c.jpg', 'b.jpg'], '无 DataTransfer 也按预览顺序提交'); -console.log('Admin upload preview / removal / sorting / submission passed'); +const drop = index => ({ dataTransfer: { getData() { return String(index); } } }); + +(async () => { + const draft = deferred(); + const h = harness({ fetchHandler(url, options) { if (options.body?.get('action') === 'create_draft') return draft.promise; } }); + h.select(files); h.select(files); + assert.equal(h.previews.children.length, 3, '忽略重复选择的文件'); + assert.equal(h.activeUrls.size, 3); + assert.equal(h.input.required, false); + h.previews.children[0].emit('drop', drop(999)); + h.previews.children[0].emit('drop', drop(2)); + assert.deepEqual(h.previews.children.map(c => c.children[0].alt), ['c.jpg', 'a.jpg', 'b.jpg']); + h.previews.children[1].children[1].click(); + assert.deepEqual(h.previews.children.map(c => c.children[0].alt), ['c.jpg', 'b.jpg']); + assert.equal(h.activeUrls.size, 2, '移除预览后释放其 URL'); + h.previews.children[0].querySelector('.pp-up-tit').value = '首张标题'; + h.previews.children[0].querySelector('.pp-up-tit').emit('input'); + h.submitForm(); h.submitForm(); + assert.equal(h.input.files.length, 0, '上传队列接管文件后清空原生 FileList,避免成功文件继续被输入框持有'); + assert.equal(h.requests.filter(r => r.body?.get('action') === 'create_draft').length, 1, '重复提交不会创建第二份草稿'); + h.previews.children[0].emit('drop', drop(1)); h.previews.children[0].children[1].emit('click'); h.select([files[0]]); + assert.deepEqual(h.previews.children.map(c => c.children[0].alt), ['c.jpg', 'b.jpg'], '处理期间不能更改队列'); + assert.equal(h.input.disabled, true); + draft.resolve(response({ ok: true, cid: 42, status: 'draft' })); await flush(); + assert.equal(h.uploads.length, 1, '同一时刻仅有首张图片请求在执行'); + assert.equal(h.uploads[0].url, '/admin/endpoint'); + assert.equal(h.uploads[0].method, 'POST'); + assert.equal(h.uploads[0].data.get('action'), 'upload_image'); + assert.equal(h.uploads[0].data.get('file').name, 'c.jpg', '没有 DataTransfer 时也按预览顺序串行提交'); + assert.equal(h.uploads[0].data.get('files[]'), null, '不提交批量文件字段'); + assert.equal(h.uploads[0].data.get('title'), '首张标题'); + assert.equal(h.uploads[0].data.get('_'), 'secret-csrf'); + assert.deepEqual(h.states(), ['uploading', 'queued']); + const beforeEmptyProgress = createdNodes; + h.uploads[0].upload.onprogress({ lengthComputable: true, loaded: 0, total: 0 }); + assert.equal(createdNodes, beforeEmptyProgress); + assert.equal(h.document.querySelector('#pp-upload-bar').style.width, '0%', '零字节进度不会产生 NaN%'); + const untouchedCard = h.previews.children[1]; + h.uploads[0].upload.onprogress({ lengthComputable: true, loaded: 10, total: 10 }); + assert.equal(h.previews.children[1], untouchedCard, '当前项状态改变不会重建其他卡片'); + const convertedCard = h.previews.children[0], afterConversionNodes = createdNodes; + h.uploads[0].upload.onload(); h.uploads[0].upload.onload(); + assert.equal(h.previews.children[0], convertedCard, '上传完成事件不会重复刷新转换状态'); + assert.equal(createdNodes, afterConversionNodes); + assert.deepEqual(h.states(), ['converting', 'queued'], '转换阶段具有独立状态'); + h.uploads[0].reply({ ok: true, rowId: 100, cid: 42 }); await flush(); + assert.equal(h.uploads.length, 2); + assert.deepEqual(h.states(), ['done', 'uploading']); + assert.equal(h.activeUrls.size, 1, '已保存原图的 ObjectURL 立即释放,未完成项保留'); + assert.equal(h.previews.children[0].children[0].tagName, 'DIV', '成功项释放 File 引用后使用已保存占位卡片'); + assert.ok(h.previews.children[0].querySelector('.pp-up-saved')); + const failedKey = h.uploads[1].data.get('item_key'); + h.uploads[1].reply({ ok: false, retryable: true, msg: '正忙' }); await flush(); + assert.deepEqual(h.states(), ['done', 'failed']); + assert.equal(h.activeUrls.size, 1, '失败项保留原文件预览以便直接重试'); + assert.equal(h.publish.disabled, true, '部分失败时不能误发布'); + assert.equal(h.requests.some(r => r.body?.get('action') === 'publish_album'), false, '保存不会隐式发布图集'); + const listRead = h.requests.find(r => r.url.includes('job=albums_html')); + assert.match(listRead.url, /page=3/); + assert.match(listRead.url, /q=%E6%97%85%E8%A1%8C%20%26%20%E6%B5%B7/, 'AJAX 刷新保留搜索条件与页码'); + h.retry.click(); h.retry.click(); await flush(); + assert.equal(h.uploads.length, 3, '重试仅提交失败项,且不会重复提交'); + assert.equal(h.uploads[2].data.get('item_key'), failedKey, '重试保留原幂等键'); + h.uploads[2].reply({ ok: true, rowId: 101, cid: 42 }); await flush(); + assert.deepEqual(h.states(), ['done', 'done']); + assert.equal(h.activeUrls.size, 0, '重试成功后释放最后一份原图预览'); + h.select([files[1], files[2]]); + assert.equal(h.activeUrls.size, 0, '重选已保存文件不重新分配预览资源'); + assert.equal(h.previews.children.length, 2); + assert.equal(h.input.files.length, 0, '无 DataTransfer 的浏览器也不持有被忽略的已保存文件'); + assert.equal(h.publish.disabled, false); + h.publish.click(); h.publish.click(); await flush(); + assert.equal(h.requests.filter(r => r.body?.get('action') === 'publish_album').length, 1); + assert.equal(h.publish.hidden, true); + h.fresh.click(); assert.equal(h.activeUrls.size, 0, '新建另一草稿会释放旧预览'); + assert.equal(h.cid.value, ''); + + // 响应丢失时,图片可能已保存入库;刷新后必须保留原图片幂等键。 + const interrupted = harness(); interrupted.select(files.slice(0, 2)); interrupted.submitForm(); await flush(); + interrupted.uploads[0].reply({ ok: true, rowId: 110 }); await flush(); + const uncertainKey = interrupted.uploads[1].data.get('item_key'); + interrupted.uploads[1].upload.onload(); interrupted.uploads[1].onerror(); await flush(); + const recovered = harness({ storage: interrupted.storage }); recovered.setCount(2); await flush(); + assert.deepEqual(recovered.states(), ['done', 'failed'], '刷新后仍保留服务器已保存项的状态'); + assert.equal(recovered.uploads.length, 0, '刷新后不会假设本地文件内容仍然可用'); + recovered.retry.click(); await flush(); assert.equal(recovered.uploads.length, 0, '文件内容缺失时必须重新选择'); + recovered.select(files.slice(0, 2)); recovered.retry.click(); await flush(); + assert.equal(recovered.uploads.length, 1, '重新选择整批文件时跳过已保存项'); + assert.equal(recovered.uploads[0].data.get('item_key'), uncertainKey); + recovered.uploads[0].reply({ ok: true, replayed: true, rowId: 111 }); await flush(); + assert.deepEqual(recovered.states(), ['done', 'done']); + assert.match(recovered.target.textContent, /已保存 2 张/, '幂等重试后的数量以服务器结果为准'); + + // 创建响应丢失后必须复用原操作幂等键,避免重复创建图集。 + let creates = 0; + const lostDraft = harness({ fetchHandler(url, options) { + if (options.body?.get('action') === 'create_draft' && ++creates === 1) return Promise.reject(new Error('网络断开')); + } }); + lostDraft.select([files[0]]); lostDraft.submitForm(); await flush(); lostDraft.submitForm(); await flush(); + const createCalls = lostDraft.requests.filter(r => r.body?.get('action') === 'create_draft'); + assert.equal(createCalls.length, 2); assert.equal(createCalls[0].body.get('operation_key'), createCalls[1].body.get('operation_key')); + lostDraft.uploads[0].reply({ ok: true, rowId: 1 }); await flush(); + + // 已有已发布图集支持追加图片,并保持发布状态。 + const append = harness(); append.setStatus('publish'); append.setCount(4); append.append.click(); await flush(); + append.select([files[0]]); append.submitForm(); await flush(); + assert.equal(append.uploads[0].data.get('cid'), '90'); + assert.equal(append.requests.some(r => r.body?.get('action') === 'create_draft'), false); + append.uploads[0].reply({ ok: true, rowId: 90 }); await flush(); + assert.equal(append.publish.hidden, true); + + // 明确选中的、经服务器授权的图集优先于旧会话队列。 + const targeted = harness({ storage: interrupted.storage, initialTarget: { cid: 90, title: '明确选择的图集', status: 'publish', count: 4 } }); + targeted.setStatus('publish'); await flush(); + assert.equal(targeted.cid.value, '90'); assert.equal(targeted.previews.children.length, 0); + assert.equal(targeted.fresh.hidden, false, '服务器初始隐藏的新建按钮可正常显示'); + + // 贡献者可以保存草稿,但不能发布或修改已发布图集。 + const contributor = harness({ canPublish: false }); contributor.select([files[0]]); contributor.submitForm(); await flush(); + contributor.uploads[0].reply({ ok: true, rowId: 1 }); await flush(); + assert.equal(contributor.publish.hidden, true); contributor.publish.emit('click'); await flush(); + assert.equal(contributor.requests.some(r => r.body?.get('action') === 'publish_album'), false); + assert.match(contributor.target.textContent, /编辑或管理员/); + contributor.setStatus('publish'); contributor.append.click(); await flush(); + assert.equal(contributor.input.disabled, true); assert.equal(contributor.submit.disabled, true); + contributor.select([files[1]]); contributor.submitForm(); await flush(); + assert.equal(contributor.uploads.length, 1, '贡献者只能只读查看已发布图集'); + assert.equal(contributor.fresh.disabled, false, '只读图集不会阻止用户新建草稿'); + contributor.fresh.click(); assert.equal(contributor.input.disabled, false); + + // 大队列只允许线性创建 DOM 节点;每个原文件仅上传一次。 + // 时间值受 DOM/XHR 替身和运行环境影响,不作为浏览器性能门槛。 + const costs = []; + for (const count of [10, 50, 100, 200]) { + const batch = harness(), baseline = createdNodes; + const batchFiles = Array.from({ length: count }, (_, i) => ({ name: 'photo-' + i + '.jpg', size: 20 * 1024 * 1024, lastModified: 1 })); + batch.select(batchFiles); batch.submitForm(); await flush(); + for (let i = 0; i < count; i++) { + assert.equal(batch.uploads.length, i + 1, '前一个请求结束前不会上传下一项'); + const xhr = batch.uploads[i]; + assert.equal(xhr.data.get('file'), batchFiles[i]); + xhr.upload.onprogress({ lengthComputable: true, loaded: 20, total: 20 }); + const convertedNodes = createdNodes; + xhr.upload.onload(); + assert.equal(createdNodes, convertedNodes, '100% progress 与 onload 不重复创建节点'); + xhr.reply({ ok: true, rowId: i + 1 }); await flush(); + assert.equal(batch.activeUrls.size, count - i - 1, '逐项释放资源,待上传文件保持可用'); + const settledNodes = createdNodes; + xhr.upload.onload(); xhr.onerror(); xhr.ontimeout(); + assert.equal(createdNodes, settledNodes, '已结算请求的迟到事件不改变队列或重复释放资源'); + } + assert.equal(batch.uploads.length, count); + assert.equal(new Set(batch.uploads.map(xhr => xhr.data.get('item_key'))).size, count, '每项保留独立幂等键'); + assert.equal(batch.states().filter(state => state === 'done').length, count); + assert.equal(batch.previews.querySelectorAll('.pp-up-saved').length, count); + assert.equal(batch.activeUrls.size, 0); + const nodes = createdNodes - baseline; + assert.ok(nodes <= 50 * count + 10, '节点数必须随队列线性增长:' + count + ' 张创建了 ' + nodes + ' 个节点'); + costs.push({ count, nodes }); + batch.fresh.click(); assert.equal(batch.activeUrls.size, 0, '清空成功队列不会再次回收 URL'); + } + assert.ok(costs[3].nodes <= costs[2].nodes * 2 + 10, '队列翻倍不能导致节点数平方增长'); + + // 支持 DataTransfer 的浏览器:重排/移除会同步 FileList,上传后清空但失败项仍能重试。 + const nativeInput = harness({ withDataTransfer: true }); + nativeInput.select(files); nativeInput.previews.children[0].emit('drop', drop(2)); + assert.deepEqual(Array.from(nativeInput.input.files, file => file.name), ['c.jpg', 'a.jpg', 'b.jpg']); + nativeInput.previews.children[1].children[1].click(); + assert.deepEqual(Array.from(nativeInput.input.files, file => file.name), ['c.jpg', 'b.jpg']); + nativeInput.submitForm(); await flush(); assert.equal(nativeInput.input.files.length, 0); + nativeInput.uploads[0].reply({ ok: true, rowId: 1 }); await flush(); + nativeInput.uploads[1].onerror(); await flush(); + assert.equal(nativeInput.activeUrls.size, 1); + const nativeFailedKey = nativeInput.uploads[1].data.get('item_key'); + nativeInput.retry.click(); await flush(); + assert.equal(nativeInput.uploads.length, 3); + assert.equal(nativeInput.uploads[2].data.get('file'), files[1]); + assert.equal(nativeInput.uploads[2].data.get('item_key'), nativeFailedKey); + nativeInput.uploads[2].reply({ ok: true, rowId: 2 }); await flush(); + assert.equal(nativeInput.activeUrls.size, 0); + nativeInput.select(files.slice(1)); + assert.equal(nativeInput.input.files.length, 0, '重选全部已保存文件也不会保留原生文件引用'); + assert.equal(nativeInput.activeUrls.size, 0); + + // 兼容旧响应:未提供 rowId 时,不激进释放无法确认为服务器记录的原文件。 + const legacyResponse = harness(); legacyResponse.select([files[0]]); legacyResponse.submitForm(); await flush(); + legacyResponse.uploads[0].reply({ ok: true }); await flush(); + assert.equal(legacyResponse.activeUrls.size, 1); + assert.equal(legacyResponse.previews.children[0].children[0].tagName, 'IMG'); + legacyResponse.fresh.click(); assert.equal(legacyResponse.activeUrls.size, 0); + + // 图片详情加载失败可重试;维护操作仅使用带鉴权信息的 POST 请求。 + let detailAttempts = 0; + const lazy = harness({ fetchHandler(url) { if (url.includes('job=album_images')) return response('detail', ++detailAttempts > 1); } }); + lazy.details.open = true; lazy.details.emit('toggle'); await flush(); + assert.equal(lazy.thumbs.dataset.loaded, '0'); + lazy.thumbs.querySelector('button').click(); await flush(); + assert.equal(detailAttempts, 2); assert.equal(lazy.thumbs.dataset.loaded, '1'); + lazy.maintenance.click(); lazy.maintenance.click(); await flush(); + const jobs = lazy.requests.filter(r => r.body?.get('action') === 'maintenance'); + assert.equal(jobs.length, 1); assert.equal(jobs[0].method, 'POST'); assert.equal(jobs[0].body.get('job'), 'rebuild'); + assert.equal(jobs[0].body.get('_'), 'secret-csrf'); + assert.equal(lazy.requests.some(r => r.url.includes('_=')), false, 'CSRF 令牌不会进入只读请求 URL'); + console.log('后台串行队列、部分重试、幂等、恢复、追加、发布、分页、懒加载重试和 POST 维护测试通过'); +})().catch(error => { console.error(error); process.exitCode = 1; }); + +// 可选真实浏览器回归: PLAYWRIGHT_BROWSER=chromium|firefox|webkit node tests/admin-upload.cjs --browser +// 系统 Chromium 可额外指定 CHROMIUM_PATH=/usr/bin/chromium。 +// 使用本地隔离 HTTP 服务;无需 PHP 服务器或真实账号。 +async function browserRegression() { + const browserName = process.env.PLAYWRIGHT_BROWSER || 'chromium'; + assert.ok(['chromium', 'firefox', 'webkit'].includes(browserName), '不支持的 PLAYWRIGHT_BROWSER: ' + browserName); + const browserType = require('playwright')[browserName]; + const path = require('node:path'); + const os = require('node:os'); + const http = require('node:http'); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'infinitytime-admin-')); + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', 'base64'); + const paths = ['a.png', 'b.png'].map(name => { const file = path.join(directory, name); fs.writeFileSync(file, png); return file; }); + let browser, server; + const html = ` +
+
+
+
选择或拖入图片
+
+
+
+ `; + const fields = body => { + const result = {}; + const pattern = /Content-Disposition: form-data; name="([^"]+)"(?:; filename="([^"]*)")?\r\n(?:Content-Type:[^\r]+\r\n)?\r\n([\s\S]*?)(?=\r\n--)/g; + for (const match of body.matchAll(pattern)) result[match[1]] = match[2] || match[3]; + return result; + }; + try { + browser = await browserType.launch({ headless: true, ...(browserName === 'chromium' && process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); + const page = await browser.newPage({ viewport: { width: 1100, height: 850 } }); + await page.addInitScript(() => { + const active = new Set(), create = URL.createObjectURL.bind(URL), revoke = URL.revokeObjectURL.bind(URL); + window.uploadPreviewResources = active; + URL.createObjectURL = file => { const url = create(file); active.add(url); return url; }; + URL.revokeObjectURL = url => { active.delete(url); revoke(url); }; + }); + const errors = [], calls = [], rows = new Map(); + page.on('pageerror', error => errors.push(error.message)); + page.on('dialog', dialog => dialog.accept()); + let draftCount = 0, published = false, failedOnce = false, active = 0, maxActive = 0, releaseFirst; + const firstGate = new Promise(resolve => { releaseFirst = resolve; }); + // Playwright 路由拦截会把尚未发出的请求挂起,不能用它等待真实上传完成事件。 + // 本地服务先读完请求体,仅延迟服务器响应,才能准确观察“上传 → 转换”边界。 + server = http.createServer(async (request, response) => { + const url = new URL(request.url, 'http://127.0.0.1'); + const reply = (body, contentType = 'text/html', status = 200) => { + response.writeHead(status, { 'Content-Type': contentType, 'Cache-Control': 'no-store' }); + response.end(body); + }; + try { + if (url.pathname === '/') return reply(html); + if (url.pathname === '/admin.js') return reply(source, 'text/javascript'); + if (url.pathname === '/admin.css') return reply(fs.readFileSync(require.resolve('../usr/plugins/InfinityTime/assets/admin.css'), 'utf8'), 'text/css'); + if (url.pathname !== '/admin/endpoint') return reply('', 'text/plain', 404); + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const data = fields(Buffer.concat(chunks).toString('utf8')); + calls.push({ method: request.method, url: request.url, data }); + const json = object => reply(JSON.stringify(object), 'application/json'); + if (data.action === 'create_draft') { draftCount++; return json({ ok: true, cid: 42, status: 'draft' }); } + if (data.action === 'upload_image') { + active++; maxActive = Math.max(maxActive, active); + if (!rows.size && data.file === 'a.png') await firstGate; + active--; + if (data.file === 'b.png' && !failedOnce) { failedOnce = true; return json({ ok: false, retryable: true, msg: '媒体处理锁正忙' }); } + const replayed = rows.has(data.item_key); rows.set(data.item_key, data.file); + return json({ ok: true, rowId: rows.size, cid: 42, replayed }); + } + if (data.action === 'publish_album') { published = true; return json({ ok: true, msg: '已发布' }); } + if (url.searchParams.get('job') === 'album_status') return json({ ok: true, cid: 42, title: '浏览器测试图集', status: published ? 'publish' : 'draft', count: rows.size }); + return reply('
'); + } catch (error) { + errors.push(error.message); + if (!response.headersSent) reply('测试服务错误', 'text/plain', 500); + else response.destroy(error); + } + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + await page.goto('http://127.0.0.1:' + server.address().port + '/'); + await page.locator('#pp-files-input').setInputFiles(paths); + assert.equal(await page.locator('.pp-up-item').count(), 2); + await page.locator('#pp-upload-submit').click(); + await page.waitForFunction(() => document.querySelector('.pp-up-converting')); + await page.locator('#pp-upload-form').evaluate(form => form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))); + assert.equal(draftCount, 1); + assert.equal(await page.locator('#pp-files-input').isDisabled(), true); + assert.equal(await page.locator('.pp-up-remove').first().isDisabled(), true); + assert.equal(calls.filter(call => call.data.action === 'upload_image').length, 1); + releaseFirst(); + await page.waitForFunction(() => !document.querySelector('#pp-upload-submit').disabled && document.querySelector('.pp-up-failed')); + assert.equal(rows.size, 1); assert.equal(maxActive, 1); + assert.equal(await page.evaluate(() => window.uploadPreviewResources.size), 1, '真实浏览器只保留失败项预览'); + assert.equal(await page.locator('.pp-up-done .pp-up-saved').count(), 1); + assert.equal(await page.locator('#pp-files-input').evaluate(input => input.files.length), 0, '原生 FileList 已清空'); + assert.equal(published, false); + assert.equal(await page.locator('#pp-upload-publish').isDisabled(), true); + const failedKey = calls.find(call => call.data.file === 'b.png').data.item_key; + await page.reload(); + await page.waitForFunction(() => !document.querySelector('#pp-upload-submit').disabled); + assert.equal(await page.locator('.pp-up-done').count(), 1); + assert.equal(await page.locator('.pp-up-failed').count(), 1); + await page.locator('#pp-files-input').setInputFiles(paths); + await page.locator('#pp-upload-retry').click(); + await page.waitForFunction(() => document.querySelectorAll('.pp-up-done').length === 2 && !document.querySelector('#pp-upload-submit').disabled); + assert.equal(rows.size, 2); + assert.equal(await page.evaluate(() => window.uploadPreviewResources.size), 0, '真实浏览器全部保存后无活跃 ObjectURL'); + assert.equal(await page.locator('.pp-up-done .pp-up-saved').count(), 2); + const imageCalls = calls.filter(call => call.data.action === 'upload_image'); + assert.deepEqual(imageCalls.map(call => call.data.file), ['a.png', 'b.png', 'b.png']); + assert.equal(imageCalls[2].data.item_key, failedKey); + await page.locator('#pp-upload-publish').click(); + await page.waitForFunction(() => document.querySelector('#pp-upload-publish').hidden); + assert.equal(published, true); + assert.equal(calls.filter(call => call.data.action === 'publish_album').length, 1); + assert.ok(calls.filter(call => call.method === 'GET').every(call => !call.url.includes('test-token'))); + assert.ok(calls.filter(call => call.url.includes('albums_html')).every(call => call.url.includes('page=2') && call.url.includes('q=sky'))); + await page.setViewportSize({ width: 390, height: 844 }); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, '移动端队列布局不超出视口'); + await page.locator('#pp-upload-new').click(); + assert.equal(await page.locator('.pp-up-item').count(), 0); + assert.equal(await page.locator('#pp-upload-cid').inputValue(), ''); + assert.deepEqual(errors, []); + console.log('后台 ' + browserName + ' 回归通过:串行转换、部分重试、刷新后重选、明确发布与移动布局'); + } finally { + if (browser) await browser.close(); + if (server) { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + } + fs.rmSync(directory, { recursive: true, force: true }); + } +} +if (process.argv.includes('--browser')) browserRegression().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/admin-workflow.php b/tests/admin-workflow.php new file mode 100644 index 0000000..eb54c99 --- /dev/null +++ b/tests/admin-workflow.php @@ -0,0 +1,209 @@ +kind = $kind; $this->table = $table; $this->fields = $fields ?: ['*']; } + public function from($table): self { $this->table = $table; return $this; } + public function rows($rows): self { $this->rows = $rows; return $this; } + public function where($clause, ...$values): self { + foreach ($values as $value) { $clause = substr_replace($clause, $this->quoteValue($value), strpos($clause, '?'), 1); } + $this->where[] = '(' . $clause . ')'; return $this; + } + public function limit($limit): self { $this->limit = ' LIMIT ' . (int)$limit; return $this; } + public function order($column, $direction): self { $this->order .= ($this->order ? ',' : ' ORDER BY ') . '"' . $column . '" ' . $direction; return $this; } + public function quoteValue($value): string { $this->params[] = $value; return '#param:' . (count($this->params) - 1) . '#'; } + public function prepare(string $sql): string { + return preg_replace_callback('/#param:(\d+)#/', function ($match) { $value = $this->params[(int)$match[1]]; return $value === null ? 'NULL' : Db::get()->pdo->quote((string)$value); }, $sql); + } + public function __toString(): string { + $table = '"' . $this->table . '"'; + if ($this->kind === 'insert') { + $names = implode(',', array_map(static function ($name) { return '"' . $name . '"'; }, array_keys($this->rows))); + $values = implode(',', array_map(function ($value) { return $this->quoteValue($value); }, array_values($this->rows))); + $sql = "INSERT INTO $table ($names) VALUES ($values)"; + } elseif ($this->kind === 'update') { + $values = []; + foreach ($this->rows as $name => $value) { $values[] = '"' . $name . '" = ' . $this->quoteValue($value); } + $sql = "UPDATE $table SET " . implode(',', $values); + } elseif ($this->kind === 'delete') { $sql = "DELETE FROM $table"; } + else { $sql = 'SELECT ' . implode(',', $this->fields) . " FROM $table"; } + return $this->prepare($sql . ($this->where ? ' WHERE ' . implode(' AND ', $this->where) : '') . $this->order . $this->limit); + } + public function isInsert(): bool { return $this->kind === 'insert'; } + } + class Db { + public const SORT_ASC = 'ASC', WRITE = 'WRITE', READ = 'READ'; + public static self $instance; + public \PDO $pdo; + public array $reads = []; + public array $operations = []; + public string $adapter = 'Pdo_SQLite'; + public function __construct() { $this->pdo = new \PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); } + public static function get(): self { return self::$instance; } + public function getPrefix(): string { return 'admin_'; } + public function getAdapterName(): string { return $this->adapter; } + public function selectDb($op) { return $this->pdo; } + public function select(...$fields): Query { return new Query('select', '', $fields); } + public function insert($table): Query { return new Query('insert', $table); } + public function update($table): Query { return new Query('update', $table); } + public function delete($table): Query { return new Query('delete', $table); } + public function fetchRow($query) { if ($query instanceof \PDOStatement) { return $query->fetch(\PDO::FETCH_ASSOC); } $this->reads[] = (string)$query; return $this->pdo->query((string)$query)->fetch(\PDO::FETCH_ASSOC); } + public function fetchAll($query): array { if ($query instanceof \PDOStatement) { return $query->fetchAll(\PDO::FETCH_ASSOC); } $this->reads[] = (string)$query; return $this->pdo->query((string)$query)->fetchAll(\PDO::FETCH_ASSOC); } + public function query($query, $op = self::READ) { + $this->operations[] = ['sql' => (string)$query, 'op' => $op, 'object' => is_object($query)]; + if (preg_match('/^SELECT /i', (string)$query)) { $this->reads[] = (string)$query; return $this->pdo->query((string)$query); } + $affected = $this->pdo->exec((string)$query); + return $query instanceof Query && $query->isInsert() ? (int)$this->pdo->lastInsertId() : $affected; + } + } +} +namespace TypechoPlugin\InfinityTime { + class Plugin { + public const DEFAULT_QUALITY = 76, DEFAULT_THUMB_MAX = 1280, DEFAULT_MAX_WIDTH = 2560, + DEFAULT_FULL_QUALITY = 82, DEFAULT_PANO_WIDTH = 0, DEFAULT_PANO_QUALITY = 92, DEFAULT_KEEP_ORIGINAL = '1'; + public static function opt($name, $default) { return $default; } + } +} +namespace TypechoPlugin\InfinityTime\Lib { + function is_uploaded_file($path): bool { return is_file($path); } + class ImageRepository { + public static function uploadRoot(): string { return sys_get_temp_dir(); } + public static bool $locked = false; + public static bool $busy = false; + public static bool $failSync = false; + public static int $ingested = 0; + public static int $synced = 0; + public static ?string $lastError = null; + public static array $lastOptions = []; + public static function lockMedia(): void { if (self::$busy) { throw new \RuntimeException('busy'); } self::$locked = true; } + public static function ensureSchema(): void {} + public static function table(): string { return 'admin_infinitytime_images'; } + public static function ingest($file, $options): ?array { + if (!self::$locked) { throw new \RuntimeException('入库前处理未持锁'); } + self::$ingested++; self::$lastOptions = $options; + if ($file['name'] === 'bad.heic') { self::$lastError = 'decoder failed'; return null; } + return ['full' => '/full/' . self::$ingested . '.webp', 'exif' => ['make' => 'Camera', 'model' => 'Test']]; + } + public static function insertRow($cid, $meta, $sort): int { + if (!self::$locked) { throw new \RuntimeException('图片写入未持锁'); } + return \Typecho\Db::get()->query(\Typecho\Db::get()->insert(self::table())->rows([ + 'cid' => $cid, 'sort' => $sort, 'full' => $meta['full'], 'exif' => json_encode($meta['exif']), + 'upload_key' => $meta['upload_key'], 'upload_fingerprint' => $meta['upload_fingerprint'], + ])); + } + public static function syncPostFields($cid): void { + if (!self::$locked) { throw new \RuntimeException('字段同步未持锁'); } + self::$synced++; + if (self::$failSync) { throw new \RuntimeException('sync interrupted'); } + } + } +} +namespace { + use TypechoPlugin\InfinityTime\Lib\AdminSecurity as Security; + use TypechoPlugin\InfinityTime\Lib\AdminRepository as Repository; + use TypechoPlugin\InfinityTime\Lib\AdminWorkflow as Workflow; + use TypechoPlugin\InfinityTime\Lib\ImageRepository as Images; + require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php'; + require __DIR__ . '/../usr/plugins/InfinityTime/Lib/AdminSecurity.php'; + require __DIR__ . '/../usr/plugins/InfinityTime/Lib/AdminRepository.php'; + require __DIR__ . '/../usr/plugins/InfinityTime/Lib/AdminWorkflow.php'; + $checks = 0; + function check(bool $condition, string $label): void { global $checks; if (!$condition) { throw new RuntimeException($label); } $checks++; } + function rejects(callable $fn, string $label): void { $rejected = false; try { $fn(); } catch (Throwable $e) { $rejected = true; } check($rejected, $label); } + check(Security::validMutation('POST', 'secret', 'secret'), '接受有效 POST 令牌'); + foreach ([['GET', 'secret', 'secret'], ['POST', '', ''], ['POST', 'secret', null], ['POST', 'secret', ['secret']], ['POST', 'secret', 'wrong']] as $case) { + check(!Security::validMutation(...$case), '拒绝 GET、不可用、缺失、数组或错误令牌'); + } + check(Security::validKey(str_repeat('a', 32)) && !Security::validKey('../bad') && !Security::validKey(str_repeat('a', 65)), '上传标识格式及长度有界'); + $db = \Typecho\Db::$instance = new \Typecho\Db(); + $db->query('CREATE TABLE admin_contents (cid INTEGER PRIMARY KEY, title TEXT, slug TEXT, created INTEGER, modified INTEGER, text TEXT, authorId INTEGER, type TEXT, status TEXT, allowComment TEXT, allowPing TEXT, allowFeed TEXT, template TEXT, password TEXT)'); + $db->query('CREATE TABLE admin_fields (cid INTEGER, name TEXT, type TEXT, str_value TEXT, PRIMARY KEY(cid,name))'); + $db->query('CREATE TABLE admin_infinitytime_images (id INTEGER PRIMARY KEY, cid INTEGER, sort INTEGER, full TEXT, exif TEXT, upload_key TEXT, upload_fingerprint TEXT, UNIQUE(cid,upload_key))'); + $draft = Workflow::draft(7, ['title' => 'Draft A'], str_repeat('a', 32)); + $cid = (int)$draft['cid']; + check($draft['status'] === 'draft', '新建图集先保存草稿'); + $same = Workflow::draft(7, ['title' => 'Changed title after lost response'], str_repeat('a', 32)); + check((int)$same['cid'] === $cid && $same['replayed'], '重试创建复用已保存图集'); + $other = Workflow::draft(8, ['title' => 'Other user'], str_repeat('a', 32)); + check((int)$other['cid'] !== $cid, '操作标识按创建者隔离'); + check(Repository::album($cid, 8, false) === null, '贡献者不能读取其他作者图集'); + check(Repository::album($cid, 8, true) !== null, '管理员可编辑真实图集'); + $db->query("INSERT INTO admin_contents (cid,title,created,authorId,type,status) VALUES (500,'Ordinary post',1,7,'post','publish'),(501,'Fake page',1,7,'page','publish')"); + Repository::setField(501, Repository::MARKER, '1'); + check(Repository::album(500, 7, true) === null && Repository::album(501, 7, true) === null && Repository::album(999, 7, true) === null, '管理员仍拒绝普通文章、页面和不存在标识'); + rejects(static function () use ($cid) { Workflow::publish($cid, 7, false, true); }, '空草稿不可发布'); + $tmp = tempnam(sys_get_temp_dir(), 'infinity-admin-'); file_put_contents($tmp, 'test image bytes'); + $file = ['name' => 'good.jpg', 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => 16]; + try { + $readsBeforeUpload = count($db->reads); + $row = Workflow::upload($cid, 7, false, $file, ['title' => 'A'], str_repeat('1', 32)); + check($row['rowId'] > 0 && !$row['replayed'], '单张上传持久化图片行'); + $uploadQueries = array_slice($db->reads, $readsBeforeUpload); + check(count(array_filter($uploadQueries, static function ($sql) { return strpos(str_replace('"', '', $sql), 'SELECT id FROM admin_infinitytime_images') !== false && strpos($sql, 'LIMIT 1') !== false; })) > 0, '上传历史保护仅查询有无记录,不统计整相册'); + check(Images::$lastOptions['keep_original'] === true && Images::$lastOptions['pano_quality'] === 92 && Images::$lastOptions['pano_width'] === 0, '保留原图与全景设置'); + check(Repository::field($cid, 'device') === 'Camera Test', '保留 EXIF 设备回填'); + $replay = Workflow::upload($cid, 7, false, $file, ['title' => 'A'], str_repeat('1', 32)); + check($replay['rowId'] === $row['rowId'] && $replay['replayed'] && Images::$ingested === 1, '重复重试不重新编码或插入'); + rejects(static function () use ($cid, $file) { Workflow::upload($cid, 7, false, $file, ['title' => 'Changed'], str_repeat('1', 32)); }, '同一标识对应不同元数据时拒绝'); + file_put_contents($tmp, 'different image bytes'); + rejects(static function () use ($cid, $file) { Workflow::upload($cid, 7, false, $file, ['title' => 'A'], str_repeat('1', 32)); }, '同一标识对应不同文件内容时拒绝'); + file_put_contents($tmp, 'test image bytes'); + rejects(static function () use ($cid, $file) { Workflow::upload($cid, 8, false, $file, [], str_repeat('2', 32)); }, '其他作者不可上传'); + $bad = $file; $bad['name'] = 'bad.heic'; + rejects(static function () use ($cid, $bad) { Workflow::upload($cid, 7, false, $bad, [], str_repeat('2', 32)); }, '单图转换失败不影响其他图片'); + check((Repository::counts([$cid])[$cid] ?? 0) === 1, '单项失败后成功图片保留'); + Images::$failSync = true; + rejects(static function () use ($cid, $file) { Workflow::upload($cid, 7, false, $file, [], str_repeat('3', 32)); }, '入库后字段同步失败可见'); + $before = Images::$ingested; + Images::$failSync = false; + $repair = Workflow::upload($cid, 7, false, $file, [], str_repeat('3', 32)); + check($repair['replayed'] && Images::$ingested === $before && (Repository::counts([$cid])[$cid] ?? 0) === 2, '响应丢失或失败后重放修复字段且不重复入库'); + Images::$busy = true; + rejects(static function () use ($cid, $file) { Workflow::upload($cid, 7, false, $file, [], str_repeat('4', 32)); }, '锁忙时在处理图片前拒绝'); + check(Images::$ingested === $before, '锁忙请求不处理文件'); + Images::$busy = false; + rejects(static function () use ($cid) { Workflow::publish($cid, 7, false); }, '贡献者不能绕过发布审核'); + Workflow::publish($cid, 7, false, true); + check(Repository::album($cid, 7, false)['status'] === 'publish', '明确发布操作改变状态'); + rejects(static function () use ($cid, $file) { Workflow::upload($cid, 7, false, $file, [], str_repeat('4', 32)); }, '贡献者不能追加到已发布图集'); + Workflow::upload($cid, 7, false, $file, [], str_repeat('4', 32), true); + check(Repository::album($cid, 7, false)['status'] === 'publish' && (Repository::counts([$cid])[$cid] ?? 0) === 3, '追加保留原图集身份及发布状态'); + $legacy = Workflow::draft(7, ['title' => 'Legacy album'], str_repeat('l', 32)); + Repository::setField((int)$legacy['cid'], 'img', "/old-a.webp\n/old-b.webp"); + $before = Images::$ingested; + rejects(static function () use ($legacy, $file) { Workflow::upload((int)$legacy['cid'], 7, false, $file, [], str_repeat('5', 32)); }, '历史图集追加在文件处理前拒绝'); + check(Images::$ingested === $before && Repository::field((int)$legacy['cid'], 'img') === "/old-a.webp\n/old-b.webp", '历史图集图片保持完整'); + foreach ($db->operations as $operation) { + if (in_array($operation['sql'], ['BEGIN', 'COMMIT', 'ROLLBACK'], true) || strpos($operation['sql'], 'SELECT ') === 0) { + check($operation['op'] === \Typecho\Db::WRITE && !$operation['object'], '事务与写流程读取使用预处理后的 SQL 主库连接'); + } + } + for ($i = 0; $i < 47; $i++) { Workflow::draft(7, ['title' => 'Search item ' . $i], str_pad((string)$i, 32, 'z', STR_PAD_LEFT)); } + Workflow::draft(7, ['title' => 'Literal %_! quote\''], str_repeat('q', 32)); + $db->reads = []; + $page = Repository::page(7, false, 2, 'Search item'); + check(count($page['items']) === 20 && $page['total'] === 47 && $page['pages'] === 3 && $page['page'] === 2, '支持搜索且分页有界'); + $queries = implode("\n", $db->reads); + check(strpos($queries, 'LIMIT 20 OFFSET 20') !== false && strpos($queries, 'COUNT(*) AS image_count') !== false, 'SQL 摘要查询有界并使用聚合计数'); + check(strpos($queries, 'exif') === false && strpos($queries, 'SELECT *') === false, '列表不加载图片完整数据'); + check(Repository::page(7, false, 1, "%_! quote'")['total'] === 1, '搜索通配符及引号安全转义'); + check(Repository::page(8, false)['total'] === 1, '列表限制为贡献者自己的图集'); + $db->adapter = 'Pdo_Pgsql'; + $db->reads = []; + check(Repository::album($cid, 7, false) !== null, 'PostgreSQL 权限查询 SQL 可执行'); + check(strpos($db->reads[0], 'c."authorId"') !== false && strpos($db->reads[0], 'c.authorId') === false, 'PostgreSQL 驼峰列名正确引用'); + $db->reads = []; + Repository::draftForOperation(7, str_repeat('a', 32)); + check(strpos($db->reads[0], 'c."authorId"') !== false, 'PostgreSQL 操作查询正确引用作者列名'); + echo "后台流程:$checks 项检查通过\n"; + } finally { unlink($tmp); } +} diff --git a/tests/data-saver.cjs b/tests/data-saver.cjs new file mode 100644 index 0000000..97f8560 --- /dev/null +++ b/tests/data-saver.cjs @@ -0,0 +1,39 @@ +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const source = fs.readFileSync(require.resolve('../usr/themes/InfinityTime/assets/js/data-saver.js'), 'utf8'); +function run(saved, network, blocked = false) { + const listeners = {}, networkListeners = {}, changes = []; + const control = { value: '', parentNode: { hidden: true }, addEventListener(type, fn) { this[type] = fn; } }; + const status = {}; + const document = { hidden: false, getElementById: id => id.endsWith('-status') ? status : control }; + const connection = network && { ...network, addEventListener(type, fn) { networkListeners[type] = fn; } }; + const localStorage = { getItem() { if (blocked) throw Error('blocked'); return saved; }, setItem(key, value) { if (blocked) throw Error('blocked'); saved = value; } }; + const window = { addEventListener(type, fn) { listeners[type] = fn; }, dispatchEvent(event) { changes.push(event.detail); } }; + vm.runInNewContext(source, { document, window, navigator: { connection }, localStorage, CustomEvent: class { constructor(type, options) { this.detail = options.detail; } } }); + return { window, document, control, status, changes, connection, listeners, networkListeners, select(value) { control.value = value; control.change(); } }; +} +for (const network of [null, {}, { saveData: false, effectiveType: '4g' }]) { + const h = run(null, network); + assert.equal(h.window.InfinityDataSaver.isEnabled(), false); + assert.equal(h.control.parentNode.hidden, false); + h.select('on'); assert.equal(h.window.InfinityDataSaver.allowBackground(), false); + h.select('off'); assert.equal(h.window.InfinityDataSaver.allowBackground(), true); + h.document.hidden = true; assert.equal(h.window.InfinityDataSaver.allowBackground(), false); +} +for (const network of [{ saveData: true }, { effectiveType: '2g' }, { effectiveType: 'slow-2g' }]) { + const h = run('auto', network); + assert.equal(h.window.InfinityDataSaver.isEnabled(), true); + h.select('off'); assert.equal(h.window.InfinityDataSaver.isEnabled(), false, '显式正常模式覆盖系统省流'); + h.select('auto'); assert.equal(h.window.InfinityDataSaver.isEnabled(), true); +} +const blocked = run('off', { saveData: true }, true); +blocked.select('off'); assert.equal(blocked.window.InfinityDataSaver.isEnabled(), false, '存储拒绝不影响当前选择'); +const h = run('on', {}); +h.listeners.storage({ key: 'unrelated', newValue: 'off' }); assert.equal(h.window.InfinityDataSaver.isEnabled(), true); +h.listeners.storage({ key: 'InfinityTime.dataSaver', newValue: 'off' }); assert.equal(h.window.InfinityDataSaver.isEnabled(), false); +h.connection.saveData = true; h.select('auto'); h.networkListeners.change(); assert.equal(h.window.InfinityDataSaver.isEnabled(), true); +h.listeners.storage({ key: null, newValue: null }); assert.equal(h.control.value, 'auto'); +assert.equal(run('invalid', {}).control.value, 'auto'); +console.log('省流策略:系统建议、显式覆盖、存储拒绝、跨页同步和后台暂停通过'); diff --git a/tests/edit-workflow.php b/tests/edit-workflow.php new file mode 100644 index 0000000..25c619f --- /dev/null +++ b/tests/edit-workflow.php @@ -0,0 +1,209 @@ + '编辑原子性', 'device' => '旧设备', 'tags' => '旧标签', 'address' => '旧地点'], 'draft-edit-workflow'); + $cid = (int)$draft['cid']; + $content = static function (int $id) use ($db): array { + return AdminRepository::readRow($db->select()->from('table.contents')->where('cid = ?', $id), true); + }; + $snapshot = fields($cid); + // 复现旧 setField 的 DELETE/INSERT 分散写:插入失败后旧值确实丢失。 + // 仅操作一次性 fixture,不调用或修改生产实现。 + failureTrigger(true); + try { + injectedFailure(static function () use ($db, $cid): void { + Database::query($db->delete('table.fields')->where('cid = ?', $cid)->where('name = ?', 'tags')); + Database::query($db->insert('table.fields')->rows(['cid' => $cid, 'name' => 'tags', 'type' => 'str', 'str_value' => '失败新值'])); + }, '旧分散字段写入的插入故障能够复现'); + } finally { failureTrigger(false); } + check(AdminRepository::field($cid, 'tags') === '', '旧 DELETE/INSERT 流程故障导致旧标签丢失'); + AdminRepository::setField($cid, 'tags', '旧标签'); + check(fields($cid) === $snapshot, '复现后恢复 fixture 基线'); + + failureTrigger(true); + try { + injectedFailure(static function () use ($cid): void { AdminRepository::setField($cid, 'tags', '新标签'); }, '原子单字段插入故障'); + check(fields($cid) === $snapshot, '原子单字段失败保留全部旧字段'); + injectedFailure(static function () use ($cid): void { + AdminRepository::setFields($cid, ['device' => '新设备', 'tags' => '新标签', 'location' => '新地点']); + }, '多字段第二次插入故障'); + check(fields($cid) === $snapshot, '多字段中途失败恢复此前字段与待替换字段'); + $oldContent = $content($cid); + injectedFailure(static function () use ($cid): void { + AdminWorkflow::updateAlbum($cid, 7, false, false, ['title' => '新标题', 'device' => '新设备', 'tags' => '新标签', 'address' => '新地点']); + }, '图集标题更新后字段故障'); + check($content($cid) === $oldContent && fields($cid) === $snapshot, '标题、修改时间及三字段必须共同回滚'); + } finally { failureTrigger(false); } + rejected(static function () use ($cid): void { + AdminWorkflow::updateAlbum($cid, 8, false, true, ['title' => '越权标题']); + }, \DomainException::class, '图集编辑拒绝其他作者'); + check($content($cid) === $oldContent && fields($cid) === $snapshot, '越权图集编辑不改动记录'); + + // BEGIN 及调用方 SAVEPOINT:成功与失败均不可提交调用方事务。 + foreach (['BEGIN', 'SAVEPOINT caller_edit'] as $outer) { + Database::query('BEGIN'); + if ($outer !== 'BEGIN') { Database::query($outer); } + try { + AdminRepository::setField($cid, 'device', '外层设备'); + $nested = AdminWorkflow::draft(7, ['title' => '外层草稿', 'tags' => 'outer'], 'draft-nested-edit-' . ($outer === 'BEGIN' ? 'begin' : 'savepoint')); + check($content((int)$nested['cid']) !== [], '外层事务内草稿可见'); + check($db->fetchRow($db->select()->from('table.contents')->where('cid = ?', (int)$nested['cid'])) === null, '嵌套草稿不得提交到读连接'); + check(AdminRepository::draftForOperation(7, 'draft-nested-edit-' . ($outer === 'BEGIN' ? 'begin' : 'savepoint')) !== null, '嵌套草稿操作标识从主库可读'); + AdminWorkflow::updateAlbum($cid, 7, false, false, ['title' => '未提交标题', 'device' => '未提交设备', 'tags' => '未提交标签', 'address' => '未提交地点']); + check($content($cid)['title'] === '未提交标题', '嵌套图集编辑在主库可见'); + check(AdminRepository::album($cid, 7, false)['title'] === $oldContent['title'], '嵌套图集编辑不提前提交'); + if ($outer !== 'BEGIN') { + Database::query('ROLLBACK TO SAVEPOINT caller_edit'); + check($content((int)$nested['cid']) === [] && $content($cid) === $oldContent, '调用方保存点回滚撤销草稿和编辑'); + Database::query('RELEASE SAVEPOINT caller_edit'); + } + } finally { Database::query('ROLLBACK'); } + check($content((int)$nested['cid']) === [] && fields($cid) === $snapshot && $content($cid) === $oldContent, '外层回滚撤销全部嵌套草稿和编辑'); + } + if ($dialect === 'SQLite') { + // SQLite 可以由 SAVEPOINT 自身开启事务;内层不能 RELEASE 掉调用方边界。 + Database::query('SAVEPOINT caller_standalone'); + try { + $standalone = AdminWorkflow::draft(7, ['title' => '独立保存点草稿'], 'draft-standalone-savepoint'); + check($content((int)$standalone['cid']) !== [], '独立保存点内创建草稿可见'); + check($db->fetchRow($db->select()->from('table.contents')->where('cid = ?', (int)$standalone['cid'])) === null, '独立保存点草稿不得提前提交'); + Database::query('ROLLBACK TO SAVEPOINT caller_standalone'); + check($content((int)$standalone['cid']) === [], '调用方独立保存点仍能回滚草稿'); + } finally { Database::query('RELEASE SAVEPOINT caller_standalone'); } + } + failureTrigger(true); + Database::query('BEGIN'); + try { + AdminRepository::setField($cid, 'device', '外层故障前写入'); + injectedFailure(static function (): void { AdminWorkflow::draft(7, ['title' => '嵌套失败草稿', 'tags' => 'fail'], 'draft-nested-edit-failure'); }, '嵌套草稿字段故障'); + check(AdminRepository::field($cid, 'device', true) === '外层故障前写入', '失败草稿不得回滚调用方既有写入'); + check(AdminRepository::draftForOperation(7, 'draft-nested-edit-failure') === null, '嵌套失败草稿无操作标识残留'); + injectedFailure(static function () use ($cid): void { + AdminWorkflow::updateAlbum($cid, 7, false, false, ['title' => '失败标题', 'device' => '失败设备', 'tags' => 'fail']); + }, '嵌套图集编辑字段故障'); + check($content($cid) === $oldContent && AdminRepository::field($cid, 'device', true) === '外层故障前写入', '嵌套编辑失败恢复自身修改而保留调用方写入'); + } finally { Database::query('ROLLBACK'); failureTrigger(false); } + check(fields($cid) === $snapshot, '失败嵌套工作流后外层仍可回滚'); + + $one = photo($cid, ['title' => '第一张', 'full' => '/fixture/one.webp'], 10); + $two = photo($cid, ['title' => '第二张', 'full' => '/fixture/two.webp'], 20); + $three = photo($cid, ['title' => '第三张', 'full' => '/fixture/three.webp'], 30); + $other = AdminWorkflow::draft(8, ['title' => '排序隔离'], 'draft-edit-other-album'); + $otherId = photo((int)$other['cid']); + ImageRepository::syncPostFields($cid); + $images = ImageRepository::rowsFor($cid); + $snapshot = fields($cid); + check(!ImageRepository::editImage($one, (int)$other['cid'], '错误图集', '错描述', '错地点'), '编辑图片必须匹配图集 ID'); + check(ImageRepository::rowsFor($cid) === $images && fields($cid) === $snapshot, '错误图集不能改变图片与公共字段'); + failureTrigger(true, 'titles'); + try { + injectedFailure(static function () use ($one, $cid): void { ImageRepository::editImage($one, $cid, '新图片标题', '新描述', '新地点'); }, '图片更新后公共字段插入失败'); + check(ImageRepository::rowsFor($cid) === $images && fields($cid) === $snapshot, '图片更新及公共字段全部回滚'); + injectedFailure(static function () use ($one, $two, $three, $cid): void { ImageRepository::sortImages($cid, [$three, $one, $two]); }, '排序后公共字段插入失败'); + check(ImageRepository::rowsFor($cid) === $images && fields($cid) === $snapshot, '公共字段故障恢复全部图片顺序'); + } finally { failureTrigger(false); } + foreach ([[$one, $two], [$one, $two, $two], [$one, $two, $otherId], [$one, $two, $three, $otherId], [], [$one, $two, (string)$three], [$one, $two, 0]] as $invalid) { + rejected(static function () use ($cid, $invalid): void { ImageRepository::sortImages($cid, $invalid); }, \DomainException::class, '排序拒绝不完整、重复、跨图集或非法 ID 清单'); + check(ImageRepository::rowsFor($cid) === $images && fields($cid) === $snapshot, '非法排序不改动任何行及公共字段'); + } + + // 在反向排序的第二个 UPDATE 注入失败,确保此前第一行 UPDATE 也撤销。 + $updateTrigger = static function (bool $enabled) use ($prefix, $dialect, $two): void { + $name = $prefix . 'reject_edit_update'; + $fn = $prefix . 'reject_edit_update_fn'; + if (!$enabled) { + Database::query('DROP TRIGGER IF EXISTS ' . $name . ($dialect === 'Pgsql' ? ' ON ' . table('infinitytime_images') : '')); + if ($dialect === 'Pgsql') { Database::query('DROP FUNCTION IF EXISTS ' . $fn . '()'); } + return; + } + if ($dialect === 'SQLite') { + $sql = 'CREATE TRIGGER ' . $name . ' BEFORE UPDATE ON ' . table('infinitytime_images') . " WHEN NEW.id = $two BEGIN SELECT RAISE(ABORT, 'Integration failure'); END"; + } elseif ($dialect === 'Mysql') { + $sql = 'CREATE TRIGGER ' . $name . ' BEFORE UPDATE ON ' . table('infinitytime_images') . " FOR EACH ROW BEGIN IF NEW.id = $two THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Integration failure'; END IF; END"; + } else { + Database::query('CREATE FUNCTION ' . $fn . "() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.id = $two THEN RAISE EXCEPTION 'Integration failure'; END IF; RETURN NEW; END $$"); + $sql = 'CREATE TRIGGER ' . $name . ' BEFORE UPDATE ON ' . table('infinitytime_images') . ' FOR EACH ROW EXECUTE PROCEDURE ' . $fn . '()'; + } + Database::query($sql); + }; + $updateTrigger(true); + try { + injectedFailure(static function () use ($cid, $one, $two, $three): void { ImageRepository::sortImages($cid, [$three, $two, $one]); }, '排序第二行 UPDATE 故障'); + check(ImageRepository::rowsFor($cid) === $images && fields($cid) === $snapshot, '排序 UPDATE 中途失败撤销此前顺序写入'); + } finally { $updateTrigger(false); } + Database::query('BEGIN'); + try { + AdminRepository::setField($cid, 'device', '图片编辑外层设备'); + check(ImageRepository::editImage($one, $cid, '嵌套图片标题', '嵌套描述', '嵌套地点'), '嵌套图片编辑成功'); + ImageRepository::sortImages($cid, [$three, $one, $two]); + check(json_decode(AdminRepository::field($cid, 'photo_ids', true), true) === [$three, $one, $two], '嵌套排序与公共 ID 对齐'); + check(json_decode(AdminRepository::field($cid, 'titles', true), true)[1] === '嵌套图片标题', '嵌套排序保留已编辑标题'); + check(AdminRepository::field($cid, 'device') === '旧设备', '图片编辑与排序不得提交外层修改'); + } finally { Database::query('ROLLBACK'); } + check(ImageRepository::rowsFor($cid) === $images && fields($cid) === $snapshot, '外层回滚恢复图片编辑、排序及公共字段'); + $writerImages = static function (int $albumId) use ($db): array { + return AdminRepository::readAll($db->select()->from(ImageRepository::table())->where('cid = ?', $albumId) + ->order('sort', Db::SORT_ASC)->order('id', Db::SORT_ASC), true); + }; + $writerFields = static function (int $albumId) use ($db): array { + return AdminRepository::readAll($db->select()->from('table.fields')->where('cid = ?', $albumId)->order('name', Db::SORT_ASC), true); + }; + $imagesOnWriter = $writerImages($cid); + failureTrigger(true, 'titles'); + Database::query('BEGIN'); + try { + AdminRepository::setField($cid, 'device', '图片失败前设备'); + $fieldsOnWriter = $writerFields($cid); + injectedFailure(static function () use ($one, $cid): void { ImageRepository::editImage($one, $cid, '失败图片', '失败描述', '失败地点'); }, '嵌套图片编辑故障'); + check($writerImages($cid) === $imagesOnWriter && $writerFields($cid) === $fieldsOnWriter, '图片编辑失败后主库已恢复自身写入,不依赖外层回滚遮盖'); + injectedFailure(static function () use ($cid, $one, $two, $three): void { ImageRepository::sortImages($cid, [$three, $two, $one]); }, '嵌套图片排序故障'); + check($writerImages($cid) === $imagesOnWriter && $writerFields($cid) === $fieldsOnWriter, '排序失败后主库已恢复自身写入,保留调用方字段'); + check(ImageRepository::rowsFor($cid) === $images && AdminRepository::field($cid, 'device', true) === '图片失败前设备', '嵌套图片操作失败保留调用方事务'); + } finally { Database::query('ROLLBACK'); failureTrigger(false); } + check(fields($cid) === $snapshot, '图片失败后外层事务仍可完整回滚'); + + if ($dialect === 'Mysql') { + foreach (['fields', 'contents', 'infinitytime_images'] as $nonTransactional) { + Database::query('ALTER TABLE ' . table($nonTransactional) . ' ENGINE=MyISAM'); + try { + $operations = []; + if ($nonTransactional !== 'infinitytime_images') { + $operations[] = static function () use ($cid): void { AdminWorkflow::updateAlbum($cid, 7, false, false, ['title' => '不应写入', 'tags' => '不应写入']); }; + $operations[] = static function (): void { AdminWorkflow::draft(7, ['title' => '不应创建', 'tags' => '不应写入'], 'draft-edit-myisam-fail'); }; + } + if ($nonTransactional === 'fields') { + $operations[] = static function () use ($cid): void { AdminRepository::setField($cid, 'tags', '不应写入'); }; + $operations[] = static function () use ($cid): void { AdminRepository::setFields($cid, ['device' => '不应写入', 'tags' => '不应写入']); }; + } + if ($nonTransactional !== 'contents') { + $operations[] = static function () use ($cid, $one): void { ImageRepository::editImage($one, $cid, '不应写入', '', ''); }; + $operations[] = static function () use ($cid, $one, $two, $three): void { ImageRepository::sortImages($cid, [$three, $two, $one]); }; + } + foreach ($operations as $operation) { + $beforeCounts = [rowCount('contents'), rowCount('fields'), rowCount('infinitytime_images')]; + rejected($operation, \RuntimeException::class, $nonTransactional . ' 为 MyISAM 时写前拒绝'); + check($content($cid) === $oldContent && fields($cid) === $snapshot && ImageRepository::rowsFor($cid) === $images, 'MyISAM 拒绝后现有图集三表完全不变'); + check(AdminRepository::draftForOperation(7, 'draft-edit-myisam-fail') === null, 'MyISAM 拒绝后不产生草稿'); + check([rowCount('contents'), rowCount('fields'), rowCount('infinitytime_images')] === $beforeCounts, 'MyISAM 拒绝后无孤立文章或字段残留'); + } + } finally { Database::query('ALTER TABLE ' . table($nonTransactional) . ' ENGINE=InnoDB'); } + } + } + AdminWorkflow::updateAlbum($cid, 7, false, false, ['title' => '成功标题', 'device' => '成功设备', 'tags' => '成功标签', 'address' => '成功地点']); + check($content($cid)['title'] === '成功标题' && AdminRepository::field($cid, 'device') === '成功设备' + && AdminRepository::field($cid, 'tags') === '成功标签' && AdminRepository::field($cid, 'location') === '成功地点', '正常图集编辑共同提交标题及三字段'); + check(ImageRepository::editImage($one, $cid, "照片 O'Reilly ? 雨", '成功描述', '成功地点'), '正常图片编辑返回成功'); + ImageRepository::sortImages($cid, [$three, $one, $two]); + $edited = ImageRepository::rowsFor($cid)[1]; + check($edited['title'] === "照片 O'Reilly ? 雨" && $edited['desc'] === '成功描述' && $edited['address'] === '成功地点', '编辑成功保存图片行三项元数据'); + check(json_decode(AdminRepository::field($cid, 'descs'), true)[1] === '成功描述' && json_decode(AdminRepository::field($cid, 'addresses'), true)[1] === '成功地点', '编辑描述和地点也与公共字段对齐'); + check(array_map(static function (array $row): int { return (int)$row['id']; }, ImageRepository::rowsFor($cid)) === [$three, $one, $two], '完整清单正常提交精确顺序'); + check(json_decode(AdminRepository::field($cid, 'photo_ids'), true) === [$three, $one, $two] + && json_decode(AdminRepository::field($cid, 'titles'), true)[1] === "照片 O'Reilly ? 雨", '提交后的公共字段与新图片及顺序对齐'); +})(); diff --git a/tests/field-sync.php b/tests/field-sync.php new file mode 100644 index 0000000..46b2daa --- /dev/null +++ b/tests/field-sync.php @@ -0,0 +1,207 @@ +kind = $kind; $this->table = $table; $this->fields = $fields ?: ['*']; } + public function from($table): self { $this->table = $table; return $this; } + public function rows($rows): self { $this->rows = $rows; return $this; } + public function where($clause, ...$values): self { + foreach ($values as $value) { $clause = substr_replace($clause, $this->quoteValue($value), strpos($clause, '?'), 1); } + $this->where[] = '(' . $clause . ')'; return $this; + } + public function limit($limit): self { $this->limit = ' LIMIT ' . (int)$limit; return $this; } + public function order($column, $direction): self { $this->order .= ($this->order ? ',' : ' ORDER BY ') . '"' . $column . '" ' . $direction; return $this; } + public function quoteValue($value): string { $this->params[] = $value; return '#param:' . (count($this->params) - 1) . '#'; } + public function prepare(string $sql): string { + return preg_replace_callback('/#param:(\d+)#/', function ($match) { $value = $this->params[(int)$match[1]]; return $value === null ? 'NULL' : Db::get()->pdo->quote((string)$value); }, $sql); + } + public function __toString(): string { + $table = '"' . $this->table . '"'; + if ($this->kind === 'insert') { + $names = implode(',', array_map(static function ($name) { return '"' . $name . '"'; }, array_keys($this->rows))); + $values = implode(',', array_map(function ($value) { return $this->quoteValue($value); }, array_values($this->rows))); + $sql = "INSERT INTO $table ($names) VALUES ($values)"; + } elseif ($this->kind === 'update') { + $values = []; + foreach ($this->rows as $name => $value) { $values[] = '"' . $name . '" = ' . $this->quoteValue($value); } + $sql = "UPDATE $table SET " . implode(',', $values); + } elseif ($this->kind === 'delete') { $sql = "DELETE FROM $table"; } + else { $sql = 'SELECT ' . implode(',', $this->fields) . " FROM $table"; } + return $this->prepare($sql . ($this->where ? ' WHERE ' . implode(' AND ', $this->where) : '') . $this->order . $this->limit); + } + public function isInsert(): bool { return $this->kind === 'insert'; } + } + class Db { + public const SORT_ASC = 'ASC', WRITE = 2, READ = 1; + public static self $instance; + public \PDO $pdo; + public \PDO $replica; + public array $operations = []; + public string $adapter = 'Pdo_SQLite'; + public $beforeWrite = null; + public function __construct() { + $this->pdo = new \PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + $this->replica = new \PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + } + public static function get(): self { return self::$instance; } + public function getPrefix(): string { return 'sync_'; } + public function getAdapterName(): string { return $this->adapter; } + public function selectDb($op) { return $op === self::WRITE ? $this->pdo : $this->replica; } + public function select(...$fields): Query { return new Query('select', '', $fields); } + public function insert($table): Query { return new Query('insert', $table); } + public function delete($table): Query { return new Query('delete', $table); } + public function fetchRow($query) { return $this->query($query)->fetch(\PDO::FETCH_ASSOC); } + public function fetchAll($query): array { return $this->query($query)->fetchAll(\PDO::FETCH_ASSOC); } + public function query($query, $op = self::READ) { + if ($query instanceof \PDOStatement) { return $query; } + if ($query instanceof Query) { + // 真实 Db::query 覆盖传入的 WRITE;未编译的 SELECT 应故意去空副本失败。 + $op = preg_match('/^SELECT /', (string)$query) ? self::READ : self::WRITE; + } + $sql = (string)$query; + $this->operations[] = ['sql' => $sql, 'op' => $op, 'object' => is_object($query)]; + if ($this->beforeWrite && preg_match('/^(INSERT|DELETE)/', $sql)) { + $callback = $this->beforeWrite; + $this->beforeWrite = null; + $callback(); + } + return $this->selectDb($op)->query($sql); + } + } +} +namespace TypechoPlugin\InfinityTime { + class Plugin { + public static int $migrations = 0; + public static function migrateSchema(): void { self::$migrations++; throw new \RuntimeException('事务内禁止 DDL'); } + public static function log($message): void {} + } +} +namespace { + use TypechoPlugin\InfinityTime\Lib\ImageRepository as Repository; + require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php'; + require __DIR__ . '/../usr/plugins/InfinityTime/Lib/ImageRepository.php'; + $root = sys_get_temp_dir() . '/infinitytime-sync-' . bin2hex(random_bytes(8)); + mkdir($root, 0700); + define('__TYPECHO_ROOT_DIR__', $root); + define('__TYPECHO_UPLOAD_ROOT_DIR__', $root); + $db = \Typecho\Db::$instance = new \Typecho\Db(); + $pdo = $db->pdo; + $checks = 0; + function expect(bool $ok, string $message): void { + global $checks; + if (!$ok) { throw new \RuntimeException($message); } + $checks++; + } + function fields(): array { + global $pdo; + return $pdo->query('SELECT * FROM sync_fields ORDER BY cid,name')->fetchAll(\PDO::FETCH_ASSOC); + } + function failSync(): void { + $thrown = false; + try { Repository::syncPostFields(1); } + catch (\Throwable $e) { $thrown = strpos($e->getMessage(), 'injected failure') !== false; } + expect($thrown, '同步应传播中途写入错误'); + } + function failTrigger(bool $on, string $field = 'dims'): void { + global $pdo; + if ($on) { + $pdo->exec("CREATE TRIGGER fail_sync BEFORE INSERT ON sync_fields WHEN NEW.name = '$field' BEGIN SELECT RAISE(ABORT, 'injected failure'); END"); + } else { $pdo->exec('DROP TRIGGER fail_sync'); } + } + try { + $resource = $pdo->query('SELECT 1'); + expect(\TypechoPlugin\InfinityTime\Lib\Database::query($resource) === $resource, '已执行的 PDOStatement 句柄原样返回,不误调用 getAttribute(action)'); + $resource->closeCursor(); + $pdo->exec('CREATE TABLE sync_fields (cid INTEGER, name TEXT, type TEXT, str_value TEXT, int_value INTEGER DEFAULT 0, float_value REAL DEFAULT 0, PRIMARY KEY(cid,name))'); + $pdo->exec('CREATE TABLE sync_infinitytime_images (id INTEGER PRIMARY KEY, cid INTEGER, sort INTEGER, full TEXT, thumb TEXT, width INTEGER, height INTEGER, exif TEXT, created INTEGER)'); + $pdo->exec("INSERT INTO sync_infinitytime_images VALUES (1,1,0,'/new.webp','/new-thumb.webp',200,100,'{}',1704067200),(2,2,0,'/second.webp','/second-thumb.webp',100,100,'{}',1704067200)"); + foreach ([1,2] as $cid) { + foreach (['img','thumb','addresses','titles','descs','panos','dims','variants','exif','photo_ids','months','unrelated'] as $name) { + $q = $pdo->prepare('INSERT INTO sync_fields VALUES (?,?,?,?,?,?)'); + $q->execute([$cid,$name,'str','old-' . $name,11,1.25]); + } + } + $original = fields(); + failTrigger(true); + failSync(); + expect(fields() === $original, '独立事务中途失败必须完整保留所有旧字段及类型列'); + failTrigger(false); + $pdo->exec('BEGIN'); + $pdo->exec("UPDATE sync_fields SET str_value = 'caller-before' WHERE cid=1 AND name='unrelated'"); + $outer = fields(); + failTrigger(true); + failSync(); + expect(fields() === $outer, '嵌套失败不得回滚调用方此前的修改'); + failTrigger(false); + $pdo->exec("UPDATE sync_fields SET str_value = 'caller-after' WHERE cid=2 AND name='unrelated'"); + $pdo->exec('COMMIT'); + expect($pdo->query("SELECT str_value FROM sync_fields WHERE cid=2 AND name='unrelated'")->fetchColumn() === 'caller-after', '失败后外层事务应可继续并提交'); + + $beforeSuccess = fields(); + $pdo->exec('BEGIN'); + Repository::syncPostFields(1); + expect($pdo->query("SELECT str_value FROM sync_fields WHERE cid=1 AND name='img'")->fetchColumn() === '/new.webp', '同步使用主库图片行'); + $pdo->exec('ROLLBACK'); + expect(fields() === $beforeSuccess, '成功同步不得提前提交外层 BEGIN'); + + $pdo->exec('SAVEPOINT caller'); + Repository::syncPostFields(1); + Repository::syncPostFields(2); + $pdo->exec('ROLLBACK TO SAVEPOINT caller'); + $pdo->exec('RELEASE SAVEPOINT caller'); + expect(fields() === $beforeSuccess, '连续同步不能越过调用方 SAVEPOINT 边界'); + + $db->beforeWrite = static function (): void { + Repository::syncPostFields(2); + throw new \RuntimeException('injected failure'); + }; + failSync(); + expect(fields() === $beforeSuccess, '重入同步独立命名保存点,外层失败回滚内部成功同步'); + + Repository::syncPostFields(1); + $afterSuccess = fields(); + expect($afterSuccess !== $beforeSuccess, '独立同步成功应持久化'); + expect($pdo->query("SELECT str_value FROM sync_fields WHERE cid=1 AND name='dims'")->fetchColumn() === '["200x100"]', '字段内容与图片顺序一致'); + expect($pdo->query("SELECT str_value FROM sync_fields WHERE cid=1 AND name='unrelated'")->fetchColumn() === 'caller-before', '同步不改无关字段'); + $pdo->exec('ALTER TABLE sync_infinitytime_images ADD COLUMN title TEXT'); + $badTitle = $pdo->prepare('UPDATE sync_infinitytime_images SET title=? WHERE cid=1'); + $badTitle->execute(["bad-\xff"]); + $badJson = false; + try { Repository::syncPostFields(1); } catch (\JsonException $e) { $badJson = true; } + expect($badJson && fields() === $afterSuccess, 'JSON编码错误也须完整回滚,不能把false写入字段'); + $pdo->exec('DELETE FROM sync_infinitytime_images WHERE cid=1'); + Repository::syncPostFields(1); + expect(fields() === $afterSuccess, '历史字段无图片行时保持原样'); + failTrigger(true, 'thumb'); + $clearFailed = false; + try { Repository::syncPostFields(1, true); } catch (\Throwable $e) { $clearFailed = true; } + expect($clearFailed && fields() === $afterSuccess, '清空字段中途失败也须保留全部旧字段'); + failTrigger(false); + Repository::syncPostFields(1, true); + $empty = $pdo->query("SELECT name,str_value FROM sync_fields WHERE cid=1 AND name <> 'unrelated' ORDER BY name")->fetchAll(\PDO::FETCH_ASSOC); + expect($empty === [['name'=>'img','str_value'=>''],['name'=>'thumb','str_value'=>'']], '显式清空保留空图集标记并清除旧元数据'); + expect(\TypechoPlugin\InfinityTime\Plugin::$migrations === 0, '同步不得触发 schema DDL 或隐式提交'); + foreach ($db->operations as $operation) { + expect($operation['op'] === \Typecho\Db::WRITE && !$operation['object'], '同步的查询和事务语句全部在主库执行'); + } + $db->adapter = 'unknown'; + $beforeUnsupported = fields(); + $rejected = false; + try { Repository::syncPostFields(2); } catch (\RuntimeException $e) { $rejected = strpos($e->getMessage(), '不支持') !== false; } + expect($rejected && fields() === $beforeUnsupported, '未知适配器安全拒绝且不修改字段'); + echo "字段同步原子性、嵌套事务与主库路由检查通过($checks 项)\n"; + } finally { + Repository::unlockMedia(); + @unlink($root . '/.infinitytime-media.lock'); + @rmdir($root); + } +} diff --git a/tests/gallery-browser.cjs b/tests/gallery-browser.cjs new file mode 100644 index 0000000..4dd5642 --- /dev/null +++ b/tests/gallery-browser.cjs @@ -0,0 +1,175 @@ +// Run with Playwright installed: node tests/gallery-browser.cjs +// All page/assets/image requests are intercepted; no live site is contacted. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const playwright = require('playwright'); +const browserName = process.env.PLAYWRIGHT_BROWSER || 'chromium'; +assert.ok(['chromium', 'firefox', 'webkit'].includes(browserName), 'valid PLAYWRIGHT_BROWSER'); +const theme = path.resolve(__dirname, '../usr/themes/InfinityTime'); +const origin = 'https://gallery.test'; +const url = name => origin + '/' + name + '.webp'; +const attr = value => JSON.stringify(value).replace(/&/g, '&').replace(/'/g, '''); +function card(id, images, titles, variants = [], panos = []) { + return `
Album ${id}

Album ${id}

`; +} +const fixture = ` + +
+${card('a', [url('a'), url('plain'), url('pano')], ['Sunrise', 'Plain landscape', 'Panorama'], [{ w: 2400, webp: [url('a')], avif: [origin + '/a.avif'] }], [0, 0, 1])} +${card('b', [url('b'), url('b2')], ['', 'Blue sky'])} +
+ +${['jquery.min', 'jquery.poptrox.min', 'browser.min', 'breakpoints.min', 'lightbox', 'main'].map(name => ``).join('')} +`; +const image = ''; +(async () => { + const browser = await playwright[browserName].launch({ headless: true, ...(browserName === 'chromium' && process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); + try { + for (const viewport of [{ width: 1280, height: 900 }, { width: 390, height: 844 }]) { + const context = await browser.newContext({ viewport }); + await context.addInitScript(() => { + // Isolate optional-API handling from a CI machine's actual GPU support. + window.ResizeObserver = undefined; + const original = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function(type, ...args) { + if (/webgl/.test(type)) return { getExtension() { return null; } }; + return original.call(this, type, ...args); + }; + }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', err => errors.push(err.message)); + const releaseSlow = []; + await page.route('**/*', async route => { + const pathname = new URL(route.request().url()).pathname; + if (pathname === '/') return route.fulfill({ contentType: 'text/html', body: fixture }); + if (pathname.startsWith('/assets/')) { + const filename = path.join(theme, pathname.slice(1)); + if (fs.existsSync(filename)) return route.fulfill({ path: filename }); + return route.fulfill({ status: 404, body: '' }); + } + if (pathname === '/slow.webp') await new Promise(resolve => { releaseSlow.push(resolve); }); + return route.fulfill({ contentType: 'image/svg+xml', body: image }); + }); + await page.goto(origin); + await page.waitForFunction(() => typeof window.__rebindPoptrox === 'function'); + async function ready(name) { + await page.waitForFunction(expected => { + const p = document.querySelector('.poptrox-popup'), img = p && p.querySelector('.pic img'); + return img && img.getAttribute('src') === expected && img.complete && img.naturalWidth > 0 && !p.classList.contains('loading'); + }, url(name)); + await page.waitForTimeout(500); // Poptrox's 420ms fade completes after loading. + } + async function close() { + await page.keyboard.press('Escape'); + await page.waitForFunction(() => getComputedStyle(document.querySelector('.poptrox-overlay')).display === 'none'); + } + async function open(id, key = 'Enter') { + await page.locator('#' + id).focus(); + await page.keyboard.press(key); + await page.waitForFunction(() => document.querySelector('.poptrox-overlay').getAttribute('aria-hidden') === 'false'); + } + const handlerCount = () => page.evaluate(() => { + const events = jQuery._data(window, 'events') || {}; + return Object.values(events).flat().filter(h => /^poptrox\d+$/.test(h.namespace || '')).length; + }); + const initialHandlers = await handlerCount(); + if (initialHandlers !== 3) { + console.error('Poptrox initialization diagnostics', await page.evaluate(() => ({ + jquery: jQuery.fn.jquery, + handlers: Object.entries(jQuery._data(window, 'events') || {}).flatMap(([type, handlers]) => handlers.map(h => ({ type, namespace: h.namespace }))), + hasDestroy: typeof document.querySelector('#main')._poptroxDestroy, + patchedVendor: jQuery.fn.poptrox.toString().includes('eventNamespace'), + cards: document.querySelectorAll('#main .thumb > a.image').length, + dialogs: document.querySelectorAll('.poptrox-overlay').length + })), errors); + } + assert.equal(initialHandlers, 3, 'one instance has resize, orientation and keyboard handlers'); + assert.equal(await page.locator('#a').getAttribute('role'), 'button'); + assert.equal(await page.locator('#a').getAttribute('tabindex'), '0'); + await open('a'); await ready('a'); + assert.equal(await page.locator('.poptrox-overlay').getAttribute('role'), 'dialog'); + assert.equal(await page.locator('.poptrox-overlay').getAttribute('aria-modal'), 'true'); + assert.ok(await page.locator('#wrapper').evaluate(el => el.inert)); + assert.match(await page.locator('.pic img').getAttribute('alt'), /Sunrise/); + assert.ok(await page.locator('.pic img').evaluate(el => el.currentSrc.endsWith('/a.avif'))); + for (let i = 0; i < 10; i++) { + await page.keyboard.press(i % 2 ? 'Shift+Tab' : 'Tab'); + assert.ok(await page.evaluate(() => document.querySelector('.poptrox-overlay').contains(document.activeElement)), 'focus remains in modal'); + } + await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight'); await ready('plain'); + assert.equal(await page.locator('.pic picture').count(), 0, 'AVIF to unvarianted image removes picture'); + assert.ok(await page.locator('.pic img').evaluate(el => el.currentSrc.endsWith('/plain.webp'))); + assert.match(await page.locator('.pic img').getAttribute('alt'), /Plain landscape/); + await page.keyboard.press('ArrowLeft'); await ready('a'); + // Jump directly from AVIF to panorama on the reused image, exercising that fallback too. + await page.locator('.pic img').evaluate((el, src) => el.setAttribute('src', src), url('pano')); + await ready('pano'); + assert.equal(await page.locator('.pic picture').count(), 0, 'AVIF to panorama removes picture'); + await page.waitForSelector('.pp-pano-viewer'); + await page.waitForTimeout(500); + assert.equal(await page.evaluate(() => window.__viewerCalls), 1, 'missing ResizeObserver must not recreate viewers on each poll'); + assert.equal(await page.locator('.pp-pano-viewer').count(), 1); + await page.keyboard.press('ArrowRight'); await ready('b'); + assert.equal(await page.locator('.pp-pano-viewer').count(), 0, 'optional observer absence does not prevent cleanup'); + assert.equal(await page.evaluate(() => window.__viewerDestroys), 1); + assert.match(await page.locator('.pic img').getAttribute('alt'), /Album b/); + await page.locator('.pp-mnav-next').focus(); await page.keyboard.press('Space'); await ready('b2'); + assert.match(await page.locator('.pic img').getAttribute('alt'), /Blue sky/, 'empty first title preserves per-image alignment'); + if (viewport.width < 900) { + const handle = page.locator('.exif-dock-handle'); + await handle.click(); + assert.equal(await handle.getAttribute('aria-expanded'), 'true'); + assert.equal(await page.locator('.poptrox-overlay').getAttribute('aria-hidden'), 'false', 'drawer click does not close modal'); + await handle.focus(); await page.keyboard.press('Space'); + assert.equal(await handle.getAttribute('aria-expanded'), 'false', 'drawer keyboard activation remains local'); + assert.equal(await page.locator('.pic img').getAttribute('src'), url('b2'), 'Space does not trigger Poptrox previous'); + } + await close(); + assert.equal(await page.evaluate(() => document.activeElement.id), 'a', 'restores original gallery opener'); + assert.equal(await page.locator('#wrapper').getAttribute('inert'), null); + assert.equal(await page.locator('#preexisting-hidden').getAttribute('inert'), '', 'preserves preexisting inert state'); + await open('a', 'Space'); await ready('a'); + // Closing during the custom image transition cannot resurrect the old image. + await page.keyboard.press('ArrowRight'); await close(); + await open('b'); await ready('b'); + assert.equal(await page.locator('.pic img').getAttribute('src'), url('b')); + await close(); + // Cancel the vendor opening fade before its queued switch callback runs. + await open('a'); await close(); + await page.waitForTimeout(700); + assert.equal(await page.locator('.poptrox-popup').isVisible(), false); + assert.equal(await page.locator('.pic img').count(), 0); + for (let i = 0; i < 4; i++) await page.evaluate(() => window.__rebindPoptrox()); + assert.equal(await handlerCount(), initialHandlers, 'rebind does not accumulate window handlers'); + assert.equal(await page.locator('.pp-mobile-nav').count(), 1, 'shared controls survive without duplication'); + await open('a'); await ready('a'); + await page.evaluate(html => { + document.querySelector('#main').insertAdjacentHTML('beforeend', html); + window.__rebindPoptrox(); + }, card('new', [url('new')], ['New card'])); + assert.equal(await page.locator('#new').getAttribute('role'), null, 'open dialog defers rebind'); + await close(); + await page.waitForFunction(() => document.querySelector('#new').getAttribute('role') === 'button'); + await open('new'); await ready('new'); await close(); + assert.equal(await handlerCount(), initialHandlers); + // Late load completion after Close must not restart Poptrox or show controls. + await page.evaluate(html => { document.querySelector('#main').insertAdjacentHTML('beforeend', html); window.__rebindPoptrox(); }, card('slow', [url('slow')], ['Slow card'])); + await open('slow'); + await page.waitForFunction(expected => { const p = document.querySelector('.poptrox-popup'), img = p.querySelector('.pic img'); return p.classList.contains('loading') && img && img.getAttribute('src') === expected && !img.complete; }, url('slow')); + await close(); + releaseSlow.forEach(resolve => resolve()); + await page.waitForTimeout(900); + assert.equal(await page.locator('.poptrox-popup').isVisible(), false); + assert.equal(await page.locator('.pic img').count(), 0); + await open('new'); await ready('new'); await close(); + assert.deepEqual(errors, [], 'no uncaught script errors'); + await context.close(); + console.log(`Gallery browser regressions passed in ${browserName} at ${viewport.width}px`); + } + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/gallery-discovery.php b/tests/gallery-discovery.php new file mode 100644 index 0000000..6877442 --- /dev/null +++ b/tests/gallery-discovery.php @@ -0,0 +1,274 @@ +columns = $columns; } + public function from(string $table): self { $this->table = $table; return $this; } + public function where(string $sql, ...$args): self { $this->conditions[$sql] = $args; return $this; } + public function order(...$args): self { return $this; } + public function limit($n): self { $this->maximum = (int)$n; return $this; } + public function offset($n): self { $this->start = (int)$n; return $this; } + public function join(...$args): self { return $this; } + } + class Db { + public const SORT_ASC = 'ASC', SORT_DESC = 'DESC'; + public static self $instance; + public array $queries = [], $albums = [], $images = [], $fields = [], $metas = []; + public bool $modernSchema = true, $repositoryAvailable = true; + public int $photoFailures = 0; + public static function get(): self { return self::$instance; } + public function getPrefix(): string { return 'test_'; } + public function select(...$columns): Query { return new Query($columns); } + public function fetchAll(Query $query): array { + $this->queries[] = $query; + $columns = array_map(static function ($name) { $parts = explode('.', $name); return end($parts); }, $query->columns); + if ($query->table === 'test_infinitytime_images') { + if (!$this->repositoryAvailable) { throw new \RuntimeException('插件表不存在'); } + if (!$this->modernSchema && array_intersect(['mid', 'avif', 'mid_avif'], $columns)) { throw new \RuntimeException('旧表没有变体列'); } + if ($this->photoFailures > 0) { $this->photoFailures--; throw new \RuntimeException('临时查询失败:内部私有诊断'); } + } + $tables = ['test_contents' => $this->albums, 'test_infinitytime_images' => $this->images, + 'test_fields' => $this->fields, 'test_relationships' => $this->metas]; + if (!isset($tables[$query->table])) { throw new \RuntimeException('未预期的数据表:' . $query->table); } + $rows = $tables[$query->table]; + foreach ($query->conditions as $sql => $values) { + $rows = array_values(array_filter($rows, static function ($r) use ($sql, $values): bool { + switch ($sql) { + case 'type = ?': return $r['type'] === $values[0]; + case 'status = ?': return $r['status'] === $values[0]; + case '(password IS NULL OR password = ?)': return ($r['password'] ?? '') === ''; + case 'created <= ?': return $r['created'] <= $values[0]; + case 'cid <= ?': return $r['cid'] <= $values[0]; + case 'cid = ?': return $r['cid'] === $values[0]; + case 'id = ?': return $r['id'] === $values[0]; + default: return strpos($sql, 'cid IN') !== false ? in_array($r['cid'], $values, true) + : (strpos($sql, 'name IN') !== false ? in_array($r['name'], $values, true) : true); + } + })); + } + if ($query->table === 'test_contents') { usort($rows, static fn($a, $b) => $b['cid'] <=> $a['cid']); } + $rows = array_slice($rows, $query->start, $query->maximum ?: null); + $columns = array_map(static function ($name) { $parts = explode('.', $name); return end($parts); }, $query->columns); + return array_map(static function ($row) use ($columns) { return array_intersect_key($row, array_flip($columns)); }, $rows); + } + } +} +namespace { + require __DIR__ . '/../usr/themes/InfinityTime/functions.php'; + error_reporting(E_ALL); + set_error_handler(static function ($severity, $message, $file, $line): void { throw new RuntimeException($message . ' at ' . $file . ':' . $line); }); + $checks = 0; + function check(bool $ok, string $name): void { global $checks; if (!$ok) throw new RuntimeException($name); $checks++; } + function album(int $id, array $extra = []): array { + return array_merge(['cid' => $id, 'created' => strtotime('2026-08-15 UTC'), 'title' => '相册 ' . $id, 'text' => '', 'status' => 'publish', 'type' => 'post', 'password' => ''], $extra); + } + function photo(int $id, array $extra = []): array { + return array_merge(['id' => $id, 'cid' => 7, 'full' => '/uploads/full/' . $id . '.webp', 'thumb' => '/uploads/thumb/' . $id . '.webp', 'width' => 2000, 'height' => 1000, + 'mid' => null, 'avif' => null, 'mid_avif' => null, 'title' => '照片 ' . $id, 'desc' => '日落风景', 'created' => strtotime('2026-09-01 UTC'), 'exif' => '{"datetime":"2026:04:30 12:00:00","iso":100,"gps":{"lat":51.5},"GPSLatitude":"private","original":"secret"}', + 'original' => '/private/original.jpg', 'gps_lat' => 51.5, 'gps_lng' => -0.1, 'hash' => 'private'], $extra); + } + class GalleryFixtureOptions { + public function __get(string $name) { return $name === 'siteUrl' ? 'https://example.test/sub/' : ''; } + public function __isset(string $name): bool { return false; } + public function __call(string $name, array $args): void { + if ($name === 'themeUrl') { echo 'https://example.test/theme/' . htmlspecialchars($args[0], ENT_QUOTES); } + elseif ($name === 'charset') { echo 'UTF-8'; } + } + } + class GalleryFixture { + public GalleryFixtureOptions $options; + public string $archiveType, $archiveTitle, $archiveSlug; + public function __construct(string $archiveType = '', string $archiveTitle = '', string $archiveSlug = '') { + $this->archiveType = $archiveType; $this->archiveTitle = $archiveTitle; $this->archiveSlug = $archiveSlug; + $this->options = new GalleryFixtureOptions(); + } + public function is(string $type): bool { return $type === $this->archiveType; } + public function getArchiveTitle(): string { return $this->archiveTitle; } + public function getArchiveSlug(): string { return $this->archiveSlug; } + public function render(): void { include __DIR__ . '/../usr/themes/InfinityTime/index.php'; } + } + if (($argv[1] ?? '') === '--api-fixture') { + $db = \Typecho\Db::$instance = new \Typecho\Db(); + $db->albums = [album(7), album(9, ['password' => 'hidden']), album(8, ['status' => 'draft'])]; + $db->images = [photo(91), photo(92, ['cid' => 9]), photo(93, ['cid' => 8])]; + $_GET = ['infinitytime_api' => 'gallery']; + (new GalleryFixture())->render(); + exit; + } + $req = pp_gallery_request(['q' => ['bad'], 'kind' => 'invalid', 'month' => '2026-13', 'photo' => '../private', 'cursor' => '1 or 1=1', 'limit' => 999]); + check($req['q'] === '' && $req['kind'] === 'all' && $req['month'] === '' && $req['cursor'] === '' && $req['photo'] === '' && $req['limit'] === 12, '无效请求参数被规范化且数量受限'); + check(pp_gallery_request(['limit' => 0])['limit'] === 1, '分页数量有下限'); + check(pp_gallery_month(['datetime' => '2026:02:30 12:00:00'], strtotime('2026-03-02 UTC')) === '2026-03', '无效 EXIF 日期回退到 UTC 上传月份'); + check(pp_gallery_month(['datetime' => '2024-02-29'], 0) === '2024-02', '有效闰日归入实际拍摄月份'); + check(pp_gallery_month([], 0) === '', '没有日期时保留未知状态'); + $safe = pp_gallery_photo(photo(91), 7, 0); + check($safe['id'] === 'p-91' && $safe['month'] === '2026-04' && $safe['pano'], '仓库 ID、月份及全景标记来自实际照片'); + check(!preg_match('/gps|private|original|secret/i', json_encode($safe)), '公开照片白名单排除私有记录和 EXIF 字段'); + check(pp_gallery_media_url('javascript:alert(1)') === '' && pp_gallery_media_url('//evil.test/p.jpg') === '' && pp_gallery_media_url('/uploads/original/a.jpg') === '' && pp_gallery_media_url('/uploads/%2e%2e/a.jpg') === '', '拒绝危险 URL 协议、原图及路径穿越'); + check(pp_gallery_media_url('https://cdn.example.com/a.webp') !== '', '接受公开 HTTPS 派生图'); + check(pp_gallery_tags('城市, 夜景,城市', ['旅行', '夜景']) === ['城市', '夜景', '旅行'], '自定义及原生标签合并并去重'); + foreach (['draft', 'waiting', 'private'] as $status) { check(!pp_gallery_visible(album(1, ['status' => $status])), '不公开以下状态:' . $status); } + check(!pp_gallery_visible(album(1, ['password' => 'locked'])) && !pp_gallery_visible(album(1, ['created' => time() + 1000])), '密码及未到发布时间的相册不公开'); + $fields = ['img' => "/a.webp\n\n/b.webp", 'titles' => '["A","must not shift","B"]', 'exif' => '[{"datetime":"2026:01:01"},null,{}]']; + $legacy = pp_gallery_legacy_photos(album(7), $fields, 0, 60); + check(count($legacy['photos']) === 2 && $legacy['photos'][1]['title'] === 'B', '历史空行不影响元数据对应关系'); + $reordered = pp_gallery_legacy_photos(album(7), ['img' => "/b.webp\n/a.webp"], 0, 60); + check($legacy['photos'][0]['id'] === $reordered['photos'][1]['id'] && $legacy['photos'][1]['id'] === $reordered['photos'][0]['id'], '历史照片 ID 不随排序变化'); + check($legacy['photos'][1]['month'] === '2026-08', '历史照片无 EXIF 时使用相册创建时间的 UTC 月份'); + check(count(pp_gallery_legacy_photos(album(7), $fields, 0, 1, $legacy['photos'][1]['id'])['photos']) === 1, '历史照片深链接可定位首批之外的照片'); + $publicAlbum = ['title' => '京都旅行', 'description' => '旅途日记', 'tags' => ['城市'], 'category_slugs' => ['travel']]; + $photoA = pp_gallery_photo(photo(1, ['title' => 'Sunset', 'exif' => '{"datetime":"2026:01:02"}']), 7, 0); + $photoB = pp_gallery_photo(photo(2, ['title' => 'Morning', 'height' => 1500, 'exif' => '{"datetime":"2026:02:02"}']), 7, 0); + check(count(pp_gallery_filter($publicAlbum, [$photoA, $photoB], pp_gallery_request(['q' => '京都']))) === 2, '相册标题命中后仍需符合其他照片筛选条件'); + check(count(pp_gallery_filter($publicAlbum, [$photoA, $photoB], pp_gallery_request(['q' => 'sunSET']))) === 1, '照片标题搜索不区分大小写'); + check(count(pp_gallery_filter($publicAlbum, [$photoA, $photoB], pp_gallery_request(['q' => '日落']))) === 2, '照片描述搜索支持 Unicode'); + check(pp_gallery_filter($publicAlbum, [$photoA, $photoB], pp_gallery_request(['kind' => 'pano', 'month' => '2026-02'])) === [], '全景与月份必须命中同一照片,不能分别命中相册中的不同照片'); + check(count(pp_gallery_filter($publicAlbum, [$photoA, $photoB], pp_gallery_request(['tag' => '城市', 'category' => 'travel', 'month' => '2026-01']))) === 1, '标签、分类及拍摄月份取交集'); + check(pp_gallery_filter($publicAlbum, [$photoA], pp_gallery_request(['tag' => '城'])) === [], '标签按完整名称匹配'); + + $db = \Typecho\Db::$instance = new \Typecho\Db(); + $db->albums = [album(7), album(8, ['status' => 'draft']), album(9, ['password' => 'hidden']), album(10, ['created' => time() + 20000])]; + $db->images = [photo(1), photo(2, ['cid' => 9])]; + $db->fields = [['cid' => 7, 'name' => 'tags', 'str_value' => '城市'], ['cid' => 7, 'name' => 'secret', 'str_value' => 'never public']]; + $db->metas = [['cid' => 7, 'type' => 'tag', 'name' => '旅行', 'slug' => 'travel'], ['cid' => 7, 'type' => 'category', 'name' => '摄影', 'slug' => 'photo']]; + $page = pp_gallery_read(pp_gallery_request([]), 'https://example.test/sub/'); + check(count($page['albums']) === 1 && $page['albums'][0]['id'] === 7, '数据库页面排除草稿、密码及未来发布的相册'); + check($page['albums'][0]['tags'] === ['城市', '旅行'], '数据库页面合并两种标签来源'); + check(!preg_match('/gps|private|original|secret|password|draft|hidden/i', json_encode($page)), '公开页面和 API 结构不包含私有字段'); + check($page['albums'][0]['photos'][0]['url_link'] === 'https://example.test/sub/?album=7&photo=p-1', '稳定照片链接支持子目录安装'); + foreach ($db->queries as $query) { + if ($query->table === 'test_contents') { check($query->maximum === 17 && isset($query->conditions['status = ?'], $query->conditions['(password IS NULL OR password = ?)']), '图集查询有上限并执行发布状态和密码过滤'); } + if ($query->table === 'test_fields') { check($query->conditions['name IN (?,?,?)'] === ['tags', 'device', 'location'], '现代仓库相册阶段仅读三个小型字段'); } + if ($query->table === 'test_infinitytime_images') { check($query->maximum <= 61 && !preg_match('/(?:^|\.)(?:\*|original|gps_lat|gps_lng|hash)$/m', implode("\n", $query->columns)), '照片查询有上限且不读取私有列'); } + } + // 直接定位仓库中首批以外的照片。 + $db->images = []; for ($i = 1; $i <= 150; $i++) { $db->images[] = photo($i); } + $targetPage = pp_gallery_read(pp_gallery_request(['album' => 7, 'photo' => 'p-149']), 'https://example.test/'); + check(array_column($targetPage['albums'][0]['photos'], 'id') === ['p-149'], '仓库深链接可定位首批之外的照片'); + $cursor = ''; $all = []; $rounds = 0; + do { + $page = pp_gallery_read(pp_gallery_request(['cursor' => $cursor, 'limit' => 1]), 'https://example.test/'); + foreach ($page['albums'] as $a) { $all = array_merge($all, array_column($a['photos'], 'id')); } + $cursor = $page['next_cursor']; $rounds++; + } while ($cursor !== null && $rounds < 10); + check(count($all) === 150 && count(array_unique($all)) === 150 && $rounds === 3, '大图集分页不遗漏、不重复照片'); + + // 旧表没有变体列且尚未同步 photo_ids 时,仍按 URL 对应而非位置。 + $db->modernSchema = false; + $db->fields = [ + ['cid' => 7, 'name' => 'img', 'str_value' => "/uploads/full/2.webp\n/uploads/full/1.webp"], + ['cid' => 7, 'name' => 'variants', 'str_value' => '[{"w":2000,"webp":["/uploads/full/2.webp","/uploads/full/2-mid.webp"]},{"w":2000,"webp":["/uploads/full/1.webp","/uploads/full/1-mid.webp"]}]'], + ]; + $variantPage = pp_gallery_read(pp_gallery_request(['album' => 7, 'photo' => 'p-1']), 'https://example.test/'); + check($variantPage['albums'][0]['photos'][0]['variants']['webp'][1] === '/uploads/full/1-mid.webp', '旧图集变体按 URL 关联且无需先执行重建'); + $db->modernSchema = true; + // 插件表存在但没有对应记录时,历史字段相册仍可继续分页。 + $db->images = []; + $urls = []; for ($i = 1; $i <= 130; $i++) { $urls[] = '/legacy/' . $i . '.webp'; } + $db->fields = [['cid' => 7, 'name' => 'img', 'str_value' => implode("\n", $urls)]]; + $second = pp_gallery_read(pp_gallery_request(['cursor' => '7.60', 'limit' => 1]), 'https://example.test/'); + check(count($second['albums'][0]['photos']) === 60 && $second['albums'][0]['photos'][0]['url'] === '/legacy/61.webp', '插件表存在时字段相册仍可进入第二批'); + + // 现代仓库直接投影变体,忽略陈旧聚合字段;缓存版本与 URL/EXIF 白名单仍生效。 + $db->images = [photo(501, ['full' => '/fresh.webp', 'mid' => '/fresh-mid.webp', 'avif' => '/fresh.avif', 'mid_avif' => '/fresh-mid.avif', + 'exif' => json_encode(['make' => 'Camera', '_infinity_media_version' => str_repeat('a', 32), 'gps' => ['lat' => 1]])])]; + $db->fields = [['cid' => 7, 'name' => 'img', 'str_value' => '/stale.webp'], ['cid' => 7, 'name' => 'variants', 'str_value' => str_repeat('stale', 10000)]]; + $db->queries = []; + $fresh = pp_gallery_read(pp_gallery_request(['album' => 7]), 'https://example.test/'); + $freshPhoto = $fresh['albums'][0]['photos'][0]; + $versionSuffix = '?itv=' . str_repeat('a', 32); + check($freshPhoto['variants'] === ['w' => 2000, 'webp' => ['/fresh.webp' . $versionSuffix, '/fresh-mid.webp' . $versionSuffix], + 'avif' => ['/fresh.avif' . $versionSuffix, '/fresh-mid.avif' . $versionSuffix]], '现代投影保留完整 WebP/AVIF 变体及缓存版本'); + check($freshPhoto['exif'] === ['make' => 'Camera'] && count($db->queries) === 4, '现代查询不解析历史聚合字段且仍只公开 EXIF 白名单'); + $db->images[0]['mid'] = '/uploads/original/private.jpg'; $db->images[0]['avif'] = 'javascript:alert(1)'; + $unsafe = pp_gallery_read(pp_gallery_request(['album' => 7]), 'https://example.test/'); + check(count($unsafe['albums'][0]['photos'][0]['variants']['webp']) === 1 && !preg_match('/private|javascript|gps/', json_encode($unsafe)), '直接投影同样拒绝变体中的原图和脚本 URL'); + + // 老表按稳定 ID 取变体,即便字段与图片顺序不同;同一请求只探测一次缺列并读取一次旧字段。 + $db->modernSchema = false; $db->images = []; $urls = []; $variants = []; $ids = []; + for ($i = 1; $i <= 130; $i++) { $db->images[] = photo($i); $urls[] = '/different/' . $i . '.webp'; $ids[] = $i; $variants[] = ['w' => 2000, 'webp' => ['/stable/' . $i . '.webp']]; } + $db->fields = [['cid' => 7, 'name' => 'img', 'str_value' => implode("\n", array_reverse($urls))], + ['cid' => 7, 'name' => 'photo_ids', 'str_value' => json_encode($ids)], ['cid' => 7, 'name' => 'variants', 'str_value' => json_encode($variants)]]; + $db->queries = []; + $oldSchema = pp_gallery_read(pp_gallery_request(['album' => 7]), 'https://example.test/'); + check(count($oldSchema['albums']) === 3 && $oldSchema['albums'][1]['photos'][0]['variants']['webp'] === ['/stable/61.webp'], '旧表多分片仍按稳定 ID 关联变体'); + $modernAttempts = array_filter($db->queries, static function ($q) { return $q->table === 'test_infinitytime_images' && in_array('test_infinitytime_images.mid', $q->columns, true); }); + $legacyReads = array_filter($db->queries, static function ($q) { return $q->table === 'test_fields' && isset($q->conditions['cid = ?']); }); + check(count($modernAttempts) === 1 && count($legacyReads) === 1, '旧表缺列探测及聚合字段读取均限制为每请求每相册一次'); + + // 无插件安装继续读字段相册,深链接和多分片不需要迁移。 + $db->repositoryAvailable = false; $db->fields = [['cid' => 7, 'name' => 'img', 'str_value' => implode("\n", $urls)]]; + $db->queries = []; + $noPlugin = pp_gallery_read(pp_gallery_request(['album' => 7]), 'https://example.test/'); + check(count($noPlugin['albums']) === 3 && count($noPlugin['albums'][2]['photos']) === 10, '无插件表仍完整分页读取历史字段'); + $legacyReads = array_filter($db->queries, static function ($q) { return $q->table === 'test_fields' && isset($q->conditions['cid = ?']); }); + check(count($legacyReads) === 1, '字段相册多个分片复用请求内解析和读取'); + $lastLegacy = $noPlugin['albums'][2]['photos'][9]['id']; + $deepLegacy = pp_gallery_read(pp_gallery_request(['album' => 7, 'photo' => $lastLegacy]), 'https://example.test/'); + check(array_column($deepLegacy['albums'][0]['photos'], 'id') === [$lastLegacy], '无插件表仍可打开首批之外的历史照片深链接'); + $db->queries = []; + $privateState = []; + check(pp_gallery_db_photos($db, album(7, ['password' => 'secret']), 0, 60, '', $privateState)['photos'] === [] && !$db->queries, '回退读取在查询前仍拒绝密码相册'); + + // 可用性状态不跨请求保存;暂时失败不得留下永久回退或泄漏内部错误。 + $db->repositoryAvailable = true; $db->modernSchema = true; $db->images = [photo(91)]; $db->photoFailures = 2; + $temporaryFailure = pp_gallery_read(pp_gallery_request(['album' => 7, 'limit' => 1]), 'https://example.test/'); + check(!preg_match('/内部|private|secret|gps/', json_encode($temporaryFailure)), '暂时查询失败的公开字段回退不暴露诊断或私有记录'); + $recovered = pp_gallery_read(pp_gallery_request(['album' => 7]), 'https://example.test/'); + check($recovered['albums'][0]['photos'][0]['id'] === 'p-91', '下一请求恢复现代仓库读取,不复用旧失败状态'); + + // 稀疏匹配超出扫描额度:空批次不代表已经遍历完毕。 + $db->albums = []; $db->images = []; $db->fields = []; + for ($i = 40; $i > 0; $i--) { $db->albums[] = album($i); $db->images[] = photo($i, ['cid' => $i, 'title' => $i === 1 ? 'needle' : 'ordinary', 'desc' => '']); } + $cursor = ''; $found = false; $rounds = 0; + do { + $page = pp_gallery_read(pp_gallery_request(['q' => 'needle', 'cursor' => $cursor]), 'https://example.test/'); + if ($rounds === 0) check(!$page['albums'] && $page['scan_limited'] && $page['next_url'] !== null, '稀疏搜索的空批次保留继续搜索游标'); + if ($page['albums']) { $found = $page['albums'][0]['id'] === 1; } + check($page['scanned_chunks'] <= 16, '每次请求的扫描额度有上限'); + $cursor = $page['next_cursor']; $rounds++; + } while ($cursor !== null && $rounds < 10); + check($found && $rounds === 3, '前两个扫描窗口之后的稀疏结果仍可发现'); + $groups = pp_gallery_groups([['id' => 7, 'photos' => [$photoA, $photoB]]]); + check(array_keys($groups) === ['2026-02', '2026-01'], '按实际照片月份分组且页内月份降序'); + + // 执行实际 PHP 卡片渲染,覆盖无 JavaScript 照片及属性转义。 + $album = $targetPage['albums'][0]; + $album['title'] = "'\">"; + $album['photos'][0]['title'] = "'\">"; + $__galleryBase = 'https://example.test/'; $__galleryRequest = pp_gallery_request([]); $month = '2026-04'; + ob_start(); include __DIR__ . '/../usr/themes/InfinityTime/lib/gallery-card.php'; $html = ob_get_clean(); + check(!(strpos($html, '`).join('')}`; +} +const initial = card('a', 1, [url('a'), url('b'), url('pano'), url('slow')], ['p-1', 'p-2', 'p-3', 'p-4'], { + variants: [variants('a'), variants('b'), variants('pano')], panos: [0, 0, 1, 0] +}) + card('same-album', 1, [url('other-month')], ['p-5']) + card('broken', 2, [url('broken')], ['p-6']); +const pixel = ''; +(async () => { + const browser = await playwright[browserName].launch({ headless: true, ...(browserName === 'chromium' && process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); + try { + for (const viewport of [{ width: 1280, height: 900 }, { width: 390, height: 844 }]) { + const context = await browser.newContext({ viewport }); + await context.addInitScript(() => { + window.__samples = []; + window.__viewerCalls = 0; + const drawImage = CanvasRenderingContext2D.prototype.drawImage; + CanvasRenderingContext2D.prototype.drawImage = function(image, ...args) { + window.__samples.push({ connected: image.isConnected, source: image.currentSrc || image.src }); + return drawImage.call(this, image, ...args); + }; + const getContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function(type, ...args) { + if (/webgl/.test(type)) return null; + return getContext.call(this, type, ...args); + }; + window.pannellum = { viewer() { window.__viewerCalls++; throw new Error('挂载查看器前应先检测 WebGL'); } }; + window.__galleryEvents = []; + ['open', 'change', 'close', 'error'].forEach(type => document.addEventListener('infinitygallery:' + type, event => { + const d = event.detail; + window.__galleryEvents.push({ type, albumId: d && d.albumId, photoId: d && d.photoId }); + })); + }); + const page = await context.newPage(); + const errors = [], requests = []; + page.on('pageerror', e => errors.push(e.message)); + let broken = true, page2Fails = true, includeHistory = false, fallbackWebpFails = true, holdTimeout = true; + const delayed = new Map(); + async function waitForDelayed(name) { + const deadline = Date.now() + 5000; + while (!delayed.has(name) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 20)); + assert.ok(delayed.has(name), `网络故障注入请求已开始:${name}`); + } + await page.route('**/*', async route => { + const pathname = new URL(route.request().url()).pathname; + requests.push(pathname); + if (pathname === '/') return route.fulfill({ contentType: 'text/html', body: pageHtml(initial, '/page2', includeHistory) }); + if (pathname.startsWith('/assets/')) { + const filename = path.join(theme, pathname.slice(1)); + return fs.existsSync(filename) ? route.fulfill({ path: filename }) : route.fulfill({ status: 404, body: '' }); + } + if (pathname === '/page2') return route.fulfill(page2Fails + ? { status: 503, contentType: 'text/html', body: '

稍后重试

' } + : { contentType: 'text/html', body: pageHtml(card('appended', 3, [url('appended')], ['p-7']), '/page3') }); + if (pathname === '/page3') { + await new Promise(resolve => delayed.set('page3', resolve)); + return route.fulfill({ contentType: 'text/html', body: pageHtml(card('stale', 4, [url('stale')], ['p-8']), '') }).catch(() => {}); + } + if (pathname === '/page4') return route.fulfill({ contentType: 'text/html', body: pageHtml(card('fresh', 5, [url('fresh')], ['p-9']), '') }); + if ((pathname === '/timeout-page' && holdTimeout) || pathname === '/cancel-no-abort') { + await new Promise(resolve => delayed.set(pathname, resolve)); + return route.fulfill({ contentType: 'text/html', body: pageHtml(card('late-network', 10, [url('late-network')], ['p-50']), '') }).catch(() => {}); + } + if (pathname === '/timeout-page') return route.fulfill({ contentType: 'text/html', body: pageHtml(card('timeout-retry', 11, [url('timeout-retry')], ['p-51']), '') }); + if (pathname === '/empty') return route.fulfill({ contentType: 'text/html', body: pageHtml('', '') }); + if (/^\/fallback-.*\.avif$/.test(pathname)) return route.fulfill(pathname.startsWith('/fallback-decode') + ? { contentType: 'image/avif', body: 'corrupt AVIF bytes' } + : { status: 404, body: '' }); + if (/^\/fallback-all.*\.webp$/.test(pathname) && fallbackWebpFails) return route.fulfill({ status: 503, body: '' }); + if (pathname === '/broken.webp' && broken) return route.fulfill({ status: 503, body: '' }); + if (pathname === '/slow.webp') { + await new Promise(resolve => delayed.set('slow', resolve)); + return route.fulfill({ status: 503, body: '' }).catch(() => {}); + } + return route.fulfill({ contentType: 'image/svg+xml', body: pixel }); + }); + await page.goto(origin); + await page.waitForFunction(() => !!window.InfinityGallery && !!window.InfinityWaterfall); + async function open(albumId, photoId) { + assert.equal(await page.evaluate(target => InfinityGallery.open(target), { albumId, photoId }), true); + } + async function ready(name) { + try { + await page.waitForFunction(source => { + const p = document.querySelector('.poptrox-popup'), img = p && p.querySelector('.pic img'); + return img && img.getAttribute('src') === source && img.complete && img.naturalWidth && !p.classList.contains('loading') && img.style.opacity === '1'; + }, url(name)); + } catch (error) { + console.error('图片就绪超时诊断', await page.evaluate(() => { + const p = document.querySelector('.poptrox-popup'), img = p && p.querySelector('.pic img'); + return { hidden: document.hidden, src: img && img.getAttribute('src'), selected: img && img.currentSrc, + complete: img && img.complete, width: img && img.naturalWidth, opacity: img && img.style.opacity, + popup: p && p.className, switching: p && p.__switching, events: window.__galleryEvents.slice(-8) }; + })); + throw error; + } + await page.waitForTimeout(500); + } + async function close() { + await page.evaluate(() => InfinityGallery.close()); + await page.waitForFunction(() => getComputedStyle(document.querySelector('.poptrox-overlay')).display === 'none'); + } + await open('1', 'p-1'); await ready('a'); + await page.waitForFunction(() => document.querySelector('.palette-hex')); + assert.equal(await page.locator('.palette-hex').first().innerText(), '#556677', '代表色保持原有精确色值'); + assert.ok((await page.evaluate(() => window.__samples)).every(sample => sample.connected), '主题色采样复用已显示、已解码的位图'); + assert.equal(requests.filter(p => p === '/b.webp').length, 0, '相邻图预加载不下载全尺寸 WebP'); + assert.ok(requests.includes('/b-1600.avif'), '相邻图预加载与响应式主图候选一致'); + await page.keyboard.press('ArrowRight'); await ready('b'); + assert.ok(await page.locator('.pic img').evaluate(img => img.currentSrc.endsWith('/b-1600.avif'))); + assert.ok(requests.includes('/pano.webp'), '全景预加载原始分辨率'); + assert.equal(requests.filter(p => /pano.*\.avif|pano-1600/.test(p)).length, 0); + await page.keyboard.press('ArrowRight'); await ready('pano'); + await page.waitForFunction(() => !!document.querySelector('.pp-pano-fallback')); + assert.equal(await page.locator('.pp-pano-viewer').count(), 0); + assert.equal(await page.locator('.poptrox-popup').evaluate(p => p.classList.contains('pp-pano-mode')), false); + assert.equal(await page.locator('.pic img').evaluate(img => getComputedStyle(img).visibility), 'visible'); + assert.equal(await page.evaluate(() => window.__viewerCalls), 0, '不支持 WebGL 时不创建全景查看器'); + await open('1', 'p-5'); await ready('other-month'); + assert.equal(await page.evaluate(() => InfinityGallery.getCurrent().opener.id), 'same-album', '稳定 ID 能区分同一相册的多张月份卡片'); + await open('2', 'p-6'); + await page.waitForSelector('.pp-image-error button', { state: 'visible' }); + assert.equal(await page.locator('.poptrox-popup').evaluate(p => p.classList.contains('loading')), false, '加载失败后释放 Poptrox 导航锁'); + broken = false; + await page.locator('.pp-image-error button').click(); await ready('broken'); + assert.equal(await page.locator('.pp-image-error').count(), 0); + await page.keyboard.press('ArrowRight'); await ready('a'); + await page.evaluate(() => InfinityGallery.close()); + await open('1', 'p-5'); await ready('other-month'); + assert.equal(await page.locator('.pp-pano-fallback').count(), 0, '全景降级提示不会残留到下一张普通照片'); + await open('1', 'p-4'); + await page.waitForFunction(() => document.querySelector('.pic img').getAttribute('src').endsWith('/slow.webp')); + await close(); + if (delayed.has('slow')) delayed.get('slow')(); + await page.waitForTimeout(500); + assert.equal(await page.locator('.pp-image-error').count(), 0, '迟到的图片错误不会在灯箱关闭后重新显示'); + assert.equal(await page.locator('.pic img').count(), 0); + await open('1', 'p-1'); await ready('a'); await close(); + assert.equal(await page.evaluate(() => { + window.__superseded = InfinityGallery.open({ albumId: '1', photoId: 'p-1' }); + return InfinityGallery.open({ albumId: '1', photoId: 'p-5' }); + }), true); + await ready('other-month'); + assert.equal(await page.evaluate(() => window.__superseded), false, '被新请求取代的打开操作不会恢复旧淡入'); + await close(); + assert.ok((await page.evaluate(() => window.__galleryEvents)).some(event => event.type === 'change' && event.photoId === 'p-5')); + // 显示失败状态;失败后暂停自动请求,等待用户主动重试。 + assert.equal(await page.evaluate(() => InfinityWaterfall.loadMore()), false); + assert.equal(await page.locator('#load-more').getAttribute('aria-busy'), 'false'); + assert.match(await page.locator('.pp-load-status').innerText(), /加载失败/); + const beforeAuto = requests.filter(p => p === '/page2').length; + await page.evaluate(() => window.dispatchEvent(new Event('scroll'))); + await page.waitForTimeout(100); + assert.equal(requests.filter(p => p === '/page2').length, beforeAuto); + page2Fails = false; + await page.locator('.pp-load-retry').click(); + await page.waitForSelector('#appended[role="button"]'); + assert.equal(await page.locator('#month-2026-10').count(), 1, '追加月份标题不重复 DOM ID'); + assert.equal(await page.locator('#gallery-pagination a[rel="next"]').getAttribute('href'), '/page3'); + // 取消旧分页并发起新请求,旧响应迟到后不能追加内容。 + assert.equal(await page.evaluate(() => { + window.__pendingPage = InfinityWaterfall.loadMore(); + window.__joinedPage = InfinityWaterfall.loadMore(); + return window.__pendingPage === window.__joinedPage; + }), true, '并发调用共享待完成的真实网络结果'); + await waitForDelayed('page3'); + await page.evaluate(() => InfinityWaterfall.reset({ nextUrl: '/page4' })); + assert.equal(await page.evaluate(() => window.__pendingPage), false, 'reset 无需等待网络响应即可结束旧请求'); + assert.equal(await page.evaluate(() => window.__joinedPage), false); + delayed.get('page3')(); + assert.equal(await page.evaluate(() => InfinityWaterfall.loadMore()), true); + 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); + // 同一浏览器任务内重置并发起明确请求,避免原生滚动事件在两次 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; + assert.equal(await page.evaluate(() => { + InfinityWaterfall.reset({ nextUrl: '/empty' }); + window.scrollTo(0, document.documentElement.scrollHeight); + window.dispatchEvent(new Event('scroll')); + return InfinityWaterfall.loadMore(); + }), true, '主动调用等待已在进行的自动滚动分页,而非立即返回 false'); + 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); + // 无 AbortController 的实际浏览器请求:超时与取消必须结束等待, + // 即使网络仍在传输,迟到响应也不能写入网格或干扰重试。 + await page.evaluate(() => { + window.__savedAbortController = window.AbortController; + window.AbortController = undefined; + const schedule = window.setTimeout; + window.setTimeout = function(callback, delay, ...args) { + if (delay === 20000) window.__expireWaterfall = callback; + return schedule(callback, delay, ...args); + }; + try { + InfinityWaterfall.reset({ nextUrl: '/timeout-page' }); + window.__timedOutPage = InfinityWaterfall.loadMore(); + } finally { window.setTimeout = schedule; } + }); + await waitForDelayed('/timeout-page'); + await page.evaluate(() => window.__expireWaterfall()); + assert.equal(await page.evaluate(() => window.__timedOutPage), false, '缺少 AbortController 时超时仍可结束'); + assert.equal(await page.locator('#load-more').getAttribute('aria-busy'), 'false'); + assert.match(await page.locator('.pp-load-status').innerText(), /加载失败/); + holdTimeout = false; + assert.equal(await page.evaluate(() => InfinityWaterfall.loadMore()), true, '超时后能成功重试同一页'); + assert.equal(await page.locator('#timeout-retry').count(), 1); + const lateTimeoutResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/timeout-page'); + delayed.get('/timeout-page')(); + await (await lateTimeoutResponse).finished(); + assert.equal(await page.locator('#late-network').count(), 0, '超时后的迟到页面被隔离'); + await page.evaluate(() => { + InfinityWaterfall.reset({ nextUrl: '/cancel-no-abort' }); + window.__cancelledPage = InfinityWaterfall.loadMore(); + }); + await waitForDelayed('/cancel-no-abort'); + const cancelledState = await page.evaluate(() => { + InfinityWaterfall.cancel(); + const more = document.querySelector('#load-more'); + const state = { busy: more.getAttribute('aria-busy'), failed: more.classList.contains('pp-load-error') }; + // 同一任务内清空游标,避免恢复可重试状态后原生滚动再次请求该测试页。 + InfinityWaterfall.reset({ nextUrl: '' }); + window.AbortController = window.__savedAbortController; + return state; + }); + assert.equal(await page.evaluate(() => window.__cancelledPage), false, '缺少 AbortController 时取消也立即结束'); + assert.deepEqual(cancelledState, { busy: 'false', failed: false }); + const lateCancelledResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/cancel-no-abort'); + delayed.get('/cancel-no-abort')(); + await (await lateCancelledResponse).finished(); + assert.equal(await page.locator('#late-network').count(), 0, '取消后的迟到页面被隔离'); + // AVIF 请求和解码失败均必须尝试可用 WebP,不能仅显示永久失败。 + await page.evaluate(html => { + document.querySelector('#waterfall').insertAdjacentHTML('beforeend', html); + window.__rebindPoptrox(); + }, card('fallback', 6, ['fallback-404', 'fallback-decode', 'fallback-all'].map(url), ['p-10', 'p-11', 'p-12'], { + variants: ['fallback-404', 'fallback-decode', 'fallback-all'].map(variants) + })); + await open('6', 'p-10'); await ready('fallback-404'); + assert.ok(await page.locator('.pic img').evaluate(img => img.currentSrc.endsWith('/fallback-404-1600.webp'))); + assert.equal(await page.locator('.pp-image-error').count(), 0); + assert.equal(await page.locator('.pic source[type="image/avif"]').count(), 0); + await page.keyboard.press('ArrowRight'); await ready('fallback-decode'); + assert.ok(await page.locator('.pic img').evaluate(img => img.currentSrc.endsWith('/fallback-decode-1600.webp'))); + await page.keyboard.press('ArrowRight'); + await page.waitForSelector('.pp-image-error button', { state: 'visible' }); + assert.equal(await page.locator('.poptrox-popup').evaluate(p => p.classList.contains('loading')), false); + fallbackWebpFails = false; + await page.locator('.pp-image-error button').click(); await ready('fallback-all'); + assert.equal(await page.locator('.pp-image-error').count(), 0, '全格式失败仍可手动重试'); + await close(); + // 与真实历史模块整合:切图只替换一条灯箱记录,返回恢复列表滚动; + // 前进和刷新恢复同一照片,直接分享链接关闭后保留相册筛选。 + includeHistory = true; + await page.goto(origin); + await page.waitForFunction(() => !!window.InfinityGallery); + await page.evaluate(() => { window.scrollTo(0, 600); document.querySelector('#a').click(); }); + await ready('a'); + assert.equal(new URL(page.url()).searchParams.get('photo'), 'p-1'); + await page.keyboard.press('ArrowRight'); await ready('b'); + assert.equal(new URL(page.url()).searchParams.get('photo'), 'p-2'); + await page.goBack(); + await page.waitForFunction(() => getComputedStyle(document.querySelector('.poptrox-overlay')).display === 'none'); + assert.equal(new URL(page.url()).searchParams.has('photo'), false); + await page.waitForFunction(() => Math.abs(window.scrollY - 600) < 2); + await page.goForward(); await ready('b'); + await page.reload(); await ready('b'); + await page.keyboard.press('Escape'); + await page.waitForFunction(() => !new URL(location.href).searchParams.has('photo')); + await page.waitForFunction(() => getComputedStyle(document.querySelector('.poptrox-overlay')).display === 'none'); + await page.goto(origin + '/?album=1&photo=p-5'); await ready('other-month'); + // 关闭直接分享链接会真正重载相册页,不能只等 replaceState 改了地址。 + await Promise.all([page.waitForEvent('load'), page.keyboard.press('Escape')]); + await page.waitForFunction(() => !new URL(location.href).searchParams.has('photo')); + assert.equal(new URL(page.url()).searchParams.get('album'), '1'); + // 省流模式在真实浏览器中禁止自动分页和相邻大图预取,主动操作仍能成功。 + await page.goto(origin); + await page.selectOption('#gallery-data-saver', 'on'); + await page.reload(); + assert.equal(await page.locator('#gallery-data-saver').inputValue(), 'on', '刷新保留显式省流选择'); + const pageRequestsBefore = requests.filter(path => path === '/page2').length; + await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); window.dispatchEvent(new Event('scroll')); }); + await page.waitForTimeout(150); + assert.equal(requests.filter(path => path === '/page2').length, pageRequestsBefore, '省流禁止自动下一页'); + assert.equal(await page.evaluate(() => InfinityWaterfall.loadMore()), true, '省流允许手动加载下一页'); + await page.evaluate(html => { + document.querySelector('#waterfall').insertAdjacentHTML('beforeend', html); + window.__rebindPoptrox(); + }, card('saving', 99, [url('saving-current'), url('saving-next')], ['p-991', 'p-992'])); + await open('99', 'p-991'); await ready('saving-current'); + await page.waitForTimeout(150); + assert.equal(requests.some(path => path.includes('saving-next')), false, '省流不预取邻图'); + await page.keyboard.press('ArrowRight'); await ready('saving-next'); + await close(); + await page.selectOption('#gallery-data-saver', 'off'); + assert.equal(await page.evaluate(() => InfinityDataSaver.isEnabled()), false, '可以恢复正常模式'); + assert.deepEqual(errors, []); + await context.close(); + console.log(`图册加载、降级和重试回归测试通过:${browserName} ${viewport.width}px`); + } + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/gallery-loading.cjs b/tests/gallery-loading.cjs new file mode 100644 index 0000000..4e88fce --- /dev/null +++ b/tests/gallery-loading.cjs @@ -0,0 +1,323 @@ +// 无依赖测试直接执行生产辅助函数;真实浏览器中的响应式候选选择 +// 由 gallery-loading-browser.cjs 覆盖。 +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); +const read = name => fs.readFileSync(path.resolve(__dirname, '../usr/themes/InfinityTime/assets/js/' + name + '.js'), 'utf8'); +function helper(source, name, indent) { + const spaces = ' '.repeat(indent); + const start = source.indexOf(spaces + 'function ' + name + '('); + assert.ok(start >= 0, `${name} 已定义`); + const end = source.indexOf('\n' + spaces + '}', start); + return source.slice(start, end + indent + 2); +} +class Element { + constructor(tag = 'div', classes = '') { + this.tagName = tag.toUpperCase(); this.children = []; this.attrs = {}; this.style = {}; + this.events = {}; this.isConnected = true; this.classes = new Set(classes.split(' ').filter(Boolean)); + this.classList = { + contains: c => this.classes.has(c), add: c => this.classes.add(c), remove: c => this.classes.delete(c), + toggle: (c, value) => value ? this.classes.add(c) : this.classes.delete(c) + }; + } + set className(value) { this.classes = new Set(value.split(' ')); } + get className() { return [...this.classes].join(' '); } + get parentNode() { return this.parent; } + appendChild(node) { node.remove(); node.parent = this; this.children.push(node); return node; } + insertBefore(node, before) { node.remove(); node.parent = this; this.children.splice(this.children.indexOf(before), 0, node); } + remove() { if (this.parent) { this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null; } } + setAttribute(key, value) { this.attrs[key] = String(value); } + getAttribute(key) { return this.attrs[key] ?? null; } + hasAttribute(key) { return key in this.attrs; } + removeAttribute(key) { delete this.attrs[key]; } + contains(node) { return this === node || this.children.some(child => child.contains(node)); } + matches(selector) { return selector.split(',').some(sel => { + sel = sel.trim(); + if (sel === 'picture.pp-picture') return this.tagName === 'PICTURE' && this.classes.has('pp-picture'); + if (sel === 'source[type="image/avif"]') return this.tagName === 'SOURCE' && this.type === 'image/avif'; + if (sel === 'a[rel="next"]') return this.tagName === 'A' && this.attrs.rel === 'next'; + return sel.startsWith('.') && this.classes.has(sel.slice(1)); + }); } + querySelectorAll(selector) { + if (selector === ':scope > .thumb') return this.children.filter(n => n.classes.has('thumb')); + return this.children.flatMap(n => [...(n.matches(selector) ? [n] : []), ...n.querySelectorAll(selector)]); + } + querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } + addEventListener(type, fn) { (this.events[type] ||= []).push(fn); } +} +(async () => { + // 预加载复用主图的 /sizes 逻辑,在 src 前配置响应式属性; + // 失败后允许重试,并限制常驻对象数量。 + { + const source = read('main'); + const images = []; + const context = { + document: { createElement: tag => new Element(tag) }, window: { innerWidth: 390, devicePixelRatio: 2 }, + variantMap: { '/b.webp': { w: 3200, webp: ['/b.webp', '/b-1600.webp'], avif: ['/b.avif', '/b-1600.avif'] } }, + isPanoUrl: url => url === '/pano.webp', normUrl: url => url, + __preloaded: new Map(), + Image: class extends Element { + constructor() { super('img'); images.push(this); } + set src(value) { this.assigned = value; this.sourceWhenAssigned = this.parentNode.tagName; this.srcsetWhenAssigned = this.getAttribute('srcset'); } + } + }; + vm.createContext(context); + vm.runInContext(['removeResponsivePicture', 'applyResponsive', 'preloadUrl'].map(name => helper(source, name, 4)).join('\n'), context); + context.preloadUrl('/b.webp'); + assert.equal(images.length, 1); + assert.equal(images[0].sourceWhenAssigned, 'PICTURE'); + assert.equal(images[0].srcsetWhenAssigned, '/b.webp 3200w, /b-1600.webp 1600w'); + assert.equal(images[0].parentNode.querySelector('source[type="image/avif"]').getAttribute('srcset'), '/b.avif 3200w, /b-1600.avif 1600w'); + assert.equal(images[0].fetchPriority, 'low'); + context.preloadUrl('/b.webp'); assert.equal(images.length, 1); + images[0].onerror(); context.preloadUrl('/b.webp'); assert.equal(images.length, 2, '相邻图预加载失败后允许重试'); + context.window.innerWidth = 1280; context.preloadUrl('/b.webp'); assert.equal(images.length, 3, '视口变化后重新选择候选图'); + context.preloadUrl('/pano.webp'); + assert.equal(images.at(-1).sourceWhenAssigned, 'DIV'); + assert.equal(images.at(-1).assigned, '/pano.webp'); + for (let i = 0; i < 100; i++) context.preloadUrl('/' + i + '.webp'); + assert.equal(context.__preloaded.size, 64); + } + // 关闭动画超过预期时,重绑必须等实际淡出完成,不能遗失待处理任务。 + { + const source = read('main'), vendor = read('jquery.poptrox.min'); + let visible = true, rebuilds = 0; + const collection = { is: () => visible, remove() {} }; + const anchors = { each() {}, off() {} }; + const context = { + isPopupActive: true, poptroxRebindPending: false, + document: { querySelectorAll: () => [] }, + window: {}, $: () => collection, + $main: { 0: {}, find: () => anchors, poptrox: () => rebuilds++ }, + PP_CONFIG: {}, prepareGalleryDialog() {}, rebuildLightboxData() {} + }; + vm.createContext(context); + const start = source.indexOf(' window.__rebindPoptrox = function() {'); + const end = source.indexOf(' };', start) + ' };'.length; + vm.runInContext(source.slice(start, end), context); + const closedStart = source.indexOf(' onPopupClosed: function() {'); + const closedEnd = source.indexOf(' },', closedStart); + vm.runInContext('var afterClose = ' + source.slice(closedStart, closedEnd).replace(' onPopupClosed: ', '') + '};', context); + context.window.__rebindPoptrox(); + assert.equal(context.poptroxRebindPending, true); + context.isPopupActive = false; + for (let frame = 0; frame < 30; frame++) context.window.__rebindPoptrox(); + assert.equal(rebuilds, 0, '慢动画尚未隐藏覆盖层时不可销毁灯箱'); + assert.equal(context.poptroxRebindPending, true); + const fadeCallback = vendor.match(/a\.fadeOut\(s\.fadeSpeed,(function\(\)\{[^}]*\})\)/); + assert.ok(fadeCallback, '实际淡出完成回调仍在供应商代码中'); + context.s = { useBodyOverflow: false, onPopupClosed: context.afterClose }; + context.h = true; + visible = false; + vm.runInContext('(' + fadeCallback[1] + ')()', context); + assert.equal(context.h, false, '完成回调先释放内部切换锁'); + assert.equal(rebuilds, 1, '实际关闭完成后恰好重绑一次'); + assert.equal(context.poptroxRebindPending, false); + context.afterClose(); + assert.equal(rebuilds, 1, '重复完成通知不会重复创建灯箱'); + } + // 等待关闭期间重建数据后,待打开请求必须转向同一入口的新相册对象。 + { + const source = read('main'), timers = []; + let visible = true; + const opener = { isConnected: true }; + const oldAlbum = { opener, images: ['/one.webp', '/two.webp'] }; + const freshAlbum = { opener, images: ['/one.webp', '/two.webp'] }; + const popup = { querySelector: () => null }; + const context = { + ALBUMS: [oldAlbum], openRequestSeq: 0, isPopupActive: false, + document: { querySelector: selector => selector === '.poptrox-popup' ? popup : {} }, + getComputedStyle: () => ({ display: visible ? 'block' : 'none' }), + setTimeout: fn => timers.push(fn), Promise, Date + }; + opener.click = function() { + context.isPopupActive = true; + context.activeAlbum = context.pendingPhoto.album; + context.lastSelection = { opener, index: context.pendingPhoto.index }; + context.pendingPhoto = null; + }; + vm.createContext(context); vm.runInContext(helper(source, 'openPhoto', 4), context); + const opening = context.openPhoto(oldAlbum, 1); + assert.equal(timers.length, 1, '关闭中的打开请求先等待'); + context.ALBUMS = [freshAlbum]; + visible = false; + timers.shift()(); + assert.equal(await opening, true); + assert.equal(context.activeAlbum, freshAlbum, '重绑后不再保留旧相册对象'); + assert.equal(context.activePhotoIndex, 1, '重新定位后仍打开原请求照片'); + } + // 跨相册导航也必须保留卡片身份,不能被重复图片 URL 混淆。 + { + const source = read('main'); + const first = { images: ['/shared.webp'] }, second = { images: ['/shared.webp'] }; + const image = { getAttribute: () => '/shared.webp' }; + const popup = { querySelector: () => image }; + const nav = { closest: () => popup, classList: { contains: () => true } }; + let click; + const context = { + ALBUMS: [first, second], activeAlbum: first, normUrl: value => value, + document: { addEventListener: (_, handler) => { click = handler; } }, + inAlbumNav: () => false, captionFadeOut() {} + }; + vm.createContext(context); vm.runInContext(helper(source, 'albumForSrc', 4), context); + const start = source.indexOf(" document.addEventListener('click', function(e) {", source.indexOf('// 捕获阶段拦截')); + const end = source.indexOf(' }, true);', start) + ' }, true);'.length; + vm.runInContext(source.slice(start, end), context); + click({ target: { closest: () => nav } }); + assert.equal(context.activeAlbum, second); + assert.equal(context.albumForSrc('/shared.webp').album, second, '相同 URL 在跨相册后匹配新卡片'); + second.images.push('/shared.webp'); + context.activePhotoIndex = 1; + assert.equal(context.albumForSrc('/shared.webp').idx, 1, '同一相册内复用 URL 的仓库行也保留明确索引'); + } + // 主题色采样复用已解码主图,并保留原有三种代表色。 + // 跨域限制导致采样失败时,只能请求独立缩略图。 + { + const displayed = { complete: true, naturalWidth: 3, naturalHeight: 1, currentSrc: '/small.avif' }; + let drawn, taint = false, result, requests = []; + const source = read('lightbox'); + const context = { + __photoCache: {}, __photoPending: {}, + document: { createElement: () => ({ getContext: () => ({ + drawImage(image) { drawn = image; }, + getImageData() { if (taint && drawn === displayed) throw new Error('tainted'); return { data: [0,255,0,255, 255,0,255,255, 0,0,255,255] }; } + }) }) }, + Image: class { constructor() { this.naturalWidth = 3; this.naturalHeight = 1; requests.push(this); } set src(src) { this.requested = src; } } + }; + vm.createContext(context); + vm.runInContext(['rgbToHex', 'analyzePhoto'].map(name => helper(source, name, 8)).join('\n'), context); + context.analyzePhoto('/full.webp', displayed, '/preview.webp', value => result = value); + assert.equal(drawn, displayed); assert.equal(requests.length, 0); + assert.deepEqual(Array.from(result.colors), ['#00FF00', '#FF00FF', '#0000FF']); + context.analyzePhoto('/full.webp', displayed, '/preview.webp', value => result = value); + assert.equal(requests.length, 0, '重复采样使用缓存'); + taint = true; + let callbackCount = 0; + context.analyzePhoto('/cors.webp', displayed, '/preview.webp', () => callbackCount++); + context.analyzePhoto('/cors.webp', displayed, '/preview.webp', () => callbackCount++); + assert.equal(requests.length, 1, '同一缩略图的并发采样请求合并'); + assert.equal(requests[0].requested, '/preview.webp'); + assert.equal(requests[0].crossOrigin, 'anonymous'); + requests[0].onload(); assert.equal(callbackCount, 2); + context.analyzePhoto('/no-preview.webp', displayed, '/no-preview.webp', value => result = value); + assert.equal(requests.length, 1, '没有独立缩略图时不再解码第二份全尺寸图片'); + assert.deepEqual(Array.from(result.colors), []); + } + // 通过可控网络响应验证真实分页加载器的失败、去重、取消、失效响应、 + // 月份标题和末尾空批次处理;无 AbortController 时也必须能超时和取消。 + for (const abortSupported of [true, false]) { + const source = read('init'); + const wf = new Element(), lm = new Element(), nav = new Element(); + const next = nav.appendChild(new Element('a')); next.setAttribute('rel', 'next'); + lm.setAttribute('data-next-url', '/page2'); + const nodes = { waterfall: wf, 'load-more': lm, 'gallery-pagination': nav, 'month-2026-10': new Element('h2') }; + const pending = [], events = {}, docs = {}, timers = new Map(); + let timerId = 0, throwFetch = false; + let rebinds = 0; + const context = { + document: { getElementById: id => nodes[id] || null, createElement: tag => new Element(tag), documentElement: { offsetHeight: 3000 }, dispatchEvent() {} }, + window: { innerHeight: 800, scrollY: 0, addEventListener: (name, fn) => events[name] = fn, __rebindPoptrox: () => rebinds++ }, + location: { href: 'https://gallery.test/' }, URL, Promise, AbortController: abortSupported ? AbortController : undefined, + setTimeout: fn => { timers.set(++timerId, fn); return timerId; }, clearTimeout: id => timers.delete(id), + applyDims() {}, initImageFallback() {}, + fetch: (url, options) => { + if (throwFetch) throw new Error('synchronous fetch failure'); + return new Promise((resolve, reject) => pending.push({ url, options, resolve, reject })); + }, + DOMParser: class { parseFromString(body) { return docs[body]; } }, + CustomEvent: class { constructor(name, options) { this.type = name; this.detail = options.detail; } } + }; + function response(key, nextUrl, cards = 1) { + const grid = new Element(), pager = new Element(); pager.setAttribute('data-next-url', nextUrl); + const heading = grid.appendChild(new Element('h2', 'gallery-month-label')); heading.id = 'month-2026-10'; + for (let i = 0; i < cards; i++) grid.appendChild(new Element('article', 'thumb')); + docs[key] = { getElementById: id => ({ waterfall: grid, 'load-more': pager }[id]) }; + return { ok: true, text: () => Promise.resolve(key) }; + } + vm.createContext(context); vm.runInContext(helper(source, 'initWaterfall', 2), context); context.initWaterfall(); + const api = context.window.InfinityWaterfall; + const tick = () => new Promise(resolve => setImmediate(resolve)); + async function settled(promise, expected, message) { + assert.equal(await Promise.race([promise, tick().then(() => 'still pending')]), expected, message); + } + const timedOut = api.loadMore(), timedOutRequest = pending.shift(); + assert.equal(timers.size, 1); + [...timers.values()][0](); + await settled(timedOut, false, '超时必须结束请求 Promise,即使 fetch 不支持或忽略 abort'); + assert.equal(lm.getAttribute('aria-busy'), 'false'); + assert.match(lm.children[0].textContent, /加载失败/); + assert.equal(timers.size, 0); + if (abortSupported) assert.equal(timedOutRequest.options.signal.aborted, true); + const timeoutRetry = api.loadMore(), timeoutRetryRequest = pending.shift(); + timedOutRequest.resolve(response('late-timeout', '/wrong', 3)); + await tick(); + assert.equal(wf.children.length, 0, '超时后旧响应不能追加卡片'); + assert.equal(lm.getAttribute('aria-busy'), 'true', '旧请求完成不能释放新请求的 busy 状态'); + timeoutRetryRequest.resolve({ ok: false, status: 503 }); + await settled(timeoutRetry, false, '超时后可立即重试'); + const failed = api.loadMore(); + const joined = api.loadMore(); + assert.equal(joined, failed, '并发调用应等待同一分页结果,不能将 busy 误报为失败'); + assert.equal(pending.length, 1, '并发请求不会重复加载同一页'); + assert.equal(lm.getAttribute('aria-busy'), 'true'); + pending.shift().resolve({ ok: false, status: 503 }); + assert.equal(await failed, false); + assert.equal(await joined, false); + assert.equal(lm.getAttribute('aria-busy'), 'false'); + assert.match(lm.children[0].textContent, /加载失败/); + context.window.scrollY = 4000; events.scroll(); assert.equal(pending.length, 0, '自动加载失败后等待主动重试'); + const retried = api.loadMore(); pending.shift().resolve(response('page2', '/page3')); + assert.equal(await retried, true); assert.equal(wf.children.length, 2); assert.equal(rebinds, 1); + assert.equal(wf.children[0].getAttribute('id'), null, '追加标题的重复 ID 已移除'); + assert.equal(next.getAttribute('href'), '/page3'); + const stale = api.loadMore(); const staleRequest = pending.shift(); + const staleJoined = api.loadMore(); + api.reset({ nextUrl: '/page4' }); + if (abortSupported) assert.equal(staleRequest.options.signal.aborted, true); + await settled(stale, false, 'reset 在底层网络不响应时仍结束旧请求'); + await settled(staleJoined, false, 'reset 同时结束所有并发等待者'); + staleRequest.resolve(response('stale', '/page5', 3)); + assert.equal(await stale, false); assert.equal(wf.children.length, 2, '重置后旧响应不得追加卡片'); + const fresh = api.loadMore(); pending.shift().resolve(response('fresh', '', 1)); + assert.equal(await fresh, true); assert.equal(wf.children.length, 4); + assert.equal(lm.children[1].hidden, true); + api.reset({ nextUrl: '/empty' }); + const empty = api.loadMore(); pending.shift().resolve(response('empty', '', 0)); + assert.equal(await empty, true); assert.equal(lm.children[1].hidden, true); + api.reset({ nextUrl: '/invalid' }); + docs.invalid = { getElementById: () => null }; + const invalid = api.loadMore(); pending.shift().resolve({ ok: true, text: () => Promise.resolve('invalid') }); + assert.equal(await invalid, false); assert.match(lm.children[0].textContent, /加载失败/); + api.cancel(); + api.reset({ nextUrl: '/body-stall' }); + const bodyStall = api.loadMore(); + let finishBody; + pending.shift().resolve({ ok: true, text: () => new Promise(resolve => { finishBody = resolve; }) }); + await tick(); + [...timers.values()][0](); + await settled(bodyStall, false, '响应头已返回但响应体挂起也必须超时'); + finishBody('fresh'); await tick(); + assert.equal(wf.children.length, 5, '超时响应体不会追加旧内容'); + api.reset({ nextUrl: '/cancelled' }); + const cancelled = api.loadMore(), cancelledRequest = pending.shift(); + events.pagehide(); + await settled(cancelled, false, 'pagehide 结束挂起请求'); + assert.equal(timers.size, 0); + assert.equal(lm.getAttribute('aria-busy'), 'false'); + assert.equal(lm.classes.has('pp-load-error'), false, '取消不冒充网络失败'); + cancelledRequest.reject(new Error('late cancellation failure')); + await tick(); + assert.equal(lm.classes.has('pp-load-error'), false); + throwFetch = true; + await settled(api.loadMore(), false, '同步 fetch 异常也释放请求状态'); + assert.equal(lm.getAttribute('aria-busy'), 'false'); + assert.equal(timers.size, 0); + throwFetch = false; + const recovered = api.loadMore(); pending.shift().resolve(response('recovered', '', 1)); + assert.equal(await recovered, true); + assert.equal(lm.classes.has('pp-load-error'), false); + assert.equal(timers.size, 0); + } + console.log('图册预加载、主题色和分页生命周期回归测试通过'); +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/gallery-navigation.cjs b/tests/gallery-navigation.cjs new file mode 100644 index 0000000..0d99eab --- /dev/null +++ b/tests/gallery-navigation.cjs @@ -0,0 +1,75 @@ +'use strict'; +const assert = require('node:assert/strict'); +const install = require('../usr/themes/InfinityTime/assets/js/gallery-navigation.js'); +function fixture(url = 'https://example.test/?q=sunset&tag=city') { + const listeners = new Map(), docListeners = new Map(); + const stack = [{ url, state: {} }]; let index = 0; + const location = { href: url, reloads: 0, reload() { this.reloads++; } }; + const status = { textContent: '' }; + const doc = { addEventListener: (n, fn) => docListeners.set(n, fn), getElementById: () => status }; + const emit = (n, detail) => { const fn = docListeners.get('infinitygallery:' + n); if (fn) fn({ detail }); }; + const win = { + location, scrollX: 0, scrollY: 430, requestAnimationFrame: fn => fn(), + scrollTo(x, y) { this.scrollX = x; this.scrollY = y; }, + addEventListener: (n, fn) => listeners.set(n, fn), + InfinityGallery: { + current: null, + async open(detail) { this.current = detail; emit('open', detail); emit('change', detail); return true; }, + close() { this.current = null; emit('close'); } + }, + history: { + get state() { return stack[index].state; }, + replaceState(state, title, url) { stack[index] = { state, url }; location.href = url; }, + pushState(state, title, url) { stack.splice(++index); stack.push({ state, url }); location.href = url; }, + back() { if (index > 0) { location.href = stack[--index].url; listeners.get('popstate')(); } }, + forward() { if (index + 1 < stack.length) { location.href = stack[++index].url; listeners.get('popstate')(); } } + } + }; + const controller = install(win, doc); + return { win, doc, emit, stack, controller, status }; +} +const tick = () => new Promise(resolve => setImmediate(resolve)); +(async () => { + const f = fixture(); await tick(); + assert.equal(f.controller.selection('https://a.test/?album=7&photo=invalid'), null); + assert.deepEqual(f.controller.selection('https://a.test/?album=7&photo=p-91'), { albumId: '7', photoId: 'p-91' }); + f.emit('open', { albumId: '7', photoId: 'p-91' }); + assert.equal(f.stack.length, 2, '打开灯箱只新增一条历史记录'); + let current = new URL(f.win.location.href); + assert.equal(current.searchParams.get('q'), 'sunset'); assert.equal(current.searchParams.get('tag'), 'city'); + assert.equal(current.searchParams.get('photo'), 'p-91'); + f.emit('change', { albumId: '7', photoId: 'p-92' }); + assert.equal(f.stack.length, 2, '切换照片替换灯箱历史记录'); + f.win.history.back(); await tick(); + assert.equal(f.win.InfinityGallery.current, null, '后退关闭灯箱'); + assert.equal(f.win.scrollY, 430, '后退恢复列表滚动位置'); + assert.equal(new URL(f.win.location.href).searchParams.get('q'), 'sunset', '后退恢复筛选条件'); + f.win.history.forward(); await tick(); + assert.equal(f.win.InfinityGallery.current.photoId, 'p-92', '前进重新打开稳定照片链接'); + assert.equal(f.stack.length, 2, '恢复历史不新增记录'); + f.emit('close'); await tick(); + assert.equal(f.win.InfinityGallery.current, null, '关闭使用本次灯箱历史记录'); + const deep = fixture('https://example.test/?album=7&photo=p-149'); await tick(); + assert.equal(deep.win.InfinityGallery.current.photoId, 'p-149', '直接深链接在就绪后打开'); + assert.equal(deep.stack.length, 1, '初始深链接不新增历史记录'); + deep.emit('close'); + assert.equal(new URL(deep.win.location.href).searchParams.get('photo'), null, '直接链接关闭后清除选中照片且不离开本站'); + assert.equal(new URL(deep.win.location.href).searchParams.get('album'), '7'); + const unavailable = fixture(); await tick(); + unavailable.win.InfinityGallery.open = async () => false; + unavailable.win.history.pushState({}, '', 'https://example.test/?album=99&photo=p-3'); + await unavailable.controller.applyLocation(); + assert.equal(unavailable.win.location.reloads, 1, '历史目标不在当前 DOM 中时重新读取该服务端页面'); + const missing = fixture('https://example.test/?album=99&photo=p-3'); await tick(); + missing.win.InfinityGallery.open = async () => false; + await missing.controller.applyLocation(); + assert.match(missing.status.textContent, /不可用/, '服务端当前页面也没有目标时提示不可用,不循环刷新'); + assert.equal(missing.win.location.reloads, 0); + const refreshed = fixture('https://example.test/?album=7&photo=p-91'); await tick(); + refreshed.win.history.replaceState({ infinityGridScroll: [0, 810] }, '', 'https://example.test/?q=city'); + await refreshed.controller.applyLocation(); + assert.equal(refreshed.win.location.reloads, 1, '深链接刷新后后退会重新读取原筛选列表'); + assert.equal(refreshed.win.history.state.infinityGridScroll[1], 810, '重新读取列表保留滚动恢复信息'); + assert.equal(deep.win.location.reloads, 1, '直接照片链接关闭时重新读取完整相册'); + console.log('图集历史导航:刷新后回退及 24 项导航检查通过'); +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/gallery-performance.php b/tests/gallery-performance.php new file mode 100644 index 0000000..0b5701e --- /dev/null +++ b/tests/gallery-performance.php @@ -0,0 +1,144 @@ +columns = $columns; } + public function from(string $table): self { $this->table = $table; return $this; } + public function where(string $sql, ...$values): self { + foreach ($values as $value) { $sql = substr_replace($sql, Db::$instance->pdo->quote((string)$value), (int)strpos($sql, '?'), 1); } + $this->where[] = '(' . $sql . ')'; return $this; + } + public function order(string $column, string $direction): self { $this->order[] = $column . ' ' . $direction; return $this; } + public function limit(int $n): self { $this->maximum = $n; return $this; } + public function offset(int $n): self { $this->start = $n; return $this; } + public function join(string $table, string $condition): self { $this->joins .= ' INNER JOIN ' . $table . ' ON ' . $condition; return $this; } + public function __toString(): string { + $columns = array_map(static function ($column) { return implode('.', array_map(static function ($part) { return '"' . $part . '"'; }, explode('.', $column))); }, $this->columns); + return 'SELECT ' . implode(',', $columns) . ' FROM ' . $this->table . $this->joins + . ($this->where ? ' WHERE ' . implode(' AND ', $this->where) : '') + . ($this->order ? ' ORDER BY ' . implode(',', $this->order) : '') + . ($this->maximum ? ' LIMIT ' . $this->maximum . ' OFFSET ' . $this->start : ''); + } + } + class Db { + public const SORT_ASC = 'ASC', SORT_DESC = 'DESC'; + public static self $instance; + public \PDO $pdo; + public bool $track = false; + public array $reads = []; + public function __construct() { $this->pdo = new \PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); } + public static function get(): self { return self::$instance; } + public function getPrefix(): string { return 'perf_'; } + public function select(...$columns): Query { return new Query($columns); } + public function fetchAll(Query $query): array { + $rows = $this->pdo->query((string)$query)->fetchAll(\PDO::FETCH_ASSOC); + if ($this->track) { $this->reads[] = ['rows' => count($rows), 'bytes' => strlen(json_encode($rows, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))]; } + return $rows; + } + } +} +namespace { + require __DIR__ . '/../usr/themes/InfinityTime/lib/gallery.php'; + $db = \Typecho\Db::$instance = new \Typecho\Db(); $pdo = $db->pdo; + $pdo->exec('CREATE TABLE perf_contents (cid INTEGER PRIMARY KEY,title TEXT,text TEXT,created INTEGER,status TEXT,type TEXT,password TEXT)'); + $pdo->exec('CREATE TABLE perf_fields (cid INTEGER,name TEXT,str_value TEXT,PRIMARY KEY(cid,name))'); + $pdo->exec('CREATE TABLE perf_relationships (cid INTEGER,mid INTEGER)'); + $pdo->exec('CREATE TABLE perf_metas (mid INTEGER,name TEXT,slug TEXT,type TEXT)'); + $pdo->exec('CREATE TABLE perf_infinitytime_images (id INTEGER PRIMARY KEY,cid INTEGER,sort INTEGER,full TEXT,thumb TEXT,mid TEXT,avif TEXT,mid_avif TEXT,width INTEGER,height INTEGER,exif TEXT,title TEXT,desc TEXT,address TEXT,created INTEGER,original TEXT,gps_lat REAL,gps_lng REAL,hash TEXT)'); + $pdo->exec('CREATE INDEX perf_album_images ON perf_infinitytime_images(cid,sort,id)'); + $pdo->exec("INSERT INTO perf_contents VALUES (1,'Fixture album','',1704067200,'publish','post','')"); + $photoInsert = $pdo->prepare('INSERT INTO perf_infinitytime_images VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'); + $fieldInsert = $pdo->prepare('INSERT INTO perf_fields VALUES (1,?,?)'); + $baseColumns = ['id','cid','full','thumb','width','height','exif','title','desc','address','created']; + $base = 'https://example.test/'; $request = pp_gallery_request(['limit' => 1]); + // 保存旧路径的有界照片查询及字段变体关联,作为数据与耗时比较基线。 + $baseline = static function () use ($db, $request, $base, $baseColumns): array { + return pp_gallery_page($request, static function ($upper, $take, $r) use ($db): array { + $query = $db->select('cid','title','text','created','status','type','password')->from('perf_contents') + ->where('type = ?', 'post')->where('status = ?', 'publish')->where('(password IS NULL OR password = ?)', '') + ->where('created <= ?', time())->order('cid', \Typecho\Db::SORT_DESC)->limit($take); + if ($upper > 0) { $query->where('cid <= ?', $upper); } + if ($r['album'] > 0) { $query->where('cid = ?', $r['album']); } + $rows = $db->fetchAll($query); + if (!$rows) { return []; } + $cids = array_column($rows, 'cid'); $ph = implode(',', array_fill(0, count($cids), '?')); + $names = implode(',', array_fill(0, count(PP_GALLERY_FIELD_NAMES), '?')); + $fields = $db->fetchAll($db->select('cid','name','str_value')->from('perf_fields') + ->where('cid IN (' . $ph . ')', ...$cids)->where('name IN (' . $names . ')', ...PP_GALLERY_FIELD_NAMES)); + // 与旧相册入口相同的第三条批量分类查询;基准夹具没有原生分类。 + $db->fetchAll($db->select('perf_relationships.cid','perf_metas.name','perf_metas.slug','perf_metas.type') + ->from('perf_relationships')->join('perf_metas', 'perf_relationships.mid = perf_metas.mid') + ->where('perf_relationships.cid IN (' . $ph . ')', ...$cids)->where('perf_metas.type IN (?, ?)', 'tag', 'category')); + $grouped = []; foreach ($fields as $field) { $grouped[(int)$field['cid']][$field['name']] = $field['str_value']; } + foreach ($rows as &$row) { $row['fields'] = $grouped[(int)$row['cid']] ?? []; } unset($row); + return $rows; + }, static function ($album, $offset, $take, $target) use ($db, $baseColumns): array { + $rows = $db->fetchAll($db->select(...$baseColumns)->from('perf_infinitytime_images')->where('cid = ?', (int)$album['cid']) + ->order('sort', \Typecho\Db::SORT_ASC)->order('id', \Typecho\Db::SORT_ASC)->limit($take + 1)->offset($offset)); + $variants = pp_gallery_json_array($album['fields']['variants'] ?? ''); + $ids = pp_gallery_json_array($album['fields']['photo_ids'] ?? ''); + $byId = []; $byUrl = []; + foreach ($ids as $index => $id) { $byId[(int)$id] = $variants[$index] ?? []; } + foreach (preg_split('/\r?\n/', (string)($album['fields']['img'] ?? '')) ?: [] as $index => $url) { + $url = pp_gallery_media_url(trim($url)); if ($url !== '') { $byUrl[$url] = $variants[$index] ?? []; } + } + $photos = []; + foreach (array_slice($rows, 0, $take) as $row) { + $row['variants'] = $byId[(int)$row['id']] ?? $byUrl[$row['full']] ?? []; + $photo = pp_gallery_photo($row, (int)$album['cid'], (int)$album['created']); if ($photo) { $photos[] = $photo; } + } + return ['photos' => $photos, 'has_more' => count($rows) > $take, 'consumed' => min($take, count($rows))]; + }, $base); + }; + $projected = static function () use ($request, $base): array { return pp_gallery_read($request, $base); }; + $lastBytes = 0; $boundedBytes = null; + foreach ([100, 1000, 5000] as $size) { + $pdo->exec('DELETE FROM perf_infinitytime_images'); $pdo->exec('DELETE FROM perf_fields'); + $lists = array_fill_keys(['img','thumb','addresses','titles','descs','panos','dims','variants','exif','photo_ids','months'], []); + $pdo->beginTransaction(); + for ($id = 1; $id <= $size; $id++) { + $exif = ['make' => 'Camera', 'model' => 'Fixture', 'datetime' => '2026:01:02 12:34:56', 'note' => str_repeat('x', 1000), '_infinity_media_version' => str_repeat('a', 32)]; + $full = '/full/' . $id . '.webp'; $thumb = '/thumb/' . $id . '.webp'; $mid = '/mid/' . $id . '.webp'; + $avif = '/full/' . $id . '.avif'; $midAvif = '/mid/' . $id . '.avif'; $suffix = '?itv=' . str_repeat('a', 32); + $photoInsert->execute([$id,1,$id,$full,$thumb,$mid,$avif,$midAvif,2400,1600,json_encode($exif),"Photo $id",'Description','Location',1704067200,'/original/private.jpg',51.5,-0.1,'private']); + $lists['img'][] = $full . $suffix; $lists['thumb'][] = $thumb . $suffix; + $lists['addresses'][] = 'Location'; $lists['titles'][] = "Photo $id"; $lists['descs'][] = 'Description'; + $lists['panos'][] = 0; $lists['dims'][] = '2400x1600'; $lists['photo_ids'][] = $id; $lists['months'][] = '2026-01'; + unset($exif['_infinity_media_version']); $lists['exif'][] = $exif; + $lists['variants'][] = ['w' => 2400, 'webp' => [$full . $suffix, $mid . $suffix], 'avif' => [$avif . $suffix, $midAvif . $suffix]]; + } + foreach ($lists as $name => $values) { $fieldInsert->execute([$name, in_array($name, ['img','thumb'], true) ? implode("\n", $values) : json_encode($values, JSON_UNESCAPED_SLASHES)]); } + foreach (['tags' => 'Fixture', 'device' => 'Camera', 'location' => 'Location'] as $name => $value) { $fieldInsert->execute([$name, $value]); } + $pdo->commit(); unset($lists); + $times = ['baseline' => [], 'projected' => []]; + for ($pass = 0; $pass < 5; $pass++) { + foreach ($pass % 2 === 0 ? ['baseline','projected'] : ['projected','baseline'] as $name) { + $start = microtime(true); $result = ${$name}(); $times[$name][] = (microtime(true) - $start) * 1000; + } + } + $db->track = true; $db->reads = []; $before = $baseline(); $beforeReads = $db->reads; + $db->reads = []; $after = $projected(); $afterReads = $db->reads; $db->track = false; + if ($before !== $after || count($after['albums'][0]['photos']) !== 60) { throw new \RuntimeException('投影改变了照片、元数据、变体或分页结果'); } + if (preg_match('/original|gps|private|note|_infinity_media_version/', json_encode($after))) { throw new \RuntimeException('公开响应泄漏私有字段'); } + $beforeBytes = array_sum(array_column($beforeReads, 'bytes')); $afterBytes = array_sum(array_column($afterReads, 'bytes')); + if ($beforeBytes <= $lastBytes || ($boundedBytes !== null && $boundedBytes !== $afterBytes) || $afterBytes >= $beforeBytes) { throw new \RuntimeException('现代仓库返回量必须不随相册聚合字段增长'); } + $lastBytes = $beforeBytes; $boundedBytes = $afterBytes; + foreach ($times as &$values) { sort($values); } unset($values); + printf("%d photos, 1KB EXIF note: old reads %d JSON bytes, projected %d; page median %.2f / %.2f ms; identical 60-photo response\n", $size, $beforeBytes, $afterBytes, $times['baseline'][2], $times['projected'][2]); + } + // 使用真实 SQLite 缺列/缺表错误验证旧安装回退;不迁移表、不触及文件。 + $pdo->exec('CREATE TABLE legacy_images AS SELECT id,cid,sort,full,thumb,width,height,exif,title,desc,address,created FROM perf_infinitytime_images'); + $pdo->exec('DROP TABLE perf_infinitytime_images'); $pdo->exec('ALTER TABLE legacy_images RENAME TO perf_infinitytime_images'); + if ($projected() !== $before) { throw new \RuntimeException('缺少变体列的旧表改变公开输出'); } + $pdo->exec('DROP TABLE perf_infinitytime_images'); + if ($projected() !== $before) { throw new \RuntimeException('无插件表的字段相册改变公开输出'); } + echo "Real SQLite old-schema and field-only fallback outputs identical; no timing thresholds.\n"; +} diff --git a/tests/gallery.cjs b/tests/gallery.cjs new file mode 100644 index 0000000..63cffc9 --- /dev/null +++ b/tests/gallery.cjs @@ -0,0 +1,381 @@ +// Dependency-free functional regressions for the real gallery helpers and handlers. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const source = fs.readFileSync(require.resolve('../usr/themes/InfinityTime/assets/js/main.js'), 'utf8'); +function helper(name) { + const start = source.indexOf(' function ' + name + '('); + assert.ok(start >= 0, name + ' exists'); + return source.slice(start, source.indexOf('\n }', start) + 6); +} +class Element { + constructor(tag, cls = '') { + this.tagName = tag.toUpperCase(); this.children = []; this.attrs = {}; this.style = {}; + this.classes = new Set(cls.split(' ').filter(Boolean)); this.isConnected = true; + this.classList = { contains: v => this.classes.has(v), add: v => this.classes.add(v), remove: v => this.classes.delete(v) }; + } + set className(v) { this.classes = new Set(v.split(' ')); } + get className() { return [...this.classes].join(' '); } + get parentNode() { return this.parent; } + appendChild(el) { el.remove(); el.parent = this; this.children.push(el); return el; } + insertBefore(el, before) { el.remove(); el.parent = this; this.children.splice(this.children.indexOf(before), 0, el); } + remove() { if (this.parent) { this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null; } } + removeChild(el) { el.remove(); return el; } + setAttribute(k, v) { this.attrs[k] = String(v); } + getAttribute(k) { return this.attrs[k] ?? null; } + hasAttribute(k) { return k in this.attrs; } + removeAttribute(k) { delete this.attrs[k]; } + contains(el) { return this === el || this.children.some(c => c.contains(el)); } + querySelectorAll(selector) { + const matches = e => selector === 'picture.pp-picture' ? e.tagName === 'PICTURE' && e.classList.contains('pp-picture') + : selector === 'source[type="image/avif"]' ? e.tagName === 'SOURCE' && e.type === 'image/avif' + : selector === '.pic img' || selector === 'img' ? e.tagName === 'IMG' + : selector.startsWith('.') ? e.classList.contains(selector.slice(1)) : false; + return this.children.flatMap(child => [...(matches(child) ? [child] : []), ...child.querySelectorAll(selector)]); + } + querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } +} +const popup = new Element('div', 'poptrox-popup'); +const pic = popup.appendChild(new Element('div', 'pic')); +const img = pic.appendChild(new Element('img')); +const timers = []; +const context = { + document: { createElement: tag => new Element(tag) }, + variantMap: { + '/a.webp': { w: 2400, webp: ['/a.webp', '/a-small.webp'], avif: ['/a.avif', '/a-small.avif'] }, + '/b.webp': { w: 2200, webp: ['/b.webp'], avif: ['/b.avif'] }, + '/webp-only.webp': { w: 2000, webp: ['/webp-only.webp'] } + }, + isPanoUrl: src => src === '/pano.webp', normUrl: src => src.split('?')[0], + setTimeout: fn => timers.push(fn), window: {}, isPopupActive: true, + albumForSrc: () => ({ album: { images: ['/a.webp', '/b.webp', '/c.webp'] }, idx: 0 }) +}; +vm.createContext(context); +vm.runInContext(['removeResponsivePicture', 'fallbackAvif', 'applyResponsive', 'inAlbumNav'].map(helper).join('\n'), context); +function show(src) { img.setAttribute('src', src); context.applyResponsive(popup, img); } +show('/a.webp'); +assert.equal(img.parentNode.tagName, 'PICTURE'); +assert.match(img.parentNode.querySelector('source[type="image/avif"]').getAttribute('srcset'), /a\.avif/); +show('/legacy.webp'); +assert.equal(img.parentNode, pic, 'no-variant fallback unwraps the reused image'); +assert.equal(pic.querySelectorAll('picture.pp-picture').length, 0); +assert.equal(img.getAttribute('srcset'), null); +assert.equal(img.getAttribute('sizes'), null); +show('/a.webp'); show('/pano.webp'); +assert.equal(img.parentNode, pic, 'panorama fallback removes the old AVIF source'); +assert.equal(img.getAttribute('srcset'), null); +show('/a.webp'); show('/b.webp'); +assert.equal(pic.querySelectorAll('picture.pp-picture').length, 1); +assert.equal(img.parentNode.querySelector('source[type="image/avif"]').getAttribute('srcset'), '/b.avif 2200w'); +show('/webp-only.webp'); +assert.equal(img.parentNode, pic, 'WebP-only variants unwrap old AVIF picture'); +assert.equal(img.getAttribute('srcset'), '/webp-only.webp 2000w'); +show('/a.webp'); +img.remove(); pic.appendChild(img); show('/legacy.webp'); +assert.equal(pic.querySelectorAll('picture.pp-picture').length, 0, 'detached-image wrappers are removed'); + +show('/a.webp'); +assert.equal(context.inAlbumNav(popup, 1), true); +assert.equal(context.inAlbumNav(popup, 1), true, 'rapid clicks are consumed, not delegated across albums'); +assert.equal(timers.length, 2, 'a repeated click schedules no extra transition'); +timers[1](); +assert.equal(img.getAttribute('src'), '/b.webp'); +timers[0](); +context.inAlbumNav(popup, 1); +context.isPopupActive = false; +timers[3](); +assert.equal(img.getAttribute('src'), '/b.webp', 'closing cancels deferred image switch'); +context.isPopupActive = true; +popup.__switchSeq++; +timers[3](); +assert.equal(img.getAttribute('src'), '/b.webp', 'reopening cannot revive the previous session callback'); +popup.__switching = false; +popup.classList.add('loading'); +assert.equal(context.inAlbumNav(popup, 1), true, 'loading consumes navigation'); + +// Run the actual delegated keyboard handler, without exporting production internals. +const keyStart = source.indexOf(" document.addEventListener('keydown', function(e) {"); +const keyEnd = source.indexOf(' }, true);', keyStart) + ' }, true);'.length; +let handler, clicks = [], focus; +const opener = { click: () => clicks.push('open') }; +const button = { closest: () => null }; +const overlay = { querySelector: () => popup, focus: () => { focus = overlay; } }; +popup.querySelector = selector => ({ click: () => clicks.push(selector) }); +Object.assign(context, { + document: { addEventListener: (_, fn) => { handler = fn; }, querySelector: () => overlay, activeElement: null }, + dialogTabStops: () => context.stops || [], + $: () => ({ trigger: name => clicks.push(name) }) +}); +vm.runInContext(source.slice(keyStart, keyEnd), context); +function key(value, target = button, shiftKey = false) { + const event = { key: value, target, shiftKey, preventDefault() { this.prevented = true; }, stopImmediatePropagation() { this.stopped = true; } }; + handler(event); return event; +} +context.isPopupActive = false; +assert.ok(key('Enter', { closest: () => opener }).prevented); +assert.ok(key(' ', { closest: () => opener }).prevented); +assert.deepEqual(clicks, ['open', 'open']); +context.isPopupActive = true; +key('ArrowRight'); key('ArrowLeft'); +assert.deepEqual(clicks.slice(-2), ['.nav-next', '.nav-previous'], 'arrows use the same within-album click path'); +const first = { focus: () => { focus = first; } }, last = { focus: () => { focus = last; } }; +context.stops = [first, last]; context.document.activeElement = last; +key('Tab'); assert.equal(focus, first, 'Tab wraps inside the dialog'); +context.document.activeElement = first; +key('Tab', button, true); assert.equal(focus, last, 'Shift+Tab wraps inside the dialog'); +context.stops = []; key('Tab'); assert.equal(focus, overlay, 'empty dialog is a safe focus fallback'); +key('Escape'); assert.equal(clicks.at(-1), 'poptrox_close'); +console.log('Gallery responsive fallback, navigation cancellation, and keyboard regressions passed'); + +// Fault injection uses the actual load handler: recover before the vendor's +// terminal error handler, retain WebP candidates, and ignore superseded errors. +{ + const p = new Element('div', 'poptrox-popup'); + const box = p.appendChild(new Element('div', 'pic')); + const image = box.appendChild(new Element('img')); + image.setAttribute('src', '/a.webp'); + image.complete = false; + image.naturalWidth = 0; + const listeners = {}; + image.addEventListener = (type, fn, capture) => { listeners[type] = { fn, capture }; }; + image.removeEventListener = (type, fn, capture) => { + if (listeners[type]?.fn === fn && listeners[type].capture === capture) delete listeners[type]; + }; + let terminalErrors = 0, loaded = 0; + const c = { + document: { createElement: tag => new Element(tag) }, variantMap: context.variantMap, + isPopupActive: true, isPanoUrl: () => false, normUrl: src => src, + albumForSrc: () => null, lqipFor: src => src, clearImageError() {}, clearLqip() {}, + captionFadeInAfterImage() {}, showImageError() { terminalErrors++; }, + galleryEvent(name) { if (name === 'loaded') loaded++; }, lastSelection: null, + setTimeout() {}, clearTimeout() {} + }; + vm.createContext(c); + vm.runInContext(['removeResponsivePicture', 'fallbackAvif', 'applyResponsive', 'applyLqip'].map(helper).join('\n'), c); + c.applyLqip(p); + assert.equal(listeners.error.capture, true, 'fallback runs before vendor bubbling listeners'); + let stopped = false; + listeners.error.fn({ stopImmediatePropagation() { stopped = true; } }); + assert.equal(stopped, true, 'recoverable AVIF failure preserves vendor load handlers'); + assert.equal(image.parentNode, box); + assert.equal(image.getAttribute('srcset'), '/a.webp 2400w, /a-small.webp 1600w'); + assert.equal(terminalErrors, 0); + c.applyLqip(p); + assert.equal(image.parentNode, box, 'repeat setup cannot restore a failed AVIF source'); + image.complete = true; image.naturalWidth = 640; + listeners.load.fn(); + assert.equal(image.style.opacity, '1'); + assert.equal(loaded, 1, 'WebP recovery reveals the existing image'); + listeners.error.fn({ stopImmediatePropagation() { throw new Error('WebP error must reach vendor'); } }); + assert.equal(terminalErrors, 1, 'WebP failure remains terminal and retryable'); + const staleFailure = listeners.error.fn; + image.complete = false; image.naturalWidth = 0; image.setAttribute('src', '/b.webp'); + c.applyLqip(p); + staleFailure({ stopImmediatePropagation() { throw new Error('stale error'); } }); + assert.equal(image.parentNode.tagName, 'PICTURE', 'old errors cannot downgrade the new selection'); + assert.match(image.parentNode.querySelector('source[type="image/avif"]').getAttribute('srcset'), /b\.avif/); + c.isPopupActive = false; + listeners.error.fn({ stopImmediatePropagation() { throw new Error('closed error'); } }); + assert.equal(image.parentNode.tagName, 'PICTURE', 'closed lightbox ignores late errors'); + c.isPopupActive = true; + c.URL = URL; c.document.baseURI = 'https://gallery.test/'; + image.currentSrc = 'https://gallery.test/b.webp'; + listeners.error.fn({ stopImmediatePropagation() { throw new Error('unselected AVIF must not swallow WebP error'); } }); + assert.equal(terminalErrors, 2, 'unselected AVIF source does not hide a terminal WebP failure'); + console.log('AVIF failure, WebP recovery, terminal failure, and stale-event regressions passed'); +} + +// Thumbnail recovery tries the fallback image before exposing the retry button. +{ + const init = fs.readFileSync(require.resolve('../usr/themes/InfinityTime/assets/js/init.js'), 'utf8'); + const start = init.indexOf(' function initImageFallback('); + const code = init.slice(start, init.indexOf('\n }', start) + 4); + const article = new Element('article', 'thumb'); + const picture = article.appendChild(new Element('picture')); + const avif = picture.appendChild(new Element('source')); + avif.setAttribute('type', 'image/avif'); avif.setAttribute('srcset', '/thumb.avif'); + const image = picture.appendChild(new Element('img')); + image.setAttribute('src', '/thumb.webp'); image.setAttribute('srcset', '/thumb-small.webp 640w'); + image.setAttribute('data-fallback', '/placeholder.svg'); + image.closest = selector => selector === '.thumb' ? article : picture; + picture.querySelectorAll = selector => selector === 'source' ? [avif] : []; + const events = {}; + image.addEventListener = (type, fn) => { events[type] = fn; }; + const buttons = []; + const c = { document: { + querySelectorAll: () => [image], + createElement(tag) { + const el = new Element(tag); + el.addEventListener = (type, fn) => { el[type] = fn; }; + if (tag === 'button') buttons.push(el); + return el; + } + } }; + vm.createContext(c); vm.runInContext(code, c); c.initImageFallback(); + events.error(); + assert.equal(avif.getAttribute('srcset'), null); + assert.equal(image.getAttribute('srcset'), '/thumb-small.webp 640w'); + assert.equal(buttons.length, 0, 'AVIF-only failure does not show a premature error'); + events.error(); events.error(); + assert.equal(buttons.length, 1, 'all formats failing yields one retry control'); + assert.equal(image.src, '/placeholder.svg'); + buttons[0].click({ preventDefault() {}, stopPropagation() {} }); + assert.equal(avif.getAttribute('srcset'), '/thumb.avif', 'manual retry restores format candidates once'); + events.error(); + assert.equal(avif.getAttribute('srcset'), null); + events.load(); + assert.equal(buttons[0].parentNode, null, 'successful WebP retry removes the error control'); + avif.setAttribute('srcset', '/thumb.avif'); + image.currentSrc = 'https://gallery.test/thumb-small.webp'; + c.URL = URL; c.document.baseURI = 'https://gallery.test/'; + events.error(); + assert.equal(buttons.length, 2, 'unselected AVIF must expose the WebP retry control immediately'); + console.log('Thumbnail AVIF fallback and retry regressions passed'); +} + +// Missing or unusable ResizeObserver must not interrupt panorama registration. +// Execute production mount/destroy helpers with a lightweight viewer double. +{ + const lightbox = fs.readFileSync(require.resolve('../usr/themes/InfinityTime/assets/js/lightbox.js'), 'utf8'); + function panoHelper(name) { + const start = lightbox.indexOf(' function ' + name + '('); + return lightbox.slice(start, lightbox.indexOf('\n }', start) + 10); + } + for (const mode of ['missing', 'throws', 'available']) { + const p = new Element('div', 'poptrox-popup'); + const box = p.appendChild(new Element('div', 'pic')); + box.clientWidth = 640; box.clientHeight = 400; + const image = box.appendChild(new Element('img')); + image.setAttribute('src', '/pano.webp'); + let viewers = 0, destroyed = 0, resized = 0, observed = 0, disconnected = 0; + const pending = []; + const c = { + document: { + createElement(tag) { const el = new Element(tag); el.addEventListener = () => {}; return el; }, + querySelectorAll: () => [p], documentElement: {}, + addEventListener() {}, removeEventListener() {} + }, + window: { pannellum: { viewer() { viewers++; return { + destroy() { destroyed++; }, setSize() { resized++; } + }; } } }, + ppPanoState: null, ppCanUseWebgl: () => true, + ppBindPanoGuard() {}, ppUnbindPanoGuard() {}, + ppFlatPano() { throw new Error('optional observer must not degrade panorama'); }, + setTimeout(fn) { pending.push(fn); } + }; + if (mode !== 'missing') c.ResizeObserver = class { + constructor() { if (mode === 'throws') throw new Error('unsupported'); } + observe() { observed++; } + disconnect() { disconnected++; } + }; + vm.createContext(c); + vm.runInContext(['ppSetPanoActive', 'ppDestroyPano', 'ppMountPano'].map(panoHelper).join('\n'), c); + for (let tick = 0; tick < 10; tick++) c.ppMountPano(p, '/pano.webp'); + assert.equal(viewers, 1, mode + ': polling reuses one registered viewer'); + assert.equal(box.querySelectorAll('.pp-pano-viewer').length, 1); + assert.equal(p.__panoActive, true); + assert.equal(image.style.visibility, 'hidden'); + pending.shift()(); + assert.equal(resized, 1, 'initial deferred sizing remains available'); + assert.equal(observed, mode === 'available' ? 1 : 0); + c.ppDestroyPano(); + assert.equal(destroyed, 1); + assert.equal(disconnected, mode === 'available' ? 1 : 0); + assert.equal(box.querySelectorAll('.pp-pano-viewer').length, 0); + assert.equal(image.style.visibility, ''); + assert.equal(c.ppPanoState, null); + } + console.log('Panorama optional ResizeObserver registration and cleanup regressions passed'); +} + +// Execute the actual vendor integration with queued-animation/event doubles. +// This covers the close-before-open / late-load races and per-instance cleanup. +{ + const animations = []; + const windowNode = new Element('window'), bodyNode = new Element('body'); + const root = bodyNode.appendChild(new Element('main')); + const anchor = root.appendChild(new Element('a')); + anchor.setAttribute('href', '/first.webp'); anchor.appendChild(new Element('img')); + function descendants(node) { return node.children.flatMap(c => [c, ...descendants(c)]); } + function matches(node, selector) { return selector && (selector[0] === '.' ? node.classes.has(selector.slice(1)) : node.tagName === selector.toUpperCase()); } + class Collection extends Array { + constructor(items = []) { super(); this.push(...items); } + css(key, value) { if (typeof key === 'object') this.forEach(n => Object.assign(n.style, key)); else if (value !== undefined) this.forEach(n => n.style[key] = value); return this; } + attr(key, value) { if (value === undefined) return this[0]?.getAttribute(key); this.forEach(n => n.setAttribute(key, value)); return this; } + removeAttr(key) { this.forEach(n => n.removeAttribute(key)); return this; } + prop(key, value) { if (value === undefined) return this[0]?.[key]; this.forEach((n, i) => n[key] = typeof value === 'function' ? value.call(n, i, n[key]) : value); return this; } + data(key, value) { this.forEach(n => n.data ||= {}); if (value === undefined) return this[0]?.data[key]; this.forEach(n => n.data[key] = value); return this; } + find(selector) { return new Collection(this.flatMap(n => descendants(n).filter(c => matches(c, selector)))); } + children() { return new Collection(this.flatMap(n => n.children)); } + add(other) { return new Collection([...this, ...other]); } + each(fn) { this.forEach((n, i) => fn.call(n, i)); return this; } + append(other) { const nodes = $(other); this.forEach(n => nodes.forEach(c => n.appendChild(c))); return this; } + appendTo(parent) { $(parent).append(this); return this; } + prepend() { return this; } + html() { return this; } + width() { return arguments.length ? this : 640; } + height() { return arguments.length ? this : 400; } + outerWidth() { return 640; } + outerHeight() { return 400; } + show() { this.forEach(n => n.visible = true); return this; } + hide() { this.forEach(n => n.visible = false); return this; } + is() { return !!this[0]?.visible; } + addClass(name) { this.forEach(n => n.classes.add(name)); return this; } + removeClass(name) { this.forEach(n => n.classes.delete(name)); return this; } + detach() { this.forEach(n => n.remove()); return this; } + remove() { return this.detach(); } + on(names, callback) { if (typeof callback !== 'function') return this; this.forEach(n => { n.events ||= []; names.split(' ').forEach(name => n.events.push({ name, callback })); }); return this; } + off(name) { this.forEach(n => n.events = (n.events || []).filter(e => name[0] === '.' ? !e.name.endsWith(name) : e.name.split('.')[0] !== name)); return this; } + trigger(name, args = []) { this.forEach(n => (n.events || []).filter(e => e.name.split('.')[0] === name).forEach(e => e.callback.call(n, { preventDefault() {}, stopPropagation() {} }, ...args))); return this; } + fadeTo(speed, opacity, callback) { this.show(); this.forEach(n => animations.push({ node: n, callback })); return this; } + fadeIn(speed, callback) { return this.fadeTo(speed, 1, callback); } + fadeOut(speed, callback) { this.forEach(n => animations.push({ node: n, callback: () => { n.visible = false; if (callback) callback(); } })); return this; } + animate(properties, speed, easing, callback) { return this.fadeTo(speed, 1, callback); } + stop() { this.forEach(n => { for (let i = animations.length - 1; i >= 0; i--) if (animations[i].node === n) animations.splice(i, 1); }); return this; } + } + // Array methods must produce plain arrays, not call our collection constructor. + Object.defineProperty(Collection, Symbol.species, { value: Array }); + function $(value) { + if (value instanceof Collection) return value; + if (value === undefined || value === null) return new Collection(); + if (value === 'body') return new Collection([bodyNode]); + if (typeof value !== 'string') return new Collection([value]); + const tag = value.match(/^<(\w+)/)?.[1] || 'div'; + const cls = value.match(/class="([^"]+)"/)?.[1] || ''; + const node = new Element(tag, cls); + if (cls === 'poptrox-popup') { + for (const c of ['pic', 'closer', 'nav-previous', 'nav-next']) node.appendChild(new Element('div', c)); + } + return new Collection([node]); + } + $.fn = Collection.prototype; $.extend = Object.assign; + const vendorWindow = windowNode; vendorWindow.location = { protocol: 'https:' }; + vm.runInNewContext(fs.readFileSync(require.resolve('../usr/themes/InfinityTime/assets/js/jquery.poptrox.min.js'), 'utf8'), { + jQuery: $, window: vendorWindow, document: { createElement: tag => new Element(tag) } + }); + let opens = 0, closes = 0; + $(root).poptrox({ usePopupDefaultStyling: false, usePopupLoader: false, usePopupCaption: false, usePopupNav: true, usePopupForceClose: true, + onPopupOpen() { opens++; }, onPopupClose() { closes++; } }); + const dialog = $(bodyNode).find('.poptrox-popup'), picture = dialog.find('.pic'); + assert.equal(windowNode.events.length, 3); + function flush() { let n = 0; while (animations.length) { assert.ok(n++ < 100, 'animation queue terminates'); animations.shift().callback?.(); } } + $(anchor).trigger('click'); + dialog.trigger('poptrox_close'); + flush(); + assert.equal(picture.children().length, 0, 'closing opening fade cancels its deferred switch'); + assert.equal(dialog.is(':visible'), false); + $(anchor).trigger('click'); + // Only complete overlay opening, leaving the image load pending. + animations.shift().callback(); + const pendingImage = picture.children(); + assert.equal(pendingImage.length, 1); + assert.equal(pendingImage[0].events.filter(e => e.name === 'load').length, 1); + dialog.trigger('poptrox_close'); + assert.equal(pendingImage[0].events.filter(e => e.name === 'load').length, 0, 'closing detaches the pending image load handler'); + pendingImage.trigger('load'); flush(); + assert.equal(dialog.is(':visible'), false, 'late load cannot show a closed popup'); + root._poptroxDestroy(); + assert.equal(windowNode.events.length, 0, 'destroy removes this instance window handlers'); + assert.equal(opens, 2); assert.equal(closes, 2); + console.log('Poptrox opening/load cancellation and event teardown regressions passed'); +} diff --git a/tests/health-diagnostics.php b/tests/health-diagnostics.php new file mode 100644 index 0000000..c09ea3c --- /dev/null +++ b/tests/health-diagnostics.php @@ -0,0 +1,25 @@ + false]); + if (count($report) !== 8 || scandir($dir) !== $before) { throw new RuntimeException('自检条目或只读约束失败'); } + $byName = array_column($report, null, 'name'); + if ($byName['私有原图保护']['status'] !== 'manual' || $byName['数据库事务']['status'] !== 'manual' || $byName['HEIC/AVIF 工具']['status'] !== 'warning') { throw new RuntimeException('未知结果不能冒充验证通过'); } + $missing = HealthDiagnostics::collect(new HealthDb(), $dir . '/missing', ['heif' => '/private/tool']); + if ($missing[0]['status'] !== 'warning' || $missing[1]['status'] !== 'manual') { throw new RuntimeException('缺失目录需清楚报告'); } + if (strpos(json_encode($missing), '/private/tool') !== false || strpos(json_encode($report), $dir) !== false) { throw new RuntimeException('诊断不得暴露服务器路径'); } + if (is_dir($dir . '/missing')) { throw new RuntimeException('不能自动创建目录'); } + define('__TYPECHO_ADMIN__', true); + $user = new class { public function pass($role, $return): bool { return false; } }; + ob_start(); + include __DIR__ . '/../usr/plugins/InfinityTime/health-panel.php'; + if (ob_get_clean() !== '') { throw new RuntimeException('非管理员不得读取健康报告'); } + echo "只读健康检查:目录/磁盘/工具/手工核验边界及无写入检查通过\n"; +} finally { rmdir($dir); } diff --git a/tests/image-delete.php b/tests/image-delete.php new file mode 100644 index 0000000..e2fd4fa --- /dev/null +++ b/tests/image-delete.php @@ -0,0 +1,345 @@ +kind = $kind; $this->table = $table; $this->fields = $fields ?: ['*']; } + public function from($table): self { $this->table = $table; return $this; } + public function rows($rows): self { $this->rows = $rows; return $this; } + public function where($clause, ...$values): self { + foreach ($values as $value) { $clause = substr_replace($clause, $this->quoteValue($value), strpos($clause, '?'), 1); } + $this->where[] = '(' . $clause . ')'; return $this; + } + public function limit($limit): self { $this->limit = ' LIMIT ' . (int)$limit; return $this; } + public function order($column, $direction): self { $this->order .= ($this->order ? ',' : ' ORDER BY ') . '"' . $column . '" ' . $direction; return $this; } + public function quoteValue($value): string { $this->params[] = $value; return '#param:' . (count($this->params) - 1) . '#'; } + public function prepare(string $sql): string { + return preg_replace_callback('/#param:(\d+)#/', function ($match) { $value = $this->params[(int)$match[1]]; return $value === null ? 'NULL' : Db::get()->pdo->quote((string)$value); }, $sql); + } + public function __toString(): string { + $table = '"' . $this->table . '"'; + if ($this->kind === 'insert') { + $names = implode(',', array_map(static function ($name) { return '"' . $name . '"'; }, array_keys($this->rows))); + $values = implode(',', array_map(function ($value) { return $this->quoteValue($value); }, array_values($this->rows))); + $sql = "INSERT INTO $table ($names) VALUES ($values)"; + } elseif ($this->kind === 'update') { + $values = []; + foreach ($this->rows as $name => $value) { $values[] = '"' . $name . '" = ' . $this->quoteValue($value); } + $sql = "UPDATE $table SET " . implode(',', $values); + } elseif ($this->kind === 'delete') { $sql = "DELETE FROM $table"; } + else { $sql = 'SELECT ' . implode(',', $this->fields) . " FROM $table"; } + return $this->prepare($sql . ($this->where ? ' WHERE ' . implode(' AND ', $this->where) : '') . $this->order . $this->limit); + } + public function isInsert(): bool { return $this->kind === 'insert'; } + } + class Db { + public const SORT_ASC = 'ASC', WRITE = 2, READ = 1; + public static self $instance; + public \PDO $pdo; + public \PDO $replica; + public array $operations = []; + public string $adapter = 'Pdo_SQLite'; + public $beforeWrite = null; + public $beforeSql = null; + public function __construct() { + $this->pdo = new \PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + $this->replica = new \PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + } + public static function get(): self { return self::$instance; } + public function getPrefix(): string { return 'sync_'; } + public function getAdapterName(): string { return $this->adapter; } + public function selectDb($op) { return $op === self::WRITE ? $this->pdo : $this->replica; } + public function select(...$fields): Query { return new Query('select', '', $fields); } + public function insert($table): Query { return new Query('insert', $table); } + public function delete($table): Query { return new Query('delete', $table); } + public function fetchRow($query) { return $this->query($query)->fetch(\PDO::FETCH_ASSOC); } + public function fetchAll($query): array { return $this->query($query)->fetchAll(\PDO::FETCH_ASSOC); } + public function query($query, $op = self::READ) { + if ($query instanceof \PDOStatement) { return $query; } + if ($query instanceof Query) { + // 真实 Db::query 覆盖传入的 WRITE;未编译的 SELECT 应故意去空副本失败。 + $op = preg_match('/^SELECT /', (string)$query) ? self::READ : self::WRITE; + } + $sql = (string)$query; + if ($this->beforeSql) { ($this->beforeSql)($sql); } + $this->operations[] = ['sql' => $sql, 'op' => $op, 'object' => is_object($query)]; + if ($this->beforeWrite && preg_match('/^(INSERT|DELETE)/', $sql)) { + $callback = $this->beforeWrite; + $this->beforeWrite = null; + $callback(); + } + return $this->selectDb($op)->query($sql); + } + } +} +namespace TypechoPlugin\InfinityTime { + class Plugin { + public static int $migrations = 0; + public static function migrateSchema(): void { self::$migrations++; throw new \RuntimeException('事务内禁止 DDL'); } + public static array $logs = []; + public static function log($message): void { self::$logs[] = $message; } + } +} +namespace { + use TypechoPlugin\InfinityTime\Lib\ImageRepository as Repository; + require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php'; + require __DIR__ . '/../usr/plugins/InfinityTime/Lib/ImageRepository.php'; + $root = sys_get_temp_dir() . '/infinitytime-delete-' . bin2hex(random_bytes(8)); + mkdir($root, 0700); + define('__TYPECHO_ROOT_DIR__', $root); + define('__TYPECHO_UPLOAD_ROOT_DIR__', $root . '/uploads'); + define('__TYPECHO_UPLOAD_DIR__', '/uploads'); + foreach (['original', 'full', 'thumb'] as $dir) { mkdir($root . '/uploads/' . $dir, 0700, true); } + $db = \Typecho\Db::$instance = new \Typecho\Db(); + $pdo = $db->pdo; + $checks = 0; + function expect(bool $ok, string $message): void { + global $checks; + if (!$ok) { throw new \RuntimeException($message); } + $checks++; + } + function snapshot(): array { + global $pdo; + return [ + $pdo->query('SELECT * FROM sync_infinitytime_images ORDER BY id')->fetchAll(\PDO::FETCH_ASSOC), + $pdo->query('SELECT * FROM sync_fields ORDER BY cid,name')->fetchAll(\PDO::FETCH_ASSOC), + $pdo->query('SELECT * FROM sync_contents ORDER BY cid')->fetchAll(\PDO::FETCH_ASSOC), + ]; + } + function addImage(int $id, int $cid): array { + global $pdo, $root; + $paths = ['/uploads/original/' . $id . '.jpg', '/uploads/full/' . $id . '.webp', '/uploads/thumb/' . $id . '.webp']; + foreach ($paths as $path) { file_put_contents($root . $path, 'fixture-' . $id); } + $query = $pdo->prepare('INSERT INTO sync_infinitytime_images (id,cid,sort,original,full,thumb,width,height,exif,created) VALUES (?,?,0,?,?,?,200,100,?,1704067200)'); + $query->execute([$id,$cid,$paths[0],$paths[1],$paths[2],json_encode(['camera'=>'fixture', 'gps'=>['lat'=>10,'lng'=>20]])]); + Repository::syncPostFields($cid); + return $paths; + } + function filesExist(array $paths): bool { + global $root; + foreach ($paths as $path) { if (!is_file($root . $path)) { return false; } } + return true; + } + function failDelete(int $id, int $cid, string $message): void { + $thrown = false; + try { Repository::deleteImage($id, $cid); } catch (\Throwable $e) { $thrown = true; } + expect($thrown, $message); + } + try { + $pdo->exec('PRAGMA foreign_keys = ON'); + $pdo->exec('CREATE TABLE sync_contents (cid INTEGER PRIMARY KEY, title TEXT)'); + $pdo->exec('CREATE TABLE sync_fields (cid INTEGER, name TEXT, type TEXT, str_value TEXT, int_value INTEGER DEFAULT 0, float_value REAL DEFAULT 0, PRIMARY KEY(cid,name))'); + $pdo->exec('CREATE TABLE sync_infinitytime_images (id INTEGER PRIMARY KEY, cid INTEGER, sort INTEGER, original TEXT, full TEXT, thumb TEXT, mid TEXT, avif TEXT, mid_avif TEXT, width INTEGER, height INTEGER, exif TEXT, created INTEGER)'); + $paths = addImage(1, 1); + $variants = ['/uploads/full/1@1600.webp', '/uploads/full/1.avif', '/uploads/full/1@1600.avif']; + foreach ($variants as $path) { file_put_contents($root . $path, 'variant'); $paths[] = $path; } + $q = $pdo->prepare('UPDATE sync_infinitytime_images SET mid=?,avif=?,mid_avif=? WHERE id=1'); + $q->execute($variants); + Repository::syncPostFields(1); + $original = snapshot(); + $pdo->exec("CREATE TRIGGER fail_delete BEFORE DELETE ON sync_infinitytime_images BEGIN SELECT RAISE(ABORT, 'injected row failure'); END"); + failDelete(1, 1, '图片行删除失败必须传播'); + expect(snapshot() === $original && filesExist($paths), '行删除失败保持数据库和文件'); + $pdo->exec('DROP TRIGGER fail_delete'); + $pdo->exec("CREATE TRIGGER fail_sync BEFORE INSERT ON sync_fields WHEN NEW.name = 'thumb' BEGIN SELECT RAISE(ABORT, 'injected field failure'); END"); + failDelete(1, 1, '删除最后一图时字段失败必须传播'); + expect(snapshot() === $original && filesExist($paths), '字段写入失败完整回滚行和字段,文件不变'); + $pdo->exec('BEGIN'); + $pdo->exec("INSERT INTO sync_fields (cid,name,type,str_value) VALUES (99,'unrelated','str','caller-before')"); + $outer = snapshot(); + failDelete(1, 1, '外层事务中同样传播字段错误'); + expect(snapshot() === $outer && filesExist($paths), '失败不回滚调用方其他写入'); + $pdo->exec('DROP TRIGGER fail_sync'); + $pdo->exec('COMMIT'); + $before = snapshot(); + $pdo->exec('BEGIN'); + expect(Repository::deleteImage(1, 1), '外层事务内删除成功'); + expect(filesExist($paths), '未提交外层事务时绝不能删除文件'); + expect($pdo->query('SELECT COUNT(*) FROM sync_infinitytime_images WHERE id=1')->fetchColumn() == 0, '事务内图片行已删除'); + $pdo->exec('ROLLBACK'); + expect(snapshot() === $before && filesExist($paths), '调用方回滚后行字段文件均完好'); + $pdo->exec('SAVEPOINT caller'); + expect(Repository::deleteImage(1, 1), '外层保存点内删除成功'); + $pdo->exec('ROLLBACK TO SAVEPOINT caller'); + $pdo->exec('RELEASE SAVEPOINT caller'); + expect(snapshot() === $before && filesExist($paths), '调用方保存点不能被提前提交'); + + $pdo->exec('CREATE TABLE parents (id INTEGER PRIMARY KEY)'); + $pdo->exec('CREATE TABLE deferred_failure (id INTEGER REFERENCES parents(id) DEFERRABLE INITIALLY DEFERRED)'); + $pdo->exec('CREATE TRIGGER fail_commit AFTER DELETE ON sync_infinitytime_images BEGIN INSERT INTO deferred_failure VALUES (99); END'); + failDelete(1, 1, '真实 COMMIT 约束错误必须传播'); + expect(snapshot() === $before && filesExist($paths), '提交失败回滚且不删除文件'); + $pdo->exec('DROP TRIGGER fail_commit'); + expect(!Repository::deleteImage(1, 2) && !Repository::deleteImage(999, 1), '不匹配 cid 或不存在的 id 不修改任何数据'); + expect(snapshot() === $before && filesExist($paths), '错误目标无副作用'); + expect(Repository::deleteImage(1, 1), '独立删除成功'); + foreach ($paths as $path) { expect(!is_file($root . $path), '提交后清理原图及公开变体'); } + $empty = $pdo->query('SELECT name,str_value FROM sync_fields WHERE cid=1 ORDER BY name')->fetchAll(\PDO::FETCH_ASSOC); + expect($empty === [['name'=>'img','str_value'=>''],['name'=>'thumb','str_value'=>'']], '最后一图清空派生元数据并保留空图集标记'); + + $paths2 = addImage(2, 2); + addImage(3, 3); + $q = $pdo->prepare('UPDATE sync_infinitytime_images SET original=?,full=?,thumb=? WHERE id=3'); + $q->execute($paths2); + Repository::syncPostFields(3); + expect(Repository::deleteImage(2, 2) && filesExist($paths2), '其他仓库行共享路径时保留全部文件'); + $q = $pdo->prepare('INSERT INTO sync_fields (cid,name,type,str_value) VALUES (?,?,?,?)'); + $q->execute([9,'img','str','https://legacy.example' . $paths2[0] . "\n" . $paths2[1] . "?cache=1#preview"]); + $q->execute([9,'variants','str',json_encode([['webp'=>[str_replace('/2.webp', '/%32.webp', $paths2[2])],'avif'=>[]]])]); + expect(Repository::deleteImage(3, 3) && filesExist($paths2), '历史字段及 variants 引用保护共享实体'); + + foreach ([ + 42 => '/uploads/full/temp/../42.webp', + 43 => '/uploads/full/temp/%2e%2e/43.webp?cache=1#preview', + 44 => '/uploads/full/./44.webp', + 45 => '/uploads/full/%2E/45.webp', + 46 => '//legacy.example/uploads/full/46.webp?cache=1#preview', + 47 => '/uploads/full/temp\\..\\47.webp', + ] as $id => $legacyPath) { + $legacyPaths = addImage($id, $id); + $q = $pdo->prepare('INSERT INTO sync_fields (cid,name,type,str_value) VALUES (?,?,?,?)'); + $q->execute([$id + 100,'img','str',$legacyPath]); + expect(Repository::deleteImage($id, $id), '历史点目录引用测试删除行成功'); + expect(is_file($root . $legacyPaths[1]), '浏览器规范化后的点目录 URL 必须保护真实图片'); + touch($root . $legacyPaths[1], time() - 7200); + $legacyCandidates = Repository::orphanCandidates(); + foreach ($legacyCandidates as $candidate) { + expect($candidate['path'] !== $root . $legacyPaths[1], '旧图片孤儿扫描同样保护点目录引用'); + } + } + + $paths4 = addImage(4, 4); + addImage(5, 4); + expect(Repository::deleteImage(4, 4), '多图删除成功'); + $public = $pdo->query('SELECT str_value FROM sync_fields WHERE cid=4')->fetchAll(\PDO::FETCH_COLUMN); + $public = implode("\n", $public); + expect(strpos($public, '/original/') === false && strpos($public, '"gps"') === false, '字段同步不公开原图路径和 GPS'); + expect(strpos($public, '/uploads/full/5.webp') !== false && strpos($public, '/uploads/full/4.webp') === false, '剩余图路径按行重新同步'); + + $paths6 = addImage(6, 6); + $committed = false; + $db->beforeSql = static function ($sql) use (&$committed): void { + if ($sql === 'COMMIT') { $committed = true; } + if ($committed && strpos($sql, 'SELECT original,full,thumb') === 0) { throw new \RuntimeException('injected cleanup read failure'); } + }; + expect(Repository::deleteImage(6, 6), '提交后引用读取失败不得伪报删除失败'); + $db->beforeSql = null; + expect(filesExist($paths6) && $pdo->query('SELECT COUNT(*) FROM sync_infinitytime_images WHERE id=6')->fetchColumn() == 0, '清理失败保留孤儿文件且数据库删除已提交'); + expect(count(\TypechoPlugin\InfinityTime\Plugin::$logs) > 0, '延迟清理原因可诊断'); + + $paths7 = addImage(7, 7); + chmod($root . '/uploads/original', 0500); + $canUnlink = is_writable($root . '/uploads/original'); + expect(Repository::deleteImage(7, 7), 'unlink 失败不影响已提交的成功结果'); + if (!$canUnlink) { expect(is_file($root . $paths7[0]), '真实权限错误留下原图孤儿'); } + expect(!is_file($root . $paths7[1]), '单个 unlink 失败不妨碍后续文件清理'); + chmod($root . '/uploads/original', 0700); + expect($pdo->query('SELECT COUNT(*) FROM sync_infinitytime_images WHERE id=7')->fetchColumn() == 0, '清理部分失败不会恢复数据库引用'); + + $paths8 = addImage(8, 8); + file_put_contents($root . '/outside.jpg', 'do not delete'); + symlink($root . '/outside.jpg', $root . '/uploads/original/link.jpg'); + $pdo->exec("UPDATE sync_infinitytime_images SET original='/uploads/original/link.jpg', full='/outside.jpg', thumb='/uploads/thumb/../thumb/8.webp' WHERE id=8"); + expect(Repository::deleteImage(8, 8), '污染路径也可安全移除数据库行'); + expect(is_link($root . '/uploads/original/link.jpg') && is_file($root . '/outside.jpg') && filesExist($paths8), '软链接、白名单外路径和穿越路径全部跳过'); + + $paths9 = addImage(9, 9); + $pdo->exec('BEGIN'); + Repository::deleteImage(9, 9); + $pdo->exec('COMMIT'); + expect(filesExist($paths9), '调用方提交仍保守保留文件,直到安全孤儿清理'); + foreach ($paths9 as $path) { touch($root . $path, time() - 7200); } + $candidates = Repository::orphanCandidates(); + expect(Repository::removeOrphanCandidates($candidates) >= 3 && !filesExist($paths9), '已提交外层事务留下的孤儿可由现有安全流程清理'); + expect(filesExist($paths2), '后续孤儿清理仍保护历史引用'); + // 图集删除只改变授权 cid 的原有三表;失败和外层事务边界必须完整保留。 + $pdo->exec("INSERT INTO sync_contents VALUES (100,'target'),(200,'other'),(300,'legacy')"); + $albumPaths = array_merge(addImage(100, 100), addImage(101, 100)); + $otherPaths = addImage(200, 200); + $albumBefore = snapshot(); + foreach (['sync_infinitytime_images', 'sync_fields', 'sync_contents'] as $table) { + $pdo->exec("CREATE TRIGGER fail_album BEFORE DELETE ON $table WHEN OLD.cid=100 BEGIN SELECT RAISE(ABORT, 'injected album failure'); END"); + $failed = false; + try { Repository::deleteAlbum(100); } catch (\Throwable $e) { $failed = true; } + expect($failed && snapshot() === $albumBefore && filesExist($albumPaths), '任一图集表删除失败均保留三表与全部文件'); + $pdo->exec('DROP TRIGGER fail_album'); + } + $pdo->exec('BEGIN'); + $pdo->exec("UPDATE sync_contents SET title='caller-before' WHERE cid=200"); + $albumOuter = snapshot(); + $pdo->exec("CREATE TRIGGER fail_album BEFORE DELETE ON sync_contents WHEN OLD.cid=100 BEGIN SELECT RAISE(ABORT, 'injected album failure'); END"); + $failed = false; + try { Repository::deleteAlbum(100); } catch (\Throwable $e) { $failed = true; } + expect($failed && snapshot() === $albumOuter && filesExist($albumPaths), '图集失败不回滚外层其他内容修改'); + $pdo->exec('DROP TRIGGER fail_album'); + expect(Repository::deleteAlbum(100) && filesExist($albumPaths), '外层事务内图集删除不清理文件'); + $pdo->exec('ROLLBACK'); + expect(snapshot() === $albumBefore && filesExist($albumPaths), '外层回滚恢复完整图集及文件'); + $pdo->exec('SAVEPOINT album_caller'); + expect(Repository::deleteAlbum(100) && filesExist($albumPaths), '图集删除不跨越外层保存点'); + $pdo->exec('ROLLBACK TO SAVEPOINT album_caller'); + $pdo->exec('RELEASE SAVEPOINT album_caller'); + expect(snapshot() === $albumBefore && filesExist($albumPaths), '图集保存点回滚后完整恢复'); + $pdo->exec('CREATE TRIGGER fail_album_commit AFTER DELETE ON sync_contents WHEN OLD.cid=100 BEGIN INSERT INTO deferred_failure VALUES (99); END'); + $failed = false; + try { Repository::deleteAlbum(100); } catch (\Throwable $e) { $failed = true; } + expect($failed && snapshot() === $albumBefore && filesExist($albumPaths), '图集真实提交约束失败不清理文件'); + $pdo->exec('DROP TRIGGER fail_album_commit'); + expect(!Repository::deleteAlbum(0) && !Repository::deleteAlbum(99999), '不存在图集不改任何数据'); + expect(snapshot() === $albumBefore, '错误图集目标无副作用'); + // 外图集仓库行及仅有字段的图集分别引用一张,其他文件正常回收。 + $q = $pdo->prepare('UPDATE sync_infinitytime_images SET original=? WHERE id=200'); + $q->execute([$albumPaths[0]]); + $q = $pdo->prepare('INSERT INTO sync_fields (cid,name,type,str_value) VALUES (?,?,?,?)'); + $q->execute([300,'img','str','/uploads/full/temp/../101.webp?cache=1']); + $otherBefore = $pdo->query('SELECT * FROM sync_contents WHERE cid<>100 ORDER BY cid')->fetchAll(\PDO::FETCH_ASSOC); + $operationStart = count($db->operations); + expect(Repository::deleteAlbum(100), '独立图集删除成功'); + expect($pdo->query('SELECT COUNT(*) FROM sync_contents WHERE cid=100')->fetchColumn() == 0 + && $pdo->query('SELECT COUNT(*) FROM sync_fields WHERE cid=100')->fetchColumn() == 0 + && $pdo->query('SELECT COUNT(*) FROM sync_infinitytime_images WHERE cid=100')->fetchColumn() == 0, '图集三表完整删除'); + expect($pdo->query('SELECT * FROM sync_contents WHERE cid<>100 ORDER BY cid')->fetchAll(\PDO::FETCH_ASSOC) === $otherBefore, '保留其他图集内容'); + expect(is_file($root . $albumPaths[0]) && is_file($root . $albumPaths[4]), '图集共享原图及历史点目录引用受到保护'); + foreach ([1,2,3,5] as $index) { expect(!is_file($root . $albumPaths[$index]), '其余无引用图集文件在提交后删除'); } + expect(filesExist($otherPaths), '其他图集文件不受影响'); + $referenceScans = 0; + foreach (array_slice($db->operations, $operationStart) as $operation) { + if (strpos($operation['sql'], 'SELECT original,full,thumb,mid,avif,mid_avif FROM "sync_infinitytime_images"') === 0 + && strpos($operation['sql'], ' WHERE ') === false) { $referenceScans++; } + } + expect($referenceScans === 1, '整图集仅执行一次全站图片引用扫描'); + expect(!Repository::deleteAlbum(100), '重复删除图集返回不存在'); + expect(Repository::deleteAlbum(300), '仅有历史字段的空仓库图集仍可删除'); + expect(is_file($root . $albumPaths[4]), '没有仓库行的历史图片不越权直接删除'); + $pdo->exec("INSERT INTO sync_contents VALUES (400,'cleanup-failure')"); + $cleanupPaths = addImage(400, 400); + $committed = false; + $db->beforeSql = static function ($sql) use (&$committed): void { + if ($sql === 'COMMIT') { $committed = true; } + if ($committed && strpos($sql, 'SELECT original,full,thumb') === 0) { throw new \RuntimeException('album cleanup failure'); } + }; + expect(Repository::deleteAlbum(400), '图集提交后清理失败仍返回成功'); + $db->beforeSql = null; + expect(filesExist($cleanupPaths) && $pdo->query('SELECT COUNT(*) FROM sync_contents WHERE cid=400')->fetchColumn() == 0, '图集清理失败仅保留孤儿'); + expect(\TypechoPlugin\InfinityTime\Plugin::$migrations === 0, '整个删除流程不运行隐式提交 DDL'); + foreach ($db->operations as $operation) { + expect($operation['op'] === \Typecho\Db::WRITE && !$operation['object'], '删除、同步和引用检查固定主库'); + } + echo "图片删除原子性: $checks 项检查通过\n"; + } finally { + $db->beforeSql = null; + Repository::unlockMedia(); + @chmod($root . '/uploads/original', 0700); + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($it as $file) { if ($file->isDir() && !$file->isLink()) { @rmdir($file->getPathname()); } else { @unlink($file->getPathname()); } } + @rmdir($root); + } +} diff --git a/tests/maintenance-browser.cjs b/tests/maintenance-browser.cjs new file mode 100644 index 0000000..b40e690 --- /dev/null +++ b/tests/maintenance-browser.cjs @@ -0,0 +1,82 @@ +// 真实浏览器运行后台脚本;所有请求拦截,无线上数据或媒体操作。 +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const playwright = require('playwright'); +const engine = process.env.PLAYWRIGHT_BROWSER || 'chromium'; +assert.ok(['chromium', 'firefox', 'webkit'].includes(engine)); +const source = fs.readFileSync(require.resolve('../usr/plugins/InfinityTime/assets/admin.js'), 'utf8'); +const parentId = 'a'.repeat(32), childId = 'b'.repeat(32); +const failures = Array.from({ length: 123 }, (_, i) => ({ task: i === 122 ? 'resync' : 'rebuild', id: i + 1, cid: 10, reason: i === 0 ? '编码失败' : '图集字段同步失败;重试时只同步字段' })); +let state = { job: 'rebuild', job_id: parentId, total: 200, done: 200, failed: 123, finished: true }; +function response(offset = 0) { return { ...state, failures: failures.slice(offset, offset + 50), failure_total: failures.length, failure_offset: offset, failure_next: offset + 50 < failures.length ? offset + 50 : null }; } +function html() { + const job = state.finished && state.failed ? response() : { ...state, failures: [], failure_total: 0 }; + return `
+ +
+ +
`; +} +(async () => { + const browser = await playwright[engine].launch({ headless: true, ...(engine === 'chromium' && process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); + try { + const page = await browser.newPage(); + const errors = [], requests = [], pending = []; + page.on('pageerror', error => errors.push(error.message)); + await page.route('https://maintenance.test/**', async route => { + const request = route.request(), path = new URL(request.url()).pathname; + if (request.method() === 'POST') { + const fields = {}; + for (const match of request.postData().matchAll(/name="([^"]+)"\r\n\r\n([^\r]*)/g)) fields[match[1]] = match[2]; + requests.push(fields); pending.push(route); return; + } + await route.fulfill({ contentType: path === '/admin.js' ? 'text/javascript' : 'text/html', body: path === '/admin.js' ? source : html() }); + }); + async function nextRequest(count) { + await page.waitForFunction(() => document.querySelector('#pp-retry-failures').disabled || document.querySelector('#pp-failure-next').disabled); + const deadline = Date.now() + 5000; + while (requests.length < count && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10)); + assert.equal(requests.length, count, '请求数量应精确且没有重复'); + assert.equal(requests[count - 1]._, 'test-token'); + return requests[count - 1]; + } + async function reply(data) { await pending.shift().fulfill({ contentType: 'application/json', body: JSON.stringify(data) }); } + await page.goto('https://maintenance.test/'); + assert.equal(await page.locator('#pp-failure-list li').count(), 50, '刷新后立即显示有界失败清单'); + assert.equal(await page.locator('#pp-failure-list img').count(), 0, '错误内容只能显示为文本'); + await page.evaluate(() => { document.querySelector('#pp-failure-next').click(); document.querySelector('#pp-failure-next').click(); }); + assert.deepEqual(await nextRequest(1), { job: 'rebuild', mode: 'errors', job_id: parentId, offset: '50', ajax: '1', action: 'maintenance', _: 'test-token' }); + await reply(response(50)); + await page.waitForFunction(() => document.querySelector('#pp-failure-summary').textContent.includes('51–100')); + await page.click('#pp-failure-next'); await nextRequest(2); await reply(response(100)); + await page.waitForFunction(() => document.querySelector('#pp-failure-summary').textContent.includes('101–123')); + assert.equal(await page.locator('#pp-failure-list li').count(), 23); + assert.match(await page.locator('#pp-failure-list li').last().textContent(), /相册 ID 123/); + await page.evaluate(() => { document.querySelector('#pp-retry-failures').click(); document.querySelector('#pp-retry-failures').click(); document.querySelector('[data-run="resync"]').click(); }); + assert.equal((await nextRequest(3)).mode, 'retry'); + assert.equal(requests[2].job_id, parentId); + // 服务端已建立子任务,但首次响应丢失。 + state = { job: 'rebuild', job_id: childId, retry_of: parentId, total: 123, done: 3, failed: 0, finished: false }; + await pending.shift().abort('failed'); + await page.waitForFunction(() => !document.querySelector('#pp-retry-failures').disabled); + await page.click('#pp-retry-failures'); + assert.equal((await nextRequest(4)).mode, 'retry'); + assert.equal(requests[3].job_id, parentId, '丢失响应后必须重复同一原任务重试请求'); + await reply({ ...state, failures: [], failure_total: 0 }); + assert.equal((await nextRequest(5)).mode, 'poll'); + assert.equal(requests[4].job_id, childId); + await pending.shift().abort('failed'); + await page.waitForFunction(() => !document.querySelector('[data-run="rebuild"]').disabled); + await page.reload(); + await page.getByRole('button', { name: '继续', exact: true }).click(); + assert.equal((await nextRequest(6)).mode, 'resume'); + assert.equal(requests[5].job_id, childId, '刷新恢复继续同一个子任务身份'); + state = { ...state, done: 123, finished: true }; + await reply({ ...state, failures: [], failure_total: 0 }); + await page.waitForFunction(() => document.querySelector('#pp-msg-rebuild').textContent.includes('✓ 完成')); + assert.equal(await page.locator('#pp-maintenance-failures').isVisible(), false); + assert.deepEqual(errors, [], '后台脚本无浏览器异常'); + console.log(`维护真实浏览器 ${engine}:失败分页、文本安全、重复点击、丢失响应和刷新续跑通过`); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/maintenance-client.cjs b/tests/maintenance-client.cjs new file mode 100644 index 0000000..7a6d500 --- /dev/null +++ b/tests/maintenance-client.cjs @@ -0,0 +1,159 @@ +// 使用真实后台脚本和可控 DOM/网络/定时器,验证维护任务协议及故障恢复。 +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const source = fs.readFileSync(require.resolve('../usr/plugins/InfinityTime/assets/admin.js'), 'utf8'); +class Element { + constructor(job = '') { this.job = job; this.children = []; this.style = {}; this.listeners = {}; this.disabled = false; this.textContent = ''; } + set textContent(value) { this._text = value; this.children = []; } + get textContent() { return this._text + this.children.map(child => child.textContent).join(''); } + appendChild(child) { this.children.push(child); child.parentNode = this; } + addEventListener(name, listener) { this.listeners[name] = listener; } + emit(name) { this.listeners[name]?.({ target: this }); } + getAttribute(name) { return name === 'data-run' ? this.job || 'rebuild' : null; } + querySelector(selector) { return selector === 'button' ? (this.button ||= new Element()) : null; } + insertBefore(child) { this.child = child; child.parentNode = this; } + removeChild(child) { assert.equal(this.child, child, '继续任务提示应移除自身'); this.child = null; } +} +class FormDataMock { + constructor() { this.fields = new Map(); } + set(name, value) { this.fields.set(name, value); } + append(name, value) { this.set(name, value); } + get(name) { return this.fields.get(name); } +} +const flush = async () => { for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve)); }; +function harness(job = null) { + const button = new Element('rebuild'), otherButton = new Element('resync'), bar = new Element(), message = new Element(), container = new Element(); + const nodes = { '[data-run="rebuild"]': button, '[data-run="resync"]': otherButton, '#pp-bar-rebuild': bar, '#pp-msg-rebuild': message, '.container.typecho-page-main': container }; + for (const id of ['pp-maintenance-failures', 'pp-failure-summary', 'pp-failure-list', 'pp-failure-prev', 'pp-failure-next', 'pp-retry-failures']) nodes['#' + id] = new Element(); + const listeners = {}, requests = [], timers = [], replies = []; + const document = { + querySelector: selector => nodes[selector] || null, + querySelectorAll: selector => selector === '[data-run]' ? [button, otherButton] : [], + addEventListener: (name, listener) => { listeners[name] = listener; }, + createElement: () => new Element(), + }; + vm.runInNewContext(source, { + window: { PP_ADMIN: { url: '/admin/panel', token: 'fixture-token', job } }, document, FormData: FormDataMock, + setTimeout: (fn, delay) => { timers.push({ fn, delay }); }, + fetch: (url, options) => { + requests.push({ url, ...options }); + return new Promise((resolve, reject) => replies.push({ resolve, reject })); + }, + }); + listeners.DOMContentLoaded(); + return { button, otherButton, bar, message, container, requests, timers, nodes, + reply(data, ok = true) { replies.shift().resolve({ ok, json: async () => data }); }, + reject() { replies.shift().reject(new Error('模拟网络中断')); }, + malformed() { replies.shift().resolve({ ok: true, json: async () => { throw new Error('JSON'); } }); }, + poll() { assert.equal(timers.length, 1, '每次只能排队一次轮询'); const timer = timers.shift(); assert.equal(timer.delay, 300); timer.fn(); }, + }; +} +(async () => { + const h = harness(); + h.button.emit('click'); h.button.emit('click'); + assert.equal(h.requests.length, 1, '双击不得启动重复任务'); + assert.equal(h.button.disabled, true, '运行时禁用启动按钮'); + const first = h.requests[0]; + assert.equal(first.method, 'POST', '维护任务必须使用 POST'); + assert.equal(first.credentials, 'same-origin', '维护请求必须保留同源凭据'); + for (const [key, value] of Object.entries({ action: 'maintenance', job: 'rebuild', mode: 'start', job_id: '', _: 'fixture-token', ajax: '1' })) { + assert.equal(first.body.get(key), value, '开始请求字段正确:' + key); + } + h.reply({ job_id: 'current-job', total: 5, done: 3, failed: 0, finished: false }); await flush(); + assert.equal(h.bar.style.width, '60%', '进度反映已完成比例'); + assert.equal(h.button.disabled, true, '批次之间保持按钮禁用'); + h.poll(); + assert.equal(h.requests[1].body.get('mode'), 'poll', '后续请求必须改为轮询'); + assert.equal(h.requests[1].body.get('job_id'), 'current-job', '轮询必须发送服务器返回的任务身份'); + h.reply({ job_id: 'current-job', total: 5, done: 5, failed: 1, finished: true }); await flush(); + assert.match(h.message.textContent, /失败 1/, '部分失败计数必须可见'); + assert.match(h.message.textContent, /1 项失败,请查看下方清单/, '完成时必须显示失败处理提示'); + assert.equal(h.button.disabled, false, '完成后恢复启动按钮'); + assert.equal(h.timers.length, 0, '完成后不再轮询'); + h.button.emit('click'); + assert.equal(h.requests[2].body.get('mode'), 'start', '再次点击必须开始新任务'); + assert.equal(h.requests[2].body.get('job_id'), '', '再次开始不得带上旧任务身份'); + assert.equal(h.bar.style.width, '0%', '新任务清除旧进度'); + h.reply({ job_id: 'fresh-job', total: 0, done: 0, failed: 0, finished: true }); await flush(); + assert.match(h.message.textContent, /没有需要重建的图片/, '空任务显示明确说明'); + + const resumed = harness({ job: 'rebuild', job_id: 'saved-job', total: 6, done: 3, finished: false }); + resumed.container.child.querySelector('button').emit('click'); + assert.equal(resumed.requests[0].body.get('mode'), 'resume', '继续入口必须发送 resume'); + assert.equal(resumed.requests[0].body.get('job_id'), 'saved-job', '继续入口必须使用持久化任务身份'); + resumed.reply({ ok: false, msg: '维护任务已更新' }, false); await flush(); + assert.equal(resumed.message.textContent, '维护任务已更新', '过期任务拒绝必须显示真实错误'); + assert.equal(resumed.button.disabled, false, '拒绝后允许重新开始'); + assert.equal(resumed.timers.length, 0, '过期任务不得继续轮询'); + + for (const fault of ['network', 'malformed', 'lease']) { + const broken = harness(); broken.button.emit('click'); + if (fault === 'network') broken.reject(); + if (fault === 'malformed') broken.malformed(); + if (fault === 'lease') broken.reply({ finished: true, msg: '另一个维护任务正在进行' }); + await flush(); + assert.equal(broken.button.disabled, false, '故障或租约拒绝必须恢复按钮:' + fault); + assert.equal(broken.timers.length, 0, '故障或租约拒绝必须停止轮询:' + fault); + assert.ok(broken.message.textContent && !broken.message.textContent.includes('✓'), '故障不得显示成功:' + fault); + broken.button.emit('click'); + assert.equal(broken.requests.length, 2, '故障必须释放运行状态以允许重试:' + fault); + broken.reply({ total: 0, done: 0, failed: 0, finished: true }); await flush(); + } + const parentId = 'a'.repeat(32), childId = 'b'.repeat(32); + const failure = (id, task = 'rebuild') => ({ task, id, cid: 10, reason: task === 'resync' ? '图集字段同步失败;重试时只同步字段' : '图片编码失败,请检查原图与转换工具' }); + const completed = { job: 'rebuild', job_id: parentId, total: 6, done: 6, failed: 2, finished: true, + failures: [failure(1), failure(10, 'resync')], failure_total: 2, failure_offset: 0, failure_next: null }; + const retry = harness(completed); + assert.equal(retry.nodes['#pp-maintenance-failures'].hidden, false, '刷新后完成任务的失败清单保留'); + assert.equal(retry.nodes['#pp-failure-list'].children.length, 2, '刷新后立即展示所有当前页失败项'); + assert.match(retry.nodes['#pp-failure-list'].children[0].textContent, /图片 ID 1 \/ 相册 ID 10/, '图片失败显示图片与相册身份'); + assert.match(retry.nodes['#pp-failure-list'].children[1].textContent, /相册 ID 10.*只同步字段/, '字段失败显示单独恢复方式'); + const retryButton = retry.nodes['#pp-retry-failures']; + retryButton.emit('click'); retryButton.emit('click'); retry.otherButton.emit('click'); + assert.equal(retry.requests.length, 1, '重试双击及其他维护按钮不能并行启动'); + assert.equal(retry.requests[0].body.get('mode'), 'retry'); + assert.equal(retry.requests[0].body.get('job_id'), parentId, '失败重试必须带原任务身份'); + assert.equal(retry.otherButton.disabled, true, '重试时禁用所有维护启动按钮'); + retry.reject(); await flush(); + retryButton.emit('click'); + assert.equal(retry.requests[1].body.get('mode'), 'retry', '重试响应丢失必须保留重试语义'); + assert.equal(retry.requests[1].body.get('job_id'), parentId, '重试响应丢失必须保留同一原任务身份'); + const unfinished = { job: 'rebuild', job_id: childId, retry_of: parentId, total: 2, done: 1, failed: 0, failures: [], failure_total: 0, finished: false }; + retry.reply(unfinished); await flush(); retry.poll(); retry.reject(); await flush(); + retry.button.emit('click'); + assert.equal(retry.requests[3].body.get('mode'), 'poll', '轮询失败再次点击不能变成全库开始'); + assert.equal(retry.requests[3].body.get('job_id'), childId, '轮询恢复必须使用子任务身份'); + retry.reply({ ...unfinished, done: 2, finished: true }); await flush(); + assert.equal(retry.nodes['#pp-maintenance-failures'].hidden, true, '失败全部恢复后隐藏空清单'); + const refreshRetry = harness(unfinished); + refreshRetry.container.child.querySelector('button').emit('click'); + assert.equal(refreshRetry.requests[0].body.get('mode'), 'resume', '重试任务刷新后提供续跑'); + assert.equal(refreshRetry.requests[0].body.get('job_id'), childId, '刷新续跑必须保留服务器注入的完整身份'); + refreshRetry.reply({ ...unfinished, done: 2, finished: true }); await flush(); + + const pages = harness({ ...completed, failed: 123, failure_total: 123, failures: Array.from({ length: 50 }, (_, i) => failure(i + 1)), failure_next: 50 }); + pages.nodes['#pp-failure-next'].emit('click'); pages.nodes['#pp-failure-next'].emit('click'); + assert.equal(pages.requests.length, 1, '翻页重复点击只能发一个请求'); + assert.equal(pages.requests[0].body.get('mode'), 'errors', '翻页是独立只读维护模式'); + assert.equal(pages.requests[0].body.get('job_id'), parentId, '翻页必须绑定当前任务'); + assert.equal(pages.requests[0].body.get('offset'), 50); + pages.reply({ ...completed, failed: 123, failure_total: 123, failures: Array.from({ length: 50 }, (_, i) => failure(i + 51)), failure_offset: 50, failure_next: 100 }); await flush(); + assert.equal(pages.nodes['#pp-failure-list'].children.length, 50, '翻页替换当前页,DOM保持有界'); + assert.match(pages.nodes['#pp-failure-summary'].textContent, /共 123 项失败,显示 51–100 项/); + pages.nodes['#pp-failure-next'].emit('click'); + pages.reply({ ...completed, failed: 123, failure_total: 123, failures: Array.from({ length: 23 }, (_, i) => failure(i + 101)), failure_offset: 100, failure_next: null }); await flush(); + assert.equal(pages.nodes['#pp-failure-list'].children.length, 23); + assert.match(pages.nodes['#pp-failure-list'].children[22].textContent, /图片 ID 123/); + assert.equal(pages.nodes['#pp-failure-next'].disabled, true, '末页禁用下一页'); + pages.nodes['#pp-failure-prev'].emit('click'); pages.reject(); await flush(); + pages.nodes['#pp-failure-prev'].emit('click'); + assert.equal(pages.requests[3].body.get('offset'), 50, '分页网络失败后可以重试同一页'); + pages.reply({ ...completed, failure_offset: 0 }); await flush(); + const legacy = harness({ job: 'rebuild', job_id: parentId, failed: 4, finished: true, unlisted_failed: 4, failure_total: 0 }); + assert.match(legacy.nodes['#pp-failure-summary'].textContent, /4 项来自旧版任务/); + assert.equal(legacy.nodes['#pp-retry-failures'].disabled, true, '缺少旧版明细不能发起空重试'); + const panelSource = fs.readFileSync(require.resolve('../usr/plugins/InfinityTime/panel.php'), 'utf8'); + assert.match(panelSource, /'job' => \$ppJobState/, '后台必须注入带job_id的完整有界任务响应,不能重新遗漏身份'); + console.log('维护前端:身份、重复点击、失败清单、分页、刷新及定向重试响应丢失恢复检查通过'); +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/maintenance-panel.php b/tests/maintenance-panel.php new file mode 100644 index 0000000..d34b247 --- /dev/null +++ b/tests/maintenance-panel.php @@ -0,0 +1,340 @@ +(默认 rebuild-sync-failure)。 + * 测试复制未经修改的面板到独立临时目录,不接触插件真实 data 目录。 */ +declare(strict_types=1); +namespace Typecho { + class Query { + private string $kind = 'select'; + private string $table = ''; + private array $fields = []; + private array $rows = []; + private array $where = []; + private array $params = []; + private string $order = ''; + private string $limit = ''; + public function __construct(string $kind = 'select', string $table = '', array $fields = []) { $this->kind = $kind; $this->table = $table; $this->fields = $fields ?: ['*']; } + public function from($table): self { $this->table = $table; return $this; } + public function rows($rows): self { $this->rows = $rows; return $this; } + public function where($clause, ...$values): self { + foreach ($values as $value) { $clause = substr_replace($clause, $this->quoteValue($value), strpos($clause, '?'), 1); } + $this->where[] = '(' . $clause . ')'; return $this; + } + public function limit($limit): self { $this->limit = ' LIMIT ' . (int)$limit; return $this; } + public function order($column, $direction): self { $this->order .= ($this->order ? ',' : ' ORDER BY ') . '"' . $column . '" ' . $direction; return $this; } + public function quoteValue($value): string { $this->params[] = $value; return '#param:' . (count($this->params) - 1) . '#'; } + public function prepare(string $sql): string { + return preg_replace_callback('/#param:(\d+)#/', function ($match) { $value = $this->params[(int)$match[1]]; return $value === null ? 'NULL' : Db::get()->pdo->quote((string)$value); }, $sql); + } + public function __toString(): string { + $table = '"' . $this->table . '"'; + if ($this->kind === 'insert') { + $names = implode(',', array_map(static function ($name) { return '"' . $name . '"'; }, array_keys($this->rows))); + $values = implode(',', array_map(function ($value) { return $this->quoteValue($value); }, array_values($this->rows))); + $sql = "INSERT INTO $table ($names) VALUES ($values)"; + } elseif ($this->kind === 'update') { + $values = []; + foreach ($this->rows as $name => $value) { $values[] = '"' . $name . '" = ' . $this->quoteValue($value); } + $sql = "UPDATE $table SET " . implode(',', $values); + } elseif ($this->kind === 'delete') { $sql = "DELETE FROM $table"; } + else { $sql = 'SELECT ' . implode(',', $this->fields) . " FROM $table"; } + return $this->prepare($sql . ($this->where ? ' WHERE ' . implode(' AND ', $this->where) : '') . $this->order . $this->limit); + } + public function isInsert(): bool { return $this->kind === 'insert'; } + } + class Db { + public const SORT_ASC = 'ASC', WRITE = 'WRITE', READ = 'READ'; + public static self $instance; + public \PDO $pdo; + public array $reads = []; + public array $operations = []; + public bool $failDelete = false; + public bool $failScan = false; + public string $adapter = 'Pdo_SQLite'; + public function __construct() { $this->pdo = new \PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); } + public static function get(): self { return self::$instance; } + public function getPrefix(): string { return 'admin_'; } + public function getAdapterName(): string { return $this->adapter; } + public function select(...$fields): Query { return new Query('select', '', $fields); } + public function insert($table): Query { return new Query('insert', $table); } + public function update($table): Query { return new Query('update', $table); } + public function delete($table): Query { return new Query('delete', $table); } + public function fetchRow($query) { if ($query instanceof \PDOStatement) { return $query->fetch(\PDO::FETCH_ASSOC); } $this->reads[] = (string)$query; return $this->pdo->query((string)$query)->fetch(\PDO::FETCH_ASSOC); } + public function fetchAll($query): array { if ($query instanceof \PDOStatement) { return $query->fetchAll(\PDO::FETCH_ASSOC); } $this->reads[] = (string)$query; return $this->pdo->query((string)$query)->fetchAll(\PDO::FETCH_ASSOC); } + public function query($query, $op = self::READ) { + $this->operations[] = ['sql' => (string)$query, 'op' => $op, 'object' => is_object($query)]; + if ($this->failScan && strpos((string)$query, 'original <>') !== false) { throw new \RuntimeException('SQL SELECT original FROM private_table /private/server/database.sqlite'); } + if (preg_match('/^SELECT /i', (string)$query)) { $this->reads[] = (string)$query; return $this->pdo->query((string)$query); } + if ($this->failDelete && strpos((string)$query, 'DELETE FROM "admin_contents"') === 0) { throw new \RuntimeException('模拟文章删除故障'); } + $affected = $this->pdo->exec((string)$query); + return $query instanceof Query && $query->isInsert() ? (int)$this->pdo->lastInsertId() : $affected; + } + } +} +namespace Widget { + class User { + public int $uid = 7; + public static function alloc(): self { return new self(); } + public function pass($role, $return): bool { return true; } + } + class Security { + public static function alloc(): self { return new self(); } + public function getToken($scope): string { return 'fixture-maintenance-token'; } + } +} +namespace Utils { + class Helper { + public static function options(): object { return (object)['siteUrl' => 'https://example.test/']; } + } +} +namespace TypechoPlugin\InfinityTime { + class Plugin { + public const DEFAULT_QUALITY = 76, DEFAULT_THUMB_MAX = 1280, DEFAULT_MAX_WIDTH = 2560, + DEFAULT_FULL_QUALITY = 82, DEFAULT_PANO_WIDTH = 0, DEFAULT_PANO_QUALITY = 92; + public static array $logs = []; + public static function opt($name, $default) { return $default; } + public static function log($message): void { self::$logs[] = $message; } + } +} +namespace TypechoPlugin\InfinityTime\Lib { + class ImageRepository { + public static string $root; + public static bool $locked = false; + public static bool $failSync = false; + public static array $updated = []; + public static array $synced = []; + public static function lockMedia(): void { self::$locked = true; } + public static function table(): string { return 'admin_infinitytime_images'; } + public static function toAbs(string $path): string { return self::$root . '/' . ltrim($path, '/'); } + public static function isPano(int $w, int $h): bool { return $w > $h * 2; } + public static function updateVariants(int $id, array $result): void { + if (!self::$locked) { throw new \RuntimeException('变体更新必须持有媒体锁'); } + self::$updated[] = $id; + } + public static function syncPostFields(int $cid): void { + if (!self::$locked) { throw new \RuntimeException('字段同步必须持有媒体锁'); } + self::$synced[] = $cid; + if (self::$failSync) { throw new \RuntimeException('模拟字段同步故障 /private/server/database.sqlite'); } + } + } + class MediaProcessor { + public static bool $fail = false; + public static int $calls = 0; + public static function process(...$args): array { + self::$calls++; + if (self::$fail) { throw new \RuntimeException('模拟编码故障 /private/server/original.png'); } + return ['width' => 1, 'height' => 1]; + } + } +} +namespace { + use TypechoPlugin\InfinityTime\Lib\ImageRepository as Images; + use TypechoPlugin\InfinityTime\Lib\MaintenanceState as State; + use TypechoPlugin\InfinityTime\Lib\MediaProcessor as Media; + use TypechoPlugin\InfinityTime\Plugin; + function _t($message): string { return $message; } + function expect(bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); } } + function removeFixture(string $dir): void { + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $item) { + if ($item->isDir()) { rmdir($item->getPathname()); } else { unlink($item->getPathname()); } + } + rmdir($dir); + } + $mode = $argv[1] ?? 'rebuild-sync-failure'; + $modes = ['scan-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', + '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']; + expect(in_array($mode, $modes, true), '未知测试模式:' . $mode); + $source = dirname(__DIR__) . '/usr/plugins/InfinityTime'; + require $source . '/Lib/Database.php'; + require $source . '/Lib/AdminSecurity.php'; + require $source . '/Lib/AdminRepository.php'; + require $source . '/Lib/MaintenanceState.php'; + $root = sys_get_temp_dir() . '/infinity-maintenance-panel-' . bin2hex(random_bytes(8)); + mkdir($root, 0700); mkdir($root . '/Lib', 0700); mkdir($root . '/data', 0700); + copy($source . '/panel.php', $root . '/panel.php'); + // 继续使用已加载的真实状态类,避免重复声明。 + file_put_contents($root . '/Lib/MaintenanceState.php', 'query('CREATE TABLE admin_infinitytime_images (id INTEGER PRIMARY KEY, cid INTEGER, original TEXT, full TEXT, thumb TEXT)'); + $db->query('CREATE TABLE admin_contents (cid INTEGER PRIMARY KEY, title TEXT, type TEXT)'); + $db->query('CREATE TABLE admin_fields (cid INTEGER, name TEXT, str_value TEXT)'); + $db->query("INSERT INTO admin_infinitytime_images VALUES (1,10,'/original.png','/full.webp','/thumb.webp')"); + $oldId = str_repeat('a', 32); + $state = ['job' => 'rebuild', 'job_id' => $oldId, 'total' => 3, 'done' => 1, 'failed' => 0, 'current' => 'old', 'finished' => false]; + $_SERVER['REQUEST_METHOD'] = 'POST'; $_GET = []; $_FILES = []; + $_POST = ['action' => 'maintenance', 'job' => 'rebuild', 'mode' => 'start', 'ajax' => '1', '_' => 'fixture-maintenance-token']; + if ($mode === 'rebuild-batch') { + for ($id = 2; $id <= 5; $id++) { + $db->query("INSERT INTO admin_infinitytime_images VALUES ($id,10,'/original.png','/full$id.webp','/thumb$id.webp')"); + } + } + if ($mode === 'rebuild-sync-failure') { Images::$failSync = true; } + if ($mode === 'rebuild-conversion-failure') { Media::$fail = true; } + if ($mode === 'rebuild-no-original') { $db->query("UPDATE admin_infinitytime_images SET original = ''"); } + if ($mode === 'rebuild-missing-original') { unlink($root . '/original.png'); } + if (in_array($mode, ['rebuild-restart', 'rebuild-resume', 'rebuild-start-reuse', 'stale-poll', 'finished-poll'], true)) { + if (in_array($mode, ['rebuild-restart', 'finished-poll'], true)) { $state['done'] = 3; $state['finished'] = true; } + State::write($root . '/data/job.json', $state); + State::write(State::listFile($root . '/data', $state), [1, 2, 3]); + // 快照中的第 2 行已删除,第 3 行仍存在,第 4 行是快照之后的新图片。 + $db->query("INSERT INTO admin_infinitytime_images VALUES (3,30,'/original.png','/full3.webp','/thumb3.webp'),(4,40,'/original.png','/full4.webp','/thumb4.webp')"); + if ($mode === 'rebuild-resume') { $_POST['mode'] = 'resume'; $_POST['job_id'] = $oldId; } + if (in_array($mode, ['stale-poll', 'finished-poll'], true)) { + $_POST['mode'] = 'poll'; $_POST['job_id'] = $mode === 'stale-poll' ? str_repeat('b', 32) : $oldId; + State::write($root . '/data/job.lock', ['uid' => 7, 'job' => 'rebuild', 'time' => time() - 10]); + } + } + if (in_array($mode, ['delete-non-plugin', 'delete-rollback'], true)) { + $_POST = ['action' => 'delete_non_plugin', 'ajax' => '1', '_' => 'fixture-maintenance-token']; + $db->query("INSERT INTO admin_contents VALUES (10,'Image-backed','post'),(11,'Marked draft','post'),(12,'Legacy album','post'),(13,'Ordinary post','post'),(14,'Page','page')"); + $db->query("INSERT INTO admin_fields VALUES (11,'infinitytime_album','1'),(12,'img','legacy.webp'),(13,'description','ordinary')"); + $db->failDelete = $mode === 'delete-rollback'; + } + if ($mode === 'lease-conflict') { + State::write($root . '/data/job.lock', ['uid' => 7, 'job' => 'cleanup', 'time' => time()]); + } + $retryModes = ['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']; + if (in_array($mode, $retryModes, true)) { + $state['done'] = 3; $state['finished'] = true; + $db->query("INSERT INTO admin_infinitytime_images VALUES (2,20,'/original.png','/full2.webp','/thumb2.webp'),(3,30,'/original.png','/full3.webp','/thumb3.webp')"); + if ($mode === 'retry-resync') { $state['job'] = 'resync'; } + if (!in_array($mode, ['retry-fields', 'retry-resync', 'retry-empty', 'errors-page'], true)) { State::failure($state, 'rebuild', 1, 10, 'conversion'); } + if (in_array($mode, ['retry-fields', 'retry-mixed'], true)) { State::failure($state, 'resync', 30, 30, 'field_sync'); } + if ($mode === 'retry-resync') { State::failure($state, 'resync', 10, 10, 'field_sync'); } + if (in_array($mode, ['retry-repeat', 'retry-finished'], true)) { State::failure($state, 'rebuild', 3, 30, 'conversion'); } + if ($mode === 'errors-page') { + for ($id = 1; $id <= 123; $id++) { State::failure($state, 'rebuild', $id, 10, 'conversion'); } + } + if (in_array($mode, ['retry-unfinished', 'retry-other-job'], true)) { + $state['done'] = 1; $state['finished'] = false; + State::write(State::listFile($root . '/data', $state), [1, 2, 3]); + } + State::save($root . '/data', $state); + $_POST['job'] = $state['job']; $_POST['mode'] = 'retry'; $_POST['job_id'] = $oldId; + if ($mode === 'retry-repeat' || $mode === 'retry-finished') { + [$state] = State::open($root . '/data', 'rebuild', 'retry', $oldId, static function () { throw new RuntimeException('不得重扫'); }); + $state['done'] = $mode === 'retry-repeat' ? 1 : 2; + $state['finished'] = $mode === 'retry-finished'; + State::save($root . '/data', $state); + } + if ($mode === 'retry-stale' || $mode === 'errors-stale') { $_POST['job_id'] = str_repeat('b', 32); } + if ($mode === 'retry-other-job') { $_POST['job'] = 'cleanup'; $_POST['mode'] = 'start'; unset($_POST['job_id']); } + if (strpos($mode, 'errors-') === 0) { $_POST['mode'] = 'errors'; $_POST['offset'] = 100; } + } + if ($mode === 'scan-failure') { $db->failScan = true; } + if ($mode === 'resync-failure') { $_POST['job'] = 'resync'; Images::$failSync = true; } + $beforeState = is_file($root . '/data/job.json') ? file_get_contents($root . '/data/job.json') : null; + $beforeLock = is_file($root . '/data/job.lock') ? file_get_contents($root . '/data/job.lock') : null; + $beforeFiles = glob($root . '/data/*'); + $db->operations = []; $db->reads = []; + define('__TYPECHO_ADMIN__', true); + ob_start(); + register_shutdown_function(static function () use ($root, $mode, $db, $oldId, $beforeState, $beforeLock, $beforeFiles) { + $output = ob_get_clean(); + try { + $json = json_decode($output, true); + expect(is_array($json), '响应必须为 JSON:' . $output); + foreach ($db->operations as $operation) { + if (strpos($operation['sql'], 'SELECT ') === 0 || in_array($operation['sql'], ['BEGIN', 'COMMIT', 'ROLLBACK'], true)) { + expect($operation['op'] === \Typecho\Db::WRITE && !$operation['object'], '快照、实时读取与事务必须使用主库连接'); + } + } + expect(strpos($output, '/private/server/') === false && strpos($output, $root) === false, '失败响应不能泄漏服务器路径'); + if (in_array($mode, ['retry-stale', 'retry-empty', 'retry-unfinished', 'retry-other-job', 'errors-stale'], true)) { + expect($json['ok'] === false, '不合法重试必须明确拒绝'); + expect(file_get_contents($root . '/data/job.json') === $beforeState && glob($root . '/data/*') === $beforeFiles, '拒绝不能覆盖或增添检查点'); + expect(Media::$calls === 0 && Images::$synced === [] && $db->operations === [], '拒绝必须先于任何扫描或媒体操作'); + } elseif ($mode === 'scan-failure') { + expect(http_response_code() === 500 && $json['ok'] === false && strpos($output, 'private') === false && strpos($output, 'SELECT') === false, '候选扫描异常不得泄漏SQL或服务器路径'); + expect(strpos($json['msg'], '服务器日志') !== false && Media::$calls === 0, '扫描失败应提供安全恢复提示'); + } elseif ($mode === 'errors-page') { + expect($json['ok'] === true && $json['failure_total'] === 123 && $json['failure_offset'] === 100 && count($json['failures']) === 23 && $json['failure_next'] === null, '最后一页失败必须完整可达'); + expect($json['failures'][22]['id'] === 123 && $json['job_id'] === $oldId, '分页必须保留原任务身份且包含最后项目'); + expect(file_get_contents($root . '/data/job.json') === $beforeState && glob($root . '/data/*') === $beforeFiles && $db->operations === [], '翻页不能处理媒体或改写状态'); + } elseif (in_array($mode, ['retry-image', 'retry-fields', 'retry-mixed', 'retry-resync', 'retry-repeat', 'retry-finished'], true)) { + expect($json['finished'] === true && $json['failed'] === 0 && $json['retry_of'] === $oldId && $json['job_id'] !== $oldId, '重试必须完成独立子任务并保留原任务身份'); + $expectedImages = $mode === 'retry-repeat' ? [3] : (in_array($mode, ['retry-image', 'retry-mixed'], true) ? [1] : []); + $expectedAlbums = $mode === 'retry-finished' ? [] : (in_array($mode, ['retry-fields', 'retry-repeat'], true) ? [30] : ($mode === 'retry-mixed' ? [10, 30] : [10])); + expect(Images::$updated === $expectedImages && Media::$calls === count($expectedImages) && Images::$synced === $expectedAlbums, '只能处理指定失败图片及字段失败图集'); + foreach ($db->reads as $sql) { expect(strpos($sql, 'WHERE (id = ') !== false, '重试不得再次扫描全库图片或字段'); } + $persisted = State::read($root . '/data/job.json'); + unset($json['ok'], $json['msg']); + expect(State::response($persisted) === $json, '重试响应必须与持久化检查点一致'); + if ($mode === 'retry-finished') { expect(file_get_contents($root . '/data/job.json') === $beforeState && $db->operations === [], '重试首批完成且丢失响应后不能再次执行'); } + } elseif ($mode === 'resync-failure') { + expect($json['finished'] === true && $json['failed'] === 1 && Media::$calls === 0 && Images::$synced === [10], '字段失败独立于图片编码'); + expect($json['failures'][0]['task'] === 'resync' && $json['failures'][0]['id'] === 10 && $json['failures'][0]['code'] === 'field_sync', '字段失败必须保留图集身份及安全原因'); + } elseif ($mode === 'lease-conflict') { + expect($json['finished'] === true && strpos($json['msg'], '另一个维护任务') !== false, '同一管理员不同任务的有效租约必须拒绝'); + expect(file_get_contents($root . '/data/job.lock') === $beforeLock && glob($root . '/data/*') === $beforeFiles, '租约冲突不得写入检查点或修改锁'); + expect(Media::$calls === 0 && Images::$updated === [] && Images::$synced === [] && $db->operations === [], '租约冲突必须在数据库读取和媒体处理前停止'); + } elseif (in_array($mode, ['stale-poll', 'finished-poll'], true)) { + expect(file_get_contents($root . '/data/job.json') === $beforeState, '轮询不得重写检查点'); + expect(file_get_contents($root . '/data/job.lock') === $beforeLock, '轮询不得刷新或删除锁'); + expect(glob($root . '/data/*') === $beforeFiles, '轮询不得创建状态文件'); + expect(Media::$calls === 0 && Images::$synced === [], '轮询不得处理过期或已完成任务'); + expect($mode === 'stale-poll' ? (http_response_code() === 500 && $json['ok'] === false) : $json['finished'] === true, '轮询响应必须报告拒绝或完成'); + } elseif (in_array($mode, ['delete-non-plugin', 'delete-rollback'], true)) { + $remaining = $db->pdo->query('SELECT cid FROM admin_contents ORDER BY cid')->fetchAll(PDO::FETCH_COLUMN); + $remaining = array_map('intval', $remaining); + $sql = array_column($db->operations, 'sql'); + expect(count(array_filter($sql, static function ($q) { return strpos($q, 'SELECT ') === 0; })) === 3, '三类身份分类快照必须全部从主库读取'); + expect(in_array('BEGIN', $sql, true), '删除必须开启事务'); + if ($mode === 'delete-rollback') { + expect($json['ok'] === false && $remaining === [10, 11, 12, 13, 14], '删除失败必须保留每篇文章'); + expect($db->pdo->query('SELECT COUNT(*) FROM admin_fields WHERE cid=13')->fetchColumn() == 1, '回滚必须恢复已删除的自定义字段'); + expect(in_array('ROLLBACK', $sql, true) && !in_array('COMMIT', $sql, true), '删除失败必须回滚'); + } else { + expect($json['ok'] === true && $json['deleted'] === 1 && $remaining === [10, 11, 12, 14], '仅删除普通文章,保留所有图集身份及页面'); + expect(in_array('COMMIT', $sql, true), '成功删除必须提交事务'); + } + } elseif ($mode === 'rebuild-batch') { + expect($json['finished'] === false && $json['total'] === 5 && $json['done'] === 3 && $json['failed'] === 0, '首个请求必须在批次上限停止并保留可继续进度'); + expect(Images::$updated === [1, 2, 3] && Media::$calls === 3, '首批必须恰好处理三条图片记录'); + expect(Images::$synced === [10], '同一图集的多图每批只同步一次聚合字段'); + expect(State::response(State::read($root . '/data/job.json')) === $json, '未完成的持久化检查点必须与响应一致'); + expect(State::read(State::listFile($root . '/data', $json)) === [1, 2, 3, 4, 5], '未完成任务必须保留完整稳定快照'); + $lock = State::read($root . '/data/job.lock'); + expect($lock['uid'] === 7 && $lock['job'] === 'rebuild', '未完成任务必须保留用户及任务锁'); + } else { + expect(($json['finished'] ?? false) === true && !isset($json['ok']), '维护必须返回终态进度而非错误响应'); + $failed = in_array($mode, ['rebuild-sync-failure', 'rebuild-missing-original', 'rebuild-conversion-failure'], true) ? 1 : 0; + expect($json['failed'] === $failed, '失败计数必须在响应中可见'); + expect($json['failure_total'] === $failed && count($json['failures']) === $failed, '新任务每个失败必须有可恢复的明细'); + if ($failed) { + $failure = $json['failures'][0]; + expect($failure['cid'] === 10 && !empty($failure['reason']), '失败必须包含相册身份和安全原因'); + expect($failure['task'] === ($mode === 'rebuild-sync-failure' ? 'resync' : 'rebuild'), '字段失败只能计划字段重试'); + expect($failure['id'] === ($mode === 'rebuild-sync-failure' ? 10 : 1), '失败项必须指向真实图片或相册身份'); + } + $expectedIds = in_array($mode, ['rebuild-resume', 'rebuild-start-reuse'], true) ? [3] : ($mode === 'rebuild-restart' ? [1,3,4] : (in_array($mode, ['rebuild-no-original', 'rebuild-missing-original', 'rebuild-conversion-failure'], true) ? [] : [1])); + expect(Images::$updated === $expectedIds, '重建必须使用正确的快照与实时记录'); + expect($json['total'] === ($mode === 'rebuild-no-original' ? 0 : (in_array($mode, ['rebuild-restart', 'rebuild-resume', 'rebuild-start-reuse'], true) ? 3 : 1)), '快照总数必须准确'); + expect($json['done'] === $json['total'], '终态偏移必须等于总数'); + expect(!is_file($root . '/data/job.lock'), '完成维护后必须释放任务锁'); + expect(State::response(State::read($root . '/data/job.json')) === $json, '持久化状态必须与响应一致'); + expect(!is_file(State::listFile($root . '/data', $json)), '已完成的快照必须清理'); + if (in_array($mode, ['rebuild-resume', 'rebuild-start-reuse'], true)) { expect($json['job_id'] === $oldId, '未完成任务必须复用身份与偏移'); } + if ($mode === 'rebuild-restart') { expect($json['job_id'] !== $oldId, '重新开始必须生成新身份并包含新增图片'); } + if ($mode === 'rebuild-sync-failure') { + expect(strpos($json['current'], '字段同步失败') !== false && Images::$synced === [10], '同步失败必须可见并带修复提示'); + expect(count(Plugin::$logs) === 1 && strpos(Plugin::$logs[0], '模拟字段同步故障') !== false, '同步异常必须记录日志'); + } + } + echo '维护面板请求检查通过:' . $mode . "\n"; + } catch (Throwable $e) { + fwrite(STDERR, '维护面板请求检查失败:' . $mode . ' ' . $e->getMessage() . "\n"); + removeFixture($root); + exit(1); + } + removeFixture($root); + }); + require $root . '/panel.php'; + throw new RuntimeException('AJAX 请求意外进入页面渲染'); +} diff --git a/tests/maintenance-state.php b/tests/maintenance-state.php new file mode 100644 index 0000000..c668beb --- /dev/null +++ b/tests/maintenance-state.php @@ -0,0 +1,106 @@ + 'rebuild', 'total' => 2, 'done' => 1]); + State::write($dir . '/rebuild_list.json', [[21, 'old1'], [22, 'old2']]); + [$legacy, $list] = State::open($dir, 'rebuild', 'resume', '', $collect); + verify($legacy['done'] === 1 && strlen($legacy['job_id']) === 32 && $list[1][0] === 22, '旧断点必须平滑保留'); + State::write($dir . '/job.json', ['job' => 'rebuild', 'total' => 0, 'done' => 0, 'finished' => true]); + [$fresh, $list] = State::open($dir, 'rebuild', 'start', '', $collect); + verify($list === [11, 12, 13, 14], '旧版本完成状态不得复用旧清单'); + // 原子终态保留全部失败原因,分页输出,重试只使用失败身份。 + $fresh['done'] = $fresh['total']; $fresh['finished'] = true; + for ($id = 1; $id <= 123; $id++) { State::failure($fresh, 'rebuild', $id, 10, 'conversion'); } + State::failure($fresh, 'resync', 10, 10, 'field_sync'); + State::failure($fresh, 'resync', 10, 10, 'field_sync'); + verify($fresh['failed'] === 124, '同一图集的跨批次同步失败只能列出一次'); + State::save($dir, $fresh); + $saved = State::read($dir . '/job.json'); + verify($saved === $fresh && count($saved['failures']) === 124, '刷新必须能读回完整失败明细'); + $first = State::response($saved); $second = State::response($saved, 50); $last = State::response($saved, 100); + verify(count($first['failures']) === 50 && $first['failure_next'] === 50 && $first['failure_total'] === 124, '首屏输出必须有界并给出总数和下一页'); + verify(count($second['failures']) === 50 && count($last['failures']) === 24 && $last['failure_next'] === null, '最后一条失败也必须可以翻页查看'); + verify(count(array_unique(array_column(array_merge($first['failures'], $second['failures'], $last['failures']), 'id'))) === 123, '翻页不得遗漏任何图片身份'); + $before = file_get_contents($dir . '/job.json'); + $neverCollect = static function () { throw new RuntimeException('定向重试不得扫描全库'); }; + rejected(static function () use ($dir, $neverCollect) { State::open($dir, 'rebuild', 'retry', '', $neverCollect); }, '空重试身份必须拒绝'); + rejected(static function () use ($dir, $neverCollect) { State::open($dir, 'rebuild', 'retry', str_repeat('c', 32), $neverCollect); }, '陈旧重试身份必须拒绝'); + verify(file_get_contents($dir . '/job.json') === $before, '拒绝重试不能改写原状态'); + [$retry, $retryList] = State::open($dir, 'rebuild', 'retry', $fresh['job_id'], $neverCollect); + verify($retry['job_id'] !== $fresh['job_id'] && $retry['retry_of'] === $fresh['job_id'] && $retry['total'] === 124, '失败重试必须新建独立任务并记住原身份'); + verify($retryList[0] === ['task' => 'rebuild', 'id' => 1, 'cid' => 10] && $retryList[123] === ['task' => 'resync', 'id' => 10, 'cid' => 10], '编码与字段失败必须分开计划'); + $retry['done'] = 3; State::save($dir, $retry); + [$again, $againList] = State::open($dir, 'rebuild', 'retry', $fresh['job_id'], $neverCollect); + verify($again === $retry && $againList === $retryList, '丢失开始重试响应后仍继续同一快照与偏移'); + $before = file_get_contents($dir . '/job.json'); + rejected(static function () use ($dir, $retry, $neverCollect) { State::open($dir, 'rebuild', 'retry', $retry['job_id'], $neverCollect); }, '未完成任务不可再开启失败重试'); + rejected(static function () use ($dir, $neverCollect) { State::open($dir, 'cleanup', 'start', '', $neverCollect); }, '租约过期也不能覆盖异类未完成任务'); + rejected(static function () use ($dir, $neverCollect) { State::open($dir, 'rebuild', 'resume', '', $neverCollect); }, '有身份的任务不能空身份续跑'); + verify(file_get_contents($dir . '/job.json') === $before, '拒绝覆盖不能改变未完成快照'); + $retry['done'] = $retry['total']; $retry['finished'] = true; + State::failure($retry, 'resync', 10, 10, 'field_sync'); + State::clearFailure($retry, 'resync', 10); + verify($retry['failed'] === 0 && $retry['failures'] === [], '后续批次成功同步必须消除已恢复的字段失败'); + State::save($dir, $retry); + [$again, $againList] = State::open($dir, 'rebuild', 'retry', $fresh['job_id'], $neverCollect); + verify($again === $retry && $againList === [], '首批重试已完成但响应丢失时不得再次新开'); + rejected(static function () use ($dir, $retry, $neverCollect) { State::open($dir, 'rebuild', 'retry', $retry['job_id'], $neverCollect); }, '没有失败项时拒绝空重试'); + [$visible] = State::open($dir, 'rebuild', 'errors', $retry['job_id'], $neverCollect); + verify($visible === $retry, '查看失败明细不触发候选扫描或处理'); + $legacyFailures = State::response(['failed' => 2, 'finished' => true]); + verify($legacyFailures['unlisted_failed'] === 2 && $legacyFailures['failure_total'] === 0, '旧版没有明细的失败必须明确告知,不能静默遗漏'); + $mixed = $retry; + $mixed['failed'] = 2; $mixed['failures'] = []; unset($mixed['unlisted_failed']); + State::failure($mixed, 'resync', 10, 10, 'field_sync'); + State::save($dir, $mixed); + [$child, $childList] = State::open($dir, 'rebuild', 'retry', $mixed['job_id'], $neverCollect); + verify($child['failed'] === 2 && $child['unlisted_failed'] === 2 && count($childList) === 1, '定向重试必须继承旧任务无法重试的失败数'); + State::clearFailure($child, 'resync', 10); + $child['finished'] = true; $child['done'] = 1; State::save($dir, $child); + verify(State::response($child)['unlisted_failed'] === 2 && $child['failed'] === 2, '有明细项恢复后不能掩盖旧失败'); + [$full] = State::open($dir, 'rebuild', 'start', '', $collect); + verify($full['failed'] === 0, '只有明确全量新任务清空旧失败基线'); + // rename 到目录必失败,不能以成功返回。 + mkdir($dir . '/cannot-replace'); + rejected(static function () use ($dir) { State::write($dir . '/cannot-replace', ['done' => 1]); }, 'rename失败必须可见'); + verify(count(glob($dir . '/cannot-replace.tmp.*')) === 0, '失败写入必须清理临时文件'); + file_put_contents($dir . '/broken.json', '{'); + rejected(static function () use ($dir) { State::read($dir . '/broken.json'); }, '损坏状态不可静默当新任务'); + echo '维护检查点:' . $checks . " 项检查通过\n"; +} finally { + foreach (glob($dir . '/*') as $file) { if (is_dir($file)) { rmdir($file); } else { unlink($file); } } + rmdir($dir); +} diff --git a/tests/media-command.php b/tests/media-command.php new file mode 100644 index 0000000..3a25ea8 --- /dev/null +++ b/tests/media-command.php @@ -0,0 +1,48 @@ +setAccessible(true); +$root = sys_get_temp_dir() . '/infinity-command-' . bin2hex(random_bytes(5)); +mkdir($root, 0700); +$checks = 0; +function commandCheck(bool $ok, string $message): void { global $checks; if (!$ok) { throw new RuntimeException($message); } $checks++; } +try { + commandCheck($method->invoke(null, [PHP_BINARY, '-r', 'exit(0);'], 5) === 0, '保留成功退出码'); + commandCheck($method->invoke(null, [PHP_BINARY, '-r', 'exit(7);'], 5) === 7, '保留失败退出码'); + $args = ['带 空格/路径', '"quoted"', "O'Reilly", '$HOME;echo nope', 'back\\slash', '[0]', '百分号%COMSPEC%']; + $record = $root . '/argv.json'; + $cmd = [PHP_BINARY, '-r', 'file_put_contents($argv[1],json_encode(array_slice($argv,2),JSON_UNESCAPED_UNICODE));', $record]; + commandCheck($method->invoke(null, array_merge($cmd, $args), 5) === 0, '包含引号和特殊字符的命令成功'); + commandCheck(json_decode(file_get_contents($record), true) === $args, 'argv不经shell解释或破坏'); + $memory = memory_get_peak_usage(true); + $started = microtime(true); + commandCheck($method->invoke(null, [PHP_BINARY, '-r', 'for($i=0;$i<512;$i++){fwrite(STDOUT,str_repeat("a",16384));fwrite(STDERR,str_repeat("b",16384));}'], 5) === 0, '16MB双通道输出不会管道阻塞'); + commandCheck(memory_get_peak_usage(true) - $memory < 8 * 1024 * 1024, '子进程日志不累积到PHP内存'); + $marker = $root . '/late-marker'; + $started = microtime(true); $timedOut = false; + try { $method->invoke(null, [PHP_BINARY, '-r', 'usleep(2000000);file_put_contents($argv[1],"alive");', $marker], 1); } + catch (RuntimeException $e) { + $timedOut = strpos($e->getMessage(), '超时') !== false; + commandCheck(strpos($e->getMessage(), $marker) === false, '错误不暴露参数或私有临时路径'); + } + commandCheck($timedOut, '超时必须抛出明确错误'); + commandCheck(microtime(true) - $started < 3, '超时结束等待'); + usleep(1300000); + commandCheck(!is_file($marker), '实际工具子进程已停止,不会在父流程返回后继续写入'); + foreach ([[], [PHP_BINARY, "invalid\0argument"]] as $invalid) { + $rejected = false; try { $method->invoke(null, $invalid, 1); } catch (InvalidArgumentException $e) { $rejected = true; } + commandCheck($rejected, '非法命令在启动前拒绝'); + } + if (PHP_OS_FAMILY === 'Windows') { + foreach (['tool.bat', 'tool.cmd'] as $batch) { + $rejected = false; try { $method->invoke(null, [$batch, 'argument'], 1); } catch (RuntimeException $e) { $rejected = true; } + commandCheck($rejected, 'Windows批处理包装不进入隐式命令解释器'); + } + } + echo '真实转换子进程:' . $checks . ' 项检查通过(PHP ' . PHP_VERSION . ' / ' . PHP_OS_FAMILY . ")\n"; +} finally { + foreach (glob($root . '/*') as $file) { unlink($file); } + rmdir($root); +} diff --git a/tests/media-gd.php b/tests/media-gd.php new file mode 100644 index 0000000..e8059ad --- /dev/null +++ b/tests/media-gd.php @@ -0,0 +1,64 @@ + 12, '输出文件真实完整可识别'); + } + $before = [hash_file('sha256', $dir . '/full.webp'), hash_file('sha256', $dir . '/thumb.webp')]; + foreach ([[20000, 1], [1, 20000]] as $size) { + sourceGd($dir . '/source.png', $size[0], $size[1]); + $failed = false; + try { MediaProcessor::process($dir . '/source.png', $dir . '/full.webp', $dir . '/thumb.webp', 1280, 76, 0, 82); } + catch (RuntimeException $e) { $failed = strpos($e->getMessage(), '16383') !== false; } + expectGd($failed, '超出WebP编码器硬限制应明确拒绝,不偷偷降低分辨率'); + expectGd($before === [hash_file('sha256', $dir . '/full.webp'), hash_file('sha256', $dir . '/thumb.webp')], '拒绝超限时保留旧完整文件'); + } + sourceGd($dir . '/source.png', 1800, 1200); + foreach (['empty', 'false'] as $fault) { + $encodingFault = $fault; $failed = false; + try { MediaProcessor::process($dir . '/source.png', $dir . '/full.webp', $dir . '/thumb.webp'); } + catch (RuntimeException $e) { $failed = true; } + expectGd($failed, '编码器返回true但空文件或明确失败均不可假成功'); + expectGd($before === [hash_file('sha256', $dir . '/full.webp'), hash_file('sha256', $dir . '/thumb.webp')], '缩略图编码失败不能提前覆盖旧全图'); + expectGd(glob($dir . '/*.tmp.*') === [], '失败后清理所有暂存文件'); + } + echo '真实 GD 编码:' . $checks . " 项检查通过\n"; + } finally { + foreach (glob($dir . '/*') as $file) { unlink($file); } + rmdir($dir); + } +} diff --git a/tests/media-lifetime.php b/tests/media-lifetime.php new file mode 100644 index 0000000..79ce0b8 --- /dev/null +++ b/tests/media-lifetime.php @@ -0,0 +1,47 @@ +w = $w; $this->h = $h; self::$live++; } + public function __destruct() { self::$live--; } + } + class ExifReader { public static function read($path): array { return ['orientation' => 1]; } } + $dimensions = [4200, 2100]; $atVariants = []; $encodedSizes = []; + function getimagesize($path): array { global $dimensions, $encodedSizes; return $encodedSizes[$path] ?? $dimensions; } + function imagecreatefrompng($path): LifetimeBitmap { global $dimensions; return new LifetimeBitmap($dimensions[0], $dimensions[1]); } + function imagesx(LifetimeBitmap $image): int { return $image->w; } + function imagesy(LifetimeBitmap $image): int { return $image->h; } + function imagescale(LifetimeBitmap $image, $w, $h, $mode): LifetimeBitmap { return new LifetimeBitmap($w, $h); } + function imagecrop(LifetimeBitmap $image, array $rect): LifetimeBitmap { return new LifetimeBitmap($rect['width'], $rect['height']); } + function imagewebp(LifetimeBitmap $image, $path, $quality): bool { global $encodedSizes; $encodedSizes[$path] = [$image->w, $image->h, 'mime' => 'image/webp']; return file_put_contents($path, 'isolated-webp-fixture') !== false; } + function extension_loaded($name): bool { + global $atVariants; + if ($name === 'imagick') { $atVariants[] = LifetimeBitmap::$live; return false; } + return \extension_loaded($name); + } +} +namespace { + use TypechoPlugin\InfinityTime\Lib\LifetimeBitmap; + use TypechoPlugin\InfinityTime\Lib\MediaProcessor; + require __DIR__ . '/../usr/plugins/InfinityTime/Lib/MediaProcessor.php'; + if (!defined('IMG_BILINEAR_FIXED')) { define('IMG_BILINEAR_FIXED', 5); } + $root = sys_get_temp_dir() . '/infinity-lifetime-' . bin2hex(random_bytes(4)); + mkdir($root, 0700); + $source = $root . '/source.png'; + file_put_contents($source, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/lfoAAAAASUVORK5CYII=')); + try { + foreach ([[4200, 2100, 2560, 1280], [4200, 2800, 2560, 1280], [600, 400, 0, 1280], [4200, 2100, 0, 1280]] as $case) { + $dimensions = [$case[0], $case[1]]; + $result = MediaProcessor::process($source, $root . '/full.webp', $root . '/thumb.webp', $case[3], 76, $case[2], 82); + if (end($atVariants) !== 0 || LifetimeBitmap::$live !== 0) { throw new RuntimeException('响应式变体启动时仍持有 GD 位图别名'); } + if ($result['width'] !== ($case[2] ?: $case[0]) || $result['size'] <= 0) { throw new RuntimeException('生命期修复改变了尺寸或输出契约'); } + } + echo "位图生命期:普通缩放、全景裁剪、缩略图复用及不裁宽全景四种路径通过\n"; + } finally { + foreach (glob($root . '/*') as $file) { unlink($file); } + rmdir($root); + } +} diff --git a/tests/media-temp.php b/tests/media-temp.php new file mode 100644 index 0000000..f467837 --- /dev/null +++ b/tests/media-temp.php @@ -0,0 +1,75 @@ +setAccessible(true); + foreach (['success', 'fallback', 'failure', 'invalid', 'timeout', 'oversized', 'cleanup'] as $mode) { + if ($name === 'decodeAvif' && $mode === 'fallback') { continue; } + putenv('IT_CODEC_CASE=' . $mode); $ticks = 0; $before = count($producedDirs); $thrown = false; $error = ''; $logsBefore = count(\TypechoPlugin\InfinityTime\Plugin::$logs); + try { $decoded = $method->invoke(null, $source); tempCheck(imagesx($decoded) === 3 && imagesy($decoded) === 2, '有效JPEG由GD正常读取'); unset($decoded); } + catch (RuntimeException $e) { $thrown = true; $error = $e->getMessage(); } + tempCheck($thrown === in_array($mode, ['failure','invalid','timeout','oversized'], true), $name . ' 的成功/失败契约正确:' . $mode); + tempCheck(count($producedDirs) > $before, '实际执行外部工具fixture'); + if ($mode === 'oversized') { tempCheck(strpos($error, '上限') !== false, '转换JPEG超过60MP时在GD解码之前明确拒绝'); } + foreach (array_slice($producedDirs, $before) as $directory) { + if ($mode === 'cleanup') { + tempCheck(is_dir($directory) && count(array_filter(glob($directory . '/*'), 'is_file')) === 0, '清理受阻只保留私有目录,不把成功转换误报失败'); + tempCheck(count(\TypechoPlugin\InfinityTime\Plugin::$logs) > $logsBefore, '临时目录清理受阻必须有日志'); + unlink($directory . '/nested/retained'); rmdir($directory . '/nested'); rmdir($directory); + } else { tempCheck(!is_dir($directory), '成功、异常、超时和多帧输出均清理整个私有目录'); } + } + tempCheck(file_get_contents($source) === 'private original fixture', '不会修改或删除源文件'); + } + } + if (PHP_OS_FAMILY !== 'Windows') { foreach ($privateModes as $mode) { tempCheck($mode === 0700, 'POSIX转换目录仅当前用户可读写'); } } + echo '转换临时资源:' . $checks . " 项检查通过\n"; + } finally { + putenv('IT_CODEC_CASE'); + foreach (glob($fixtureRoot . '/*') as $file) { unlink($file); } + rmdir($fixtureRoot); + } +} diff --git a/tests/media-version.php b/tests/media-version.php new file mode 100644 index 0000000..0358cf1 --- /dev/null +++ b/tests/media-version.php @@ -0,0 +1,26 @@ + '相机', '_infinity_media_version' => $token, 'gps' => ['lat' => 1]]; +$cases = [ + '/full/a.webp' => '/full/a.webp?itv=' . $token, + '/full/a.webp?x=hello%20world#zoom' => '/full/a.webp?x=hello%20world&itv=' . $token . '#zoom', + '/full/a.webp?itv=old&x=1&itv=duplicate' => '/full/a.webp?x=1&itv=' . $token, + '/full/a.webp?%69tv=old' => '/full/a.webp?itv=' . $token, + '' => '', +]; +foreach ($cases as $url => $expected) { + if (ImageRepository::versionUrl($url, $meta) !== $expected || pp_gallery_version_url($url, $meta) !== $expected) { throw new RuntimeException('前后台版本URL不一致'); } + if (ImageRepository::versionUrl($expected, $meta) !== $expected) { throw new RuntimeException('同版本重复展示不应累加参数'); } +} +foreach ([null, [], 'javascript:bad', str_repeat('a', 31)] as $invalid) { + if (pp_gallery_version_url('/a.webp', ['_infinity_media_version' => $invalid]) !== '/a.webp') { throw new RuntimeException('无效版本需忽略'); } +} +$row = ['id' => 7, 'full' => '/full/a.webp', 'thumb' => '/thumb/a.webp', 'exif' => json_encode($meta), 'variants' => ['webp' => ['/full/a.webp', '/mid/a.webp'], 'avif' => ['/avif/a.avif']]]; +$photo = pp_gallery_photo($row, 1, 100); +if ($photo['id'] !== 'p-7' || $photo['exif'] !== ['make' => '相机'] || strpos($photo['preview'], $token) === false || strpos($photo['variants']['avif'][0], $token) === false) { throw new RuntimeException('公开展示需保留ID、过滤私有EXIF及更新所有派生图'); } +$legacy = pp_gallery_photo(['full' => '/legacy.webp', 'exif' => $meta], 1, 100, true); +if ($legacy['url'] !== '/legacy.webp') { throw new RuntimeException('不得擅改旧field-only照片链接'); } +echo "图片缓存版本:前后台一致、参数幂等、元数据保护与旧图链接检查通过\n"; diff --git a/tests/migration.php b/tests/migration.php new file mode 100644 index 0000000..7793a4a --- /dev/null +++ b/tests/migration.php @@ -0,0 +1,126 @@ +values = $values; return $this; } + public function __toString(): string { return 'SELECT option_fixture'; } + public function prepare(string $sql): string { return $sql; } + } + class Db { + public const WRITE = 2; + public static self $instance; + public array $columns = []; + public array $alters = []; + public ?string $failColumn = null; + public bool $race = false; + public bool $indexExists = false; + public bool $failIndex = false; + public string $adapter = 'SQLite'; + public static function get(): self { return self::$instance; } + public function getPrefix(): string { return 'test_'; } + public function getAdapterName(): string { return $this->adapter; } + public function select(...$args): Query { return new Query(); } + public function insert($table): Query { return new Query(); } + public function update($table): Query { return new Query(); } + public function fetchRow($query) { return false; } + public function fetchAll($query): array { + if (!$this->indexExists) { return []; } + $index = 'it_upload_' . substr(sha1('test_infinitytime_images'), 0, 16); + if (strpos($query, 'SHOW INDEX') === 0) { return [ + ['Seq_in_index' => 1, 'Non_unique' => 0, 'Column_name' => 'cid'], + ['Seq_in_index' => 2, 'Non_unique' => 0, 'Column_name' => 'upload_key'], + ]; } + if (strpos($query, 'SELECT indexdef') === 0) { return [['indexdef' => 'CREATE UNIQUE INDEX ' . $index . ' ON test_infinitytime_images USING btree (cid, upload_key)']]; } + if (strpos($query, 'PRAGMA index_list') === 0) { return [['name' => $index, 'unique' => 1]]; } + if (strpos($query, 'PRAGMA index_info') === 0) { return [['name' => 'cid'], ['name' => 'upload_key']]; } + throw new \RuntimeException('Unexpected index probe'); + } + public function query($query, $op = null) { + if (is_string($query) && $op !== self::WRITE) { throw new \RuntimeException('Schema SQL must use writer'); } + if ($query === 'SELECT option_fixture') { return null; } + if (is_string($query) && preg_match('/^(SHOW INDEX|SELECT indexdef|PRAGMA)/', $query)) { return $query; } + if (!is_string($query)) { return; } + if (strpos($query, 'CREATE UNIQUE INDEX') === 0) { + if ($this->failIndex) { throw new \RuntimeException('index permission denied'); } + if ($this->indexExists) { throw new \RuntimeException('index already exists'); } + $this->indexExists = true; + return; + } + if (preg_match('/^SELECT ["`]test_infinitytime_images["`]\.["`]([a-z_]+)["`]/', $query, $match)) { + if (!in_array($match[1], $this->columns, true)) { throw new \RuntimeException('missing column'); } + return; + } + if (preg_match('/ADD COLUMN ["`]([a-z_]+)["`]/', $query, $match)) { + $this->alters[] = $match[1]; + if ($match[1] === $this->failColumn) { throw new \RuntimeException('permission denied'); } + $this->columns[] = $match[1]; + if ($this->race) { throw new \RuntimeException('duplicate column from concurrent migration'); } + return; + } + throw new \RuntimeException('Unexpected SQL: ' . $query); + } + } +} +namespace Utils { + class Helper { + public static object $options; + public static function options(): object { return self::$options; } + } +} +namespace { + interface Typecho_Plugin_Interface {} + require __DIR__ . '/../usr/plugins/InfinityTime/Plugin.php'; + require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php'; + require __DIR__ . '/../usr/plugins/InfinityTime/Lib/ImageRepository.php'; + use TypechoPlugin\InfinityTime\Plugin; + use TypechoPlugin\InfinityTime\Lib\ImageRepository; + function checkMigration(bool $condition, string $message): void { + if (!$condition) { throw new RuntimeException($message); } + } + $db = \Typecho\Db::$instance = new \Typecho\Db(); + \Utils\Helper::$options = (object)['infinitytimeSchemaVersion' => 0]; + $db->columns = ['title']; + $db->failColumn = 'avif'; + $failed = false; + try { ImageRepository::ensureSchema(); } catch (RuntimeException $e) { $failed = true; } + checkMigration($failed, 'DDL failure must surface'); + checkMigration(\Utils\Helper::$options->infinitytimeSchemaVersion === 0, 'Failure must not advance schema version'); + $db->failColumn = null; + ImageRepository::ensureSchema(); + checkMigration((int)\Utils\Helper::$options->infinitytimeSchemaVersion === 4, 'Same-process retry completes'); + checkMigration(count(array_filter($db->alters, static function ($c) { return $c === 'desc'; })) === 1, 'Retry skips already-created columns'); + checkMigration(!in_array('title', $db->alters, true), 'Existing columns need no ALTER'); + $count = count($db->alters); + Plugin::migrateSchema(); + checkMigration(count($db->alters) === $count, 'Current version is idempotent'); + $db->columns = ['title', 'desc']; + \Utils\Helper::$options->infinitytimeSchemaVersion = 2; + Plugin::migrateSchema(); + checkMigration(in_array('mid', $db->columns, true) && in_array('avif', $db->columns, true) && (int)\Utils\Helper::$options->infinitytimeSchemaVersion === 4, 'Repair old falsely successful v2 migration'); + foreach (['SQLite', 'Mysql', 'Pgsql'] as $adapter) { + $db->adapter = $adapter; + $db->columns = []; + $db->race = true; + \Utils\Helper::$options->infinitytimeSchemaVersion = 0; + Plugin::migrateSchema(); + checkMigration(count($db->columns) === 7 && (int)\Utils\Helper::$options->infinitytimeSchemaVersion === 4, 'Concurrent duplicate is accepted only after verification: ' . $adapter); + } + $db->indexExists = false; + $db->failIndex = true; + \Utils\Helper::$options->infinitytimeSchemaVersion = 3; + $failed = false; + try { Plugin::migrateSchema(); } catch (RuntimeException $e) { $failed = true; } + checkMigration($failed && (int)\Utils\Helper::$options->infinitytimeSchemaVersion === 3, 'Index failure must not advance schema'); + $db->failIndex = false; + Plugin::migrateSchema(); + checkMigration($db->indexExists && (int)\Utils\Helper::$options->infinitytimeSchemaVersion === 4, 'Index retry completes safely'); + echo "Migration failure / retry / verification passed\n"; +} diff --git a/tests/orphan-performance.php b/tests/orphan-performance.php new file mode 100644 index 0000000..2acc0d7 --- /dev/null +++ b/tests/orphan-performance.php @@ -0,0 +1,48 @@ + PDO::ERRMODE_EXCEPTION]); +$pdo->exec('CREATE TABLE images (id INTEGER PRIMARY KEY, cid INTEGER, original TEXT, full TEXT, thumb TEXT, mid TEXT, avif TEXT, mid_avif TEXT, exif TEXT, title TEXT, width INTEGER, height INTEGER)'); +$insert = $pdo->prepare('INSERT INTO images VALUES (?,?,?,?,?,?,?,?,?,?,?,?)'); +$exif = json_encode(['camera' => 'fixture', 'notes' => str_repeat('x', 5000)]); +$pdo->beginTransaction(); +for ($id = 1; $id <= 10000; $id++) { + $insert->execute([$id, (int)ceil($id / 20), "/original/$id.jpg", "/full/$id.webp", "/thumb/$id.webp", + "/mid/$id.webp", "/full/$id.avif", "/mid/$id.avif", $exif, "Photo $id", 2400, 1600]); +} +$pdo->commit(); +$paths = ['original', 'full', 'thumb', 'mid', 'avif', 'mid_avif']; +function measure(PDO $pdo, string $columns, array $paths): array { + $start = microtime(true); + $rows = $pdo->query("SELECT $columns FROM images ORDER BY id")->fetchAll(PDO::FETCH_ASSOC); + $elapsed = (microtime(true) - $start) * 1000; + $bytes = 0; + $digest = hash_init('sha256'); + foreach ($rows as $row) { + $bytes += strlen(json_encode($row, JSON_UNESCAPED_SLASHES)); + foreach ($paths as $path) { hash_update($digest, (string)$row[$path] . "\n"); } + } + return ['ms' => $elapsed, 'bytes' => $bytes, 'digest' => hash_final($digest), 'count' => count($rows)]; +} +$all = []; +$projected = []; +for ($pass = 0; $pass < 5; $pass++) { + // 交替顺序减少首次查询与缓存偏差;耗时仅包含查询和 fetchAll。 + if ($pass % 2 === 0) { + $all[] = measure($pdo, '*', $paths); + $projected[] = measure($pdo, implode(',', $paths), $paths); + } else { + $projected[] = measure($pdo, implode(',', $paths), $paths); + $all[] = measure($pdo, '*', $paths); + } + if ($all[$pass]['digest'] !== $projected[$pass]['digest'] || $projected[$pass]['count'] !== 10000) { + throw new RuntimeException('路径投影改变了引用路径'); + } +} +$fullTimes = array_column($all, 'ms'); +$pathTimes = array_column($projected, 'ms'); +sort($fullTimes); +sort($pathTimes); +if ($projected[0]['bytes'] >= $all[0]['bytes']) { throw new RuntimeException('投影必须降低返回数据量'); } +printf("10,000 rows, 5KB EXIF: SELECT * median %.2f ms, %d JSON bytes; six-path projection median %.2f ms, %d JSON bytes; all path digests identical (5 runs each)\n", + $fullTimes[2], $all[0]['bytes'], $pathTimes[2], $projected[0]['bytes']); diff --git a/tests/release-guards.cjs b/tests/release-guards.cjs new file mode 100644 index 0000000..bb356b9 --- /dev/null +++ b/tests/release-guards.cjs @@ -0,0 +1,114 @@ +'use strict'; +// 离线 fixture:不连接 GitHub、不写 tag、不发布任何内容。 +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { guardRun, guardVersions, guardExisting, ASSETS } = require('../.github/scripts/release-guard.cjs'); +const repository = 'Thregren/InfinityTime'; +const sha = 'a'.repeat(40); +function fixture() { + const run = { id: 123, workflow_id: 456, name: 'CI', path: '.github/workflows/ci.yml', repository: { full_name: repository }, head_repository: { full_name: repository }, event: 'push', head_branch: 'main', status: 'completed', conclusion: 'success', head_sha: sha }; + return { event: { repository: { full_name: repository, fork: false }, workflow_run: structuredClone(run) }, run, workflow: { id: 456, path: '.github/workflows/ci.yml', state: 'active' } }; +} +function check(f, main = sha) { return guardRun(f.event, repository, main, f.run, f.workflow); } +test('唯一正常同仓 main push 成功 CI 通过', () => assert.equal(check(fixture()), sha)); +for (const [name, change] of [ + ['PR 事件', f => { f.event.workflow_run.event = f.run.event = 'pull_request'; }], + ['失败 CI', f => { f.run.conclusion = 'failure'; }], + ['未结束 CI', f => { f.run.status = 'in_progress'; }], + ['错误分支', f => { f.run.head_branch = 'feature'; }], + ['fork 源', f => { f.run.head_repository.full_name = 'attacker/InfinityTime'; }], + ['fork 仓库', f => { f.event.repository.fork = true; }], + ['事件仓库错误', f => { f.event.repository.full_name = 'attacker/InfinityTime'; }], + ['run 仓库错误', f => { f.run.repository.full_name = 'attacker/InfinityTime'; }], + ['陈旧 SHA', f => { f.run.head_sha = 'b'.repeat(40); }], + ['事件 SHA 与 API 不同', f => { f.event.workflow_run.head_sha = 'b'.repeat(40); }], + ['伪装 CI 名称', f => { f.run.name = 'Other'; }], + ['同名不同 workflow', f => { f.run.path = '.github/workflows/evil.yml'; }], + ['workflow API path 不符', f => { f.workflow.path = '.github/workflows/evil.yml'; }], + ['workflow 禁用', f => { f.workflow.state = 'disabled_manually'; }], + ['run ID 不符', f => { f.run.id++; }], + ['workflow ID 不符', f => { f.workflow.id++; }], +]) test(`拒绝${name}`, () => { const f = fixture(); change(f); assert.throws(() => check(f)); }); +test('拒绝 main 已移动', () => assert.throws(() => check(fixture(), 'b'.repeat(40)))); +test('允许唯一版本 1.15.0', () => guardVersions('@version 1.15.0', "const VERSION = '1.15.0'", '{"tag_name":"1.15.0"}')); +for (const index of [0, 1, 2]) test(`拒绝版本字段 ${index} 不一致或未来版本`, () => { + const values = ['@version 1.15.0', "const VERSION = '1.15.0'", '{"tag_name":"1.15.0"}']; + values[index] = values[index].replace('1.15.0', '1.15.1'); + assert.throws(() => guardVersions(...values)); +}); +const digests = Object.fromEntries(ASSETS.map(name => [name, 'f'.repeat(64)])); +function complete() { return { tag_name: 'v1.15.0', draft: false, prerelease: false, assets: ASSETS.map(name => ({ name, state: 'uploaded', size: 42, digest: `sha256:${digests[name]}` })) }; } +test('仅 tag/release 均不存在时允许创建', () => assert.equal(guardExisting(null, null, sha, digests), 'create')); +test('已有完整一致发布仅幂等成功', () => assert.equal(guardExisting(sha, complete(), sha, digests), 'complete')); +for (const [name, tag, release] of [ + ['tag SHA 冲突', 'b'.repeat(40), complete()], ['tag-only 部分发布', sha, null], ['release 无 tag', null, complete()], + ['draft 部分发布', sha, { ...complete(), draft: true }], ['prerelease', sha, { ...complete(), prerelease: true }], + ['错误 tag', sha, { ...complete(), tag_name: 'v1.15.1' }], ['缺资产', sha, { ...complete(), assets: complete().assets.slice(1) }], + ['额外资产', sha, { ...complete(), assets: [...complete().assets, complete().assets[0]] }], + ['摘要冲突', sha, { ...complete(), assets: complete().assets.map(a => ({ ...a, digest: 'sha256:wrong' })) }], + ['空包', sha, { ...complete(), assets: complete().assets.map(a => ({ ...a, size: 0 })) }], + ['未上传完成', sha, { ...complete(), assets: complete().assets.map(a => ({ ...a, state: 'new' })) }], + ['重复包名', sha, { ...complete(), assets: [complete().assets[0], complete().assets[0]] }], +]) test(`拒绝${name}`, () => assert.throws(() => guardExisting(tag, release, sha, digests))); + +test('真实 ZIP 构建确定性、单根目录、排除 data,拒绝 symlink', () => { + const fs = require('node:fs'); + const os = require('node:os'); + const path = require('node:path'); + const { execFileSync } = require('node:child_process'); + const { buildArchives } = require('../.github/scripts/release-guard.cjs'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'release-guard-test-')); + const old = process.cwd(); + try { + process.chdir(root); execFileSync('git', ['init', '-q']); + for (const kind of ['themes', 'plugins']) { + fs.mkdirSync(`usr/${kind}/InfinityTime/data`, { recursive: true }); + fs.writeFileSync(`usr/${kind}/InfinityTime/index.php`, 'safe entry'); + fs.writeFileSync(`usr/${kind}/InfinityTime/data/private`, 'exclude plugin data'); + fs.writeFileSync(`usr/${kind}/InfinityTime/.DS_Store`, 'exclude'); + } + execFileSync('git', ['add', 'usr']); + const build = name => { + const temp = path.join(root, name); const dist = path.join(temp, 'dist'); + fs.mkdirSync(dist, { recursive: true }); + const digest = buildArchives(temp, dist); + for (const asset of ASSETS) { + const names = execFileSync('unzip', ['-Z1', path.join(dist, asset)], { encoding: 'utf8' }).trim().split('\n'); + assert(names.every(n => n.startsWith('InfinityTime/') && !n.includes('..') && !n.includes('.DS_Store'))); + assert(names.includes('InfinityTime/index.php')); + if (asset.includes('plugin')) assert(!names.some(n => n.startsWith('InfinityTime/data/'))); + } + return digest; + }; + const first = build('first'); + fs.utimesSync('usr/themes/InfinityTime/index.php', new Date(), new Date()); + assert.deepEqual(first, build('second')); + fs.symlinkSync('/etc/passwd', 'usr/themes/InfinityTime/link'); + execFileSync('git', ['add', 'usr']); + 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())); +}); diff --git a/tests/repository.php b/tests/repository.php index 458e8ab..4c453e7 100644 --- a/tests/repository.php +++ b/tests/repository.php @@ -7,25 +7,51 @@ class Query { public string $table = ''; public array $values = []; public array $conditions = []; - public function __construct(public string $kind) {} + public string $kind; + public function __construct(string $kind) { $this->kind = $kind; } public function from($table): self { $this->table = $table; return $this; } public function rows($values): self { $this->values = $values; return $this; } public function where($clause, ...$values): self { $this->conditions[$clause] = $values; return $this; } public function order(...$args): self { return $this; } + public function limit($limit): self { return $this; } + public function __toString(): string { return 'fixture:' . base64_encode(serialize($this)); } + public function prepare(string $sql): string { return $sql; } } class Db { public const SORT_ASC = 'ASC'; + public const WRITE = 2; public static self $instance; public array $images = []; public array $fields = []; + public array $legacyFields = []; public static function get(): self { return self::$instance; } public function getPrefix(): string { return 'test_'; } + public function getAdapterName(): string { return 'Pdo_SQLite'; } + public function selectDb($op) { return $this; } public function select(...$args): Query { return new Query('select'); } public function insert($table): Query { return (new Query('insert'))->from($table); } public function delete($table): Query { return (new Query('delete'))->from($table); } public function update($table): Query { return (new Query('update'))->from($table); } - public function fetchAll($query): array { return $this->images; } - public function query($query): void { + public function fetchAll($query): array { + $rows = $query->table === 'test_fields' ? $this->legacyFields : $this->images; + if (isset($query->conditions['id = ?'])) { $id = $query->conditions['id = ?'][0]; $rows = array_values(array_filter($rows, static function ($row) use ($id) { return $row['id'] === $id; })); } + return $rows; + } + public function query($query, $op = null) { + if (is_string($query) && strpos($query, 'fixture:') === 0) { + if ($op !== self::WRITE) { throw new \RuntimeException('Locked reads must use writer'); } + $query = unserialize(base64_decode(substr($query, 8))); + if ($query->kind === 'select') { return $query; } + } + // 此替身只测字段内容;真实事务边界由 field-sync.php 覆盖。 + if (is_string($query)) { return; } + if ($query->table === 'test_infinitytime_images' && $query->kind === 'update') { + foreach ($this->images as &$image) { + if (($image['id'] ?? null) === $query->conditions['id = ?'][0]) { $image = array_merge($image, $query->values); } + } + unset($image); + return; + } if ($query->table !== 'test_fields') { return; } if ($query->kind === 'delete') { unset($this->fields[$query->conditions['name = ?'][0]]); } if ($query->kind === 'insert') { $this->fields[$query->values['name']] = $query->values['str_value']; } @@ -46,7 +72,11 @@ public static function log($message): void {} namespace TypechoPlugin\InfinityTime\Lib { class MediaProcessor { public static array $calls = []; - public static function process(...$args): array { self::$calls[] = $args; return []; } + public static function process(...$args): array { + self::$calls[] = $args; + return ['width' => $args[5], 'height' => 80, 'size' => 321, + 'mid' => null, 'avif' => null, 'mid_avif' => null]; + } } } @@ -54,6 +84,7 @@ public static function process(...$args): array { self::$calls[] = $args; return use TypechoPlugin\InfinityTime\Lib\ImageRepository as Repository; use TypechoPlugin\InfinityTime\Lib\MediaProcessor; + require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php'; require __DIR__ . '/../usr/plugins/InfinityTime/Lib/ImageRepository.php'; $root = sys_get_temp_dir() . '/infinitytime-test-' . bin2hex(random_bytes(8)); mkdir($root, 0700); @@ -80,6 +111,8 @@ function expect(bool $condition, string $name): void { expect($result === ['rebuilt' => 2, 'failed' => 0], '混合图片重建成功'); expect(array_slice(MediaProcessor::$calls[0], 5) === [180, 94], '全景独立压缩设置'); expect(array_slice(MediaProcessor::$calls[1], 5) === [120, 51], '后续普通图不继承全景设置'); + expect($db->images[0]['width'] === 180 && $db->images[1]['width'] === 120, '重建持久化实际输出宽度'); + expect($db->images[1]['height'] === 80 && $db->images[1]['size'] === 321, '重建持久化高度和字节数'); $db->images[0]['mid'] = '/uploads/full/pano@1600.webp'; $db->images[0]['avif'] = '/uploads/full/pano.avif'; $db->images[0]['mid_avif'] = '/uploads/full/pano@1600.avif'; @@ -89,18 +122,65 @@ function expect(bool $condition, string $name): void { } } file_put_contents($root . '/uploads/full/orphan.webp', 'fixture'); + touch($root . '/uploads/full/orphan.webp', time() - 7200); expect(Repository::cleanupOrphans()['removed_files'] === 1, '只清理一个孤儿文件'); foreach (['mid', 'avif', 'mid_avif'] as $field) { expect(is_file(Repository::toAbs($db->images[0][$field])), '保留引用 ' . $field); } + // 快照生成后补入引用,恢复旧任务不得删掉该图。 + $late = $root . '/uploads/full/late.webp'; + file_put_contents($late, 'late'); touch($late, time() - 7200); + $snapshot = Repository::orphanCandidates(); + $db->images[] = ['full' => '/uploads/full/late.webp']; + expect(Repository::removeOrphanCandidates($snapshot) === 0 && is_file($late), '删除前重查新增数据库引用'); + $changed = $root . '/uploads/full/changed.webp'; + file_put_contents($changed, 'old'); touch($changed, time() - 7200); + $snapshot = Repository::orphanCandidates(); + file_put_contents($changed, 'replacement with different size'); touch($changed, time() - 7200); + expect(Repository::removeOrphanCandidates($snapshot) === 0 && is_file($changed), '文件身份变化后跳过旧候选'); + $fresh = $root . '/uploads/full/fresh.webp'; file_put_contents($fresh, 'new upload'); + expect(!in_array($fresh, array_column(Repository::orphanCandidates(), 'path'), true), '新上传文件享有宽限期'); + expect(Repository::removeOrphanCandidates([$changed]) === 0, '拒绝旧版纯路径删除快照'); + $outside = $root . '/outside.webp'; file_put_contents($outside, 'outside'); touch($outside, time() - 7200); + symlink($outside, $root . '/uploads/full/link.webp'); + expect(!in_array($root . '/uploads/full/link.webp', array_column(Repository::orphanCandidates(), 'path'), true), '不收集指向外部的软链接'); + expect(Repository::removeOrphanCandidates([['path' => $outside]]) === 0 && is_file($outside), '拒绝上传目录外的路径'); + $legacy = $root . '/uploads/full/legacy.webp'; + $legacyAvif = $root . '/uploads/full/legacy.avif'; + foreach ([$legacy, $legacyAvif] as $path) { file_put_contents($path, 'legacy'); touch($path, time() - 7200); } + $db->legacyFields = [ + ['name' => 'img', 'str_value' => "https://example.com/uploads/full/legacy.webp\n/uploads/full/other.webp"], + ['name' => 'variants', 'str_value' => '[{"avif":["/uploads/full/legacy.avif"]}]'], + ]; + $legacyCandidates = array_column(Repository::orphanCandidates(), 'path'); + expect(!in_array($legacy, $legacyCandidates, true) && !in_array($legacyAvif, $legacyCandidates, true), '保留仅由历史文章字段引用的全图和变体'); + // 模拟其他进程持锁:失败时绝不执行清理。 + Repository::unlockMedia(); + $other = fopen($root . '/uploads/.infinitytime-media.lock', 'c'); + flock($other, LOCK_EX | LOCK_NB); + $blocked = false; + try { Repository::cleanupOrphans(); } catch (RuntimeException $e) { $blocked = true; } + expect($blocked && is_file($changed), '上传/维护锁竞争时安全失败'); + flock($other, LOCK_UN); fclose($other); + Repository::lockMedia(); + Repository::lockMedia(); // 同一请求可复用锁,覆盖 ingest 到 insertRow 的间隙。 + $db->images = [ - ['full' => '/b.webp', 'thumb' => '/b-thumb.webp', 'title' => 'B', 'exif' => '{"gps":"private","iso":100}'], - ['full' => '/a.webp', 'thumb' => '/a-thumb.webp', 'title' => 'A', 'exif' => '{}'], + ['id' => 21, 'created' => 1767225600, 'full' => '/b.webp', 'thumb' => '/b-thumb.webp', 'title' => 'B', 'width' => 2400, 'height' => 1600, 'mid' => '/b@1600.webp', 'avif' => '/b.avif', 'exif' => '{"gps":"private","iso":100}'], + ['id' => 15, 'created' => 1735689600, 'full' => '/a.webp', 'thumb' => '/a-thumb.webp', 'title' => 'A', 'exif' => '{}'], ]; + expect(Repository::photoMonth(['datetime' => '2024:02:29 10:11:12'], 1735689600) === '2024-02', 'EXIF valid leap date keeps camera month'); + expect(Repository::photoMonth(['datetime' => '2025:02:29 10:11:12'], 1735689600) === '2025-01', 'Invalid EXIF day uses creation UTC month'); + expect(Repository::photoMonth([], 0) === '', 'Missing dates remain unknown'); Repository::syncPostFields(1); + expect(json_decode($db->fields['photo_ids'], true) === [21, 15], 'Stable IDs follow sorted rows, not positions'); + expect(json_decode($db->fields['months'], true) === ['2026-01', '2025-01'], 'Photo months synced in same order'); expect($db->fields['img'] === "/b.webp\n/a.webp", '图片路径与仓库排序一致'); expect($db->fields['thumb'] === "/b-thumb.webp\n/a-thumb.webp", '缩略图同序'); + expect(json_decode($db->fields['dims'], true) === ['2400x1600', ''], '首次同步包含尺寸字段'); + $variants = json_decode($db->fields['variants'], true); + expect($variants[0]['w'] === 2400 && $variants[0]['webp'] === ['/b.webp', '/b@1600.webp'] && $variants[0]['avif'] === ['/b.avif'], '首次同步包含响应式变体'); expect(json_decode($db->fields['titles']) === ['B', 'A'], '标题同序'); expect(strpos($db->fields['exif'], 'private') === false, 'GPS 不写入公开字段'); $db->images = [$db->images[1]]; @@ -113,9 +193,11 @@ function expect(bool $condition, string $name): void { expect($db->fields['img'] === '' && $db->fields['thumb'] === '' && !isset($db->fields['titles']), '删除最后一张清空地址及元数据'); echo "Repository: $checks checks passed\n"; } finally { + Repository::unlockMedia(); // 仅移除本次随机创建的 fixture 目录。 $entries = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); foreach ($entries as $entry) { $entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); } rmdir($root); } } + diff --git a/tests/schema-sqlite.php b/tests/schema-sqlite.php new file mode 100644 index 0000000..f5fb51a --- /dev/null +++ b/tests/schema-sqlite.php @@ -0,0 +1,66 @@ +pdo = new \PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); } + public static function get(): self { return self::$instance; } + public function getPrefix(): string { return 'fixture_'; } + public function getAdapterName(): string { return 'Pdo_SQLite'; } + public function select(...$args): Query { return new Query(); } + public function insert($table): Query { return new Query(); } + public function update($table): Query { return new Query(); } + public function fetchRow($query) { return false; } + public function fetchAll($query): array { return ($query instanceof \PDOStatement ? $query : $this->pdo->query($query))->fetchAll(\PDO::FETCH_ASSOC); } + public function query($query, $op = null) { + if (!is_string($query)) { return null; } + return preg_match('/^(SELECT|PRAGMA)/', $query) ? $this->pdo->query($query) : $this->pdo->exec($query); + } + } +} +namespace Utils { + class Helper { + public static object $options; + public static function options(): object { return self::$options; } + } +} +namespace { + interface Typecho_Plugin_Interface {} + require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php'; + require __DIR__ . '/../usr/plugins/InfinityTime/Plugin.php'; + use TypechoPlugin\InfinityTime\Plugin; + $db = \Typecho\Db::$instance = new \Typecho\Db(); + \Utils\Helper::$options = (object)['infinitytimeSchemaVersion' => 3]; + $db->query('CREATE TABLE fixture_infinitytime_images (id INTEGER PRIMARY KEY, cid INTEGER, full TEXT, title TEXT, "desc" TEXT, mid TEXT, avif TEXT, mid_avif TEXT)'); + $db->query("INSERT INTO fixture_infinitytime_images (id,cid,full) VALUES (1,10,'/keep-a.webp'),(2,10,'/keep-b.webp')"); + Plugin::migrateSchema(); + $db->query("INSERT INTO fixture_infinitytime_images (id,cid,upload_key,upload_fingerprint) VALUES (3,10,'retry-key','hash-a')"); + $rejected = false; + try { $db->query("INSERT INTO fixture_infinitytime_images (id,cid,upload_key) VALUES (4,10,'retry-key')"); } + catch (\PDOException $e) { $rejected = true; } + if (!$rejected) { throw new \RuntimeException('同一相册内上传重试键必须唯一'); } + $db->query("INSERT INTO fixture_infinitytime_images (id,cid,upload_key) VALUES (4,11,'retry-key')"); + // 模拟索引已建好、版本选项尚未更新时中断,再次升级必须安全完成。 + \Utils\Helper::$options->infinitytimeSchemaVersion = 3; + Plugin::migrateSchema(); + $rows = $db->fetchAll('SELECT id,full,upload_key FROM fixture_infinitytime_images ORDER BY id'); + if (count($rows) !== 4 || $rows[0]['full'] !== '/keep-a.webp' || $rows[1]['full'] !== '/keep-b.webp' + || $rows[0]['upload_key'] !== null || (int)\Utils\Helper::$options->infinitytimeSchemaVersion !== 4) { + throw new \RuntimeException('升级必须保留已有图片及空重试键'); + } + echo "SQLite 升级数据保留、重试键唯一约束及中断恢复检查通过\n"; +} diff --git a/tests/test.php b/tests/test.php index f2d63e5..d598f47 100644 --- a/tests/test.php +++ b/tests/test.php @@ -5,6 +5,7 @@ */ declare(strict_types=1); +require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php'; require __DIR__ . '/../usr/plugins/InfinityTime/Lib/Sanitizer.php'; require __DIR__ . '/../usr/plugins/InfinityTime/Lib/ImageRepository.php'; require __DIR__ . '/../usr/plugins/InfinityTime/Lib/MediaProcessor.php'; diff --git a/tests/typecho-integration.php b/tests/typecho-integration.php new file mode 100644 index 0000000..303936a --- /dev/null +++ b/tests/typecho-integration.php @@ -0,0 +1,685 @@ + 'SQLite', 'SQLite' => 'SQLite', 'Pdo_Mysql' => 'Mysql', + 'Mysqli' => 'Mysql', 'Pdo_Pgsql' => 'Pgsql', 'Pgsql' => 'Pgsql']; + if (!isset($dialects[$adapter])) { throw new \InvalidArgumentException('不支持的测试适配器'); } + $dialect = $dialects[$adapter]; + $prefix = 'it_test_' . bin2hex(random_bytes(6)) . '_'; + $tmp = sys_get_temp_dir() . '/' . $prefix; + if (!mkdir($tmp, 0700)) { throw new \RuntimeException('无法创建临时测试目录'); } + define('__TYPECHO_ROOT_DIR__', $typecho); + define('__TYPECHO_PLUGIN_DIR__', '/usr/plugins'); + define('__TYPECHO_UPLOAD_ROOT_DIR__', $tmp . '/uploads'); + define('__TYPECHO_UPLOAD_DIR__', '/usr/uploads'); + // 采用 Typecho Widget\Init 的兼容别名,并加载真实插件接口。 + define('__TYPECHO_CLASS_ALIASES__', ['Typecho_Plugin_Interface' => '\\Typecho\\Plugin\\PluginInterface']); + require $typecho . '/var/Typecho/Common.php'; + spl_autoload_register(static function (string $class): void { + $namespace = 'TypechoPlugin\\InfinityTime\\'; + if (strpos($class, $namespace) === 0) { + $file = __DIR__ . '/../usr/plugins/InfinityTime/' . str_replace('\\', '/', substr($class, strlen($namespace))) . '.php'; + if (is_file($file)) { require_once $file; } + } + }); + \Utils\Helper::$options = new \stdClass(); + $db = new Db($adapter, $prefix); + Db::set($db); + $config = $dialect === 'SQLite' ? ['file' => $tmp . '/database.sqlite'] : [ + 'host' => getenv('IT_DB_HOST') ?: '127.0.0.1', + 'port' => getenv('IT_DB_PORT') ?: ($dialect === 'Mysql' ? '3306' : '5432'), + 'database' => getenv('IT_DB_NAME') ?: 'infinitytime_test', + 'user' => getenv('IT_DB_USER') ?: ($dialect === 'Mysql' ? 'root' : 'postgres'), + 'password' => getenv('IT_DB_PASSWORD') ?: '', + 'charset' => $dialect === 'Mysql' ? 'utf8mb4' : 'UTF8', + ]; + $db->addServer($config, Db::WRITE); + // pg_connect 会复用相同连接串;使用不同主机写法确保读写为独立连接。 + if ($adapter === 'Pgsql' && $config['host'] === '127.0.0.1') { $config['host'] = 'localhost'; } + $db->addServer($config, Db::READ); + + $checks = 0; + function check(bool $passed, string $message): void + { + global $checks; + if (!$passed) { throw new \RuntimeException($message); } + $checks++; + } + function rejected(callable $operation, string $exception, string $message): void + { + try { $operation(); } catch (\Throwable $e) { + check($e instanceof $exception, $message . ':异常类型不符 ' . get_class($e) . ': ' . $e->getMessage()); + return; + } + check(false, $message . ':本应拒绝的操作意外成功'); + } + function injectedFailure(callable $operation, string $message, string $expected = 'Integration failure'): void + { + try { $operation(); } catch (\Throwable $e) { + // 上游原生 Pgsql 在 SQL 失败时向 pg_result_error_field 传入 false,PHP 8 会抛 TypeError。 + global $adapter; + $upstreamPgError = $adapter === 'Pgsql' && strpos($e->getMessage(), 'pg_result_error_field') !== false; + check(stripos($e->getMessage(), $expected) !== false || $upstreamPgError, $message . ':非预期错误 ' . $e->getMessage()); + return; + } + check(false, $message . ':本应拒绝的操作意外成功'); + } + function table(string $name): string + { + global $prefix, $dialect; + $quote = $dialect === 'Mysql' ? '`' : '"'; + return $quote . $prefix . $name . $quote; + } + function rowCount(string $name): int + { + return (int)Db::get()->fetchRow('SELECT COUNT(*) AS n FROM ' . table($name))['n']; + } + function fields(int $cid): array + { + return Db::get()->fetchAll(Db::get()->select()->from('table.fields')->where('cid = ?', $cid)->order('name', Db::SORT_ASC)); + } + function post(array $data): int + { + return (int)Database::query(Db::get()->insert('table.contents')->rows($data + [ + 'title' => 'Fixture post', 'slug' => bin2hex(random_bytes(10)), 'created' => 100, + 'modified' => 100, 'authorId' => 7, 'type' => 'post', 'status' => 'draft', + ])); + } + function photo(int $cid, array $data = [], int $sort = 0): int + { + return ImageRepository::insertRow($cid, $data + [ + 'original' => null, 'full' => '/usr/uploads/full/a.webp', 'thumb' => '/usr/uploads/thumb/a.webp', + 'width' => 1200, 'height' => 600, 'size' => 1000, + 'title' => 'photo', 'desc' => 'description', 'address' => 'Location', + 'exif' => ['make' => 'Camera', 'datetime' => '2024:05:06 10:20:30', 'gps' => ['lat' => 1]], + ], $sort); + } + // 同一操作已有部分写入成功后,通过真实数据库触发器注入错误。 + function failureTrigger(bool $enabled, string $field = 'tags'): void + { + global $db, $prefix, $dialect; + $name = $prefix . 'reject_field'; + $fn = $prefix . 'reject_field_fn'; + if (!$enabled) { + Database::query('DROP TRIGGER IF EXISTS ' . $name . ($dialect === 'Pgsql' ? ' ON ' . table('fields') : ''), Db::WRITE); + if ($dialect === 'Pgsql') { Database::query('DROP FUNCTION IF EXISTS ' . $fn . '()', Db::WRITE); } + return; + } + if (!in_array($field, ['tags', 'titles'], true)) { throw new \InvalidArgumentException('触发器字段无效'); } + if ($dialect === 'SQLite') { + $sql = 'CREATE TRIGGER ' . $name . ' BEFORE INSERT ON ' . table('fields') + . " WHEN NEW.name = '$field' BEGIN SELECT RAISE(ABORT, 'Integration failure'); END"; + } elseif ($dialect === 'Mysql') { + $sql = 'CREATE TRIGGER ' . $name . ' BEFORE INSERT ON ' . table('fields') + . " FOR EACH ROW BEGIN IF NEW.name = '$field' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Integration failure'; END IF; END"; + } else { + Database::query('CREATE FUNCTION ' . $fn . "() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.name = '$field' THEN RAISE EXCEPTION 'Integration failure'; END IF; RETURN NEW; END $$", Db::WRITE); + $sql = 'CREATE TRIGGER ' . $name . ' BEFORE INSERT ON ' . table('fields') + . ' FOR EACH ROW EXECUTE PROCEDURE ' . $fn . '()'; + } + Database::query($sql, Db::WRITE); + } + + $failed = null; + $fixtureFiles = []; + try { + // 使用官方建表 SQL,包含真实唯一约束及需要引号的驼峰列名。 + $schema = file_get_contents($typecho . '/install/' . $dialect . '.sql'); + if ($schema === false) { throw new \RuntimeException('缺少官方安装 SQL'); } + $schema = preg_replace('/^\s*--[^\n]*(?:\n|$)/m', '', $schema); + $schema = str_replace(['typecho_', '%engine%', '%charset%'], [$prefix, 'InnoDB', 'utf8mb4'], $schema); + foreach (explode(';', $schema) as $statement) { + if (trim($statement) !== '') { Database::query($statement, Db::WRITE); } + } + // 通过反射调用生产建表代码,避免激活时修改目录、工具和菜单。 + $create = new \ReflectionMethod(Plugin::class, 'createTables'); + $create->setAccessible(true); + $create->invoke(null); + check((int)Plugin::opt('infinitytimeSchemaVersion') === 4, '首次建表必须完成版本迁移'); + check(rowCount('infinitytime_images') === 0, '首次建表不得产生图片记录'); + $create->invoke(null); + check(rowCount('options') === 1, '重复初始化不得重复插入版本选项'); + check($db->selectDb(Db::READ) !== $db->selectDb(Db::WRITE), '测试必须使用独立的 Typecho 读写连接'); + + $resource = Database::query('SELECT 1 AS probe'); + check(Database::query($resource) === $resource, '已执行的真实查询资源必须原样返回'); + unset($resource); + + // 真实 Typecho 延迟展开查询参数,覆盖标点、占位符字面量及 Unicode。 + $text = "图集 ? 50%_! O'Reilly \\ #param:42#"; + $draft = AdminWorkflow::draft(7, ['title' => $text, 'device' => 'Camera ? 日本', 'tags' => 'night', 'address' => '杭州'], 'draft-operation-0001'); + $cid = (int)$draft['cid']; + check($cid > 0 && $draft['status'] === 'draft' && !$draft['replayed'], '创建持久化草稿'); + check(AdminRepository::album($cid, 7, false)['title'] === $text, '真实适配器必须保留引号及 Unicode'); + check(AdminRepository::field($cid, 'device') === 'Camera ? 日本', '草稿元数据成功保存'); + check(AdminRepository::field($cid, AdminRepository::MARKER) === '1', '空草稿具有图集标记'); + $again = AdminWorkflow::draft(7, ['title' => 'Do not overwrite'], 'draft-operation-0001'); + check((int)$again['cid'] === $cid && $again['replayed'], '重试返回原有草稿'); + check(rowCount('contents') === 1, '重试不得创建重复文章'); + $other = AdminWorkflow::draft(8, ['title' => 'Other author'], 'draft-operation-0001'); + check((int)$other['cid'] !== $cid, '操作标识按作者隔离'); + check(AdminRepository::album($cid, 8, false) === null, '其他作者不得访问图集'); + check(AdminRepository::album($cid, 8, true) !== null, '管理员可访问其他作者图集'); + check(AdminRepository::album($cid, 0, true) === null, '缺少用户身份时拒绝访问'); + rejected(static function () use ($cid) { AdminWorkflow::requireAlbum($cid, 8, false); }, \DomainException::class, '写操作拒绝修改其他作者图集'); + rejected(static function () { AdminWorkflow::draft(7, ['title' => 'bad'], 'short'); }, \InvalidArgumentException::class, '拒绝无效操作标识'); + rejected(static function () { AdminWorkflow::draft(7, ['title' => ' '], 'draft-operation-0002'); }, \InvalidArgumentException::class, '拒绝空白标题'); + check(AdminSecurity::validMutation('POST', 'token', 'token') && !AdminSecurity::validMutation('GET', 'token', 'token') + && !AdminSecurity::validMutation('POST', '', '') && !AdminSecurity::validMutation('POST', 'token', ['token']), '写操作校验缺失时必须拒绝'); + + $ordinary = post([]); + check(AdminRepository::album($ordinary, 7, false) === null, '普通文章不应被识别为图集'); + foreach (['attachment', 'page'] as $type) { + $excluded = post(['type' => $type]); + AdminRepository::setField($excluded, AdminRepository::MARKER, '1'); + check(AdminRepository::album($excluded, 7, true) === null, '拒绝非文章内容类型:' . $type); + } + $hidden = post(['status' => 'private']); + AdminRepository::setField($hidden, AdminRepository::MARKER, '1'); + check(AdminRepository::album($hidden, 7, true) === null, '拒绝不支持的文章状态'); + rejected(static function () use ($cid) { AdminWorkflow::publish($cid, 7, false, true); }, \InvalidArgumentException::class, '空图集不能发布'); + check(AdminRepository::page(7, false, 1, '50%_!')['total'] === 1, '搜索中的 LIKE 通配符按字面处理'); + check(AdminRepository::page(7, false, 1, "' OR 1=1 --")['total'] === 0, '搜索不得注入 SQL'); + check(AdminRepository::page(8, false)['total'] === 1, '分页仅返回有权限的作者内容'); + + // Typecho 会将传给 Db::query(..., WRITE) 的 SELECT Query 对象重新路由到 READ。 + // 生产辅助函数必须先编译字符串,再选择主库连接。 + AdminRepository::setField($cid, 'device', 'before transaction'); + Database::query('BEGIN', Db::WRITE); + try { + AdminRepository::setField($cid, 'device', 'inside transaction'); + check(AdminRepository::field($cid, 'device', true) === 'inside transaction', '主库读取可见尚未提交的元数据'); + check(AdminRepository::field($cid, 'device') === 'before transaction', '读库不能看见主库未提交的数据'); + $query = $db->select('str_value')->from('table.fields')->where('cid = ?', $cid)->where('name = ?', 'device'); + check(AdminRepository::readAll($query, true)[0]['str_value'] === 'inside transaction', 'readAll 编译查询并读取主库'); + } finally { Database::query('ROLLBACK', Db::WRITE); } + check(AdminRepository::field($cid, 'device') === 'before transaction', '回滚恢复原有字段'); + + $before = rowCount('contents'); + failureTrigger(true); + try { + injectedFailure(static function () { AdminWorkflow::draft(7, ['title' => 'Rollback draft', 'device' => 'first write', 'tags' => 'fail'], 'draft-operation-fail'); }, + '草稿字段失败必须抛出数据库错误'); + } finally { failureTrigger(false); } + check(rowCount('contents') === $before, '草稿及此前字段写入必须共同回滚'); + check(AdminRepository::draftForOperation(7, 'draft-operation-fail') === null, '失败草稿不得残留操作标记'); + + $a = photo($cid, ['full' => '/usr/uploads/full/late.webp', 'title' => 'Late', 'upload_key' => 'photo-operation-0001'], 20); + $b = photo($cid, ['full' => '/usr/uploads/full/early.webp', 'title' => "Early ' ?", 'desc' => 'Unicode 雨', 'width' => 800, 'height' => 600], 10); + check($a > 0 && $b > $a, '图片插入返回真实自增主键'); + injectedFailure(static function () use ($cid) { photo($cid, ['upload_key' => 'photo-operation-0001']); }, + '同一图集内上传重试标识必须唯一', $dialect === 'SQLite' ? 'UNIQUE' : 'duplicate'); + check(rowCount('infinitytime_images') === 2, '拒绝重复上传时不得增加图片记录'); + check(photo((int)$other['cid'], ['upload_key' => 'photo-operation-0001']) > 0, '不同图集可使用相同上传标识'); + check(photo((int)$other['cid']) > 0 && photo((int)$other['cid']) > 0, '兼容多条旧记录的 NULL 上传标识'); + ImageRepository::syncPostFields($cid); + check(AdminRepository::field($cid, 'img') === "/usr/uploads/full/early.webp\n/usr/uploads/full/late.webp", '图片路径按排序字段排列'); + check(json_decode(AdminRepository::field($cid, 'photo_ids'), true) === [$b, $a], '图片 ID 与路径逐项对齐'); + check(json_decode(AdminRepository::field($cid, 'titles'), true) === ["Early ' ?", 'Late'], '标题与路径逐项对齐'); + check(json_decode(AdminRepository::field($cid, 'dims'), true) === ['800x600', '1200x600'], '尺寸与路径逐项对齐'); + check(json_decode(AdminRepository::field($cid, 'panos'), true) === [0, 1], '全景标记与图片逐项对齐'); + $exif = json_decode(AdminRepository::field($cid, 'exif'), true); + check(!isset($exif[0]['gps']) && !isset($exif[1]['gps']), '公开 EXIF 字段不含 GPS'); + check(AdminRepository::counts([$cid, $cid, 0, -1]) === [$cid => 2], '图片计数去重并忽略无效图集 ID'); + check(count(ImageRepository::rowsForCids([$cid, (int)$other['cid']])) === 2, '批量图片查询按图集分组'); + $legacy = post(['title' => 'Legacy album']); + AdminRepository::setField($legacy, 'img', "/legacy/a.webp\n/legacy/b.webp"); + ImageRepository::syncPostFields($legacy); + check(AdminRepository::counts([$legacy]) === [$legacy => 2], '旧图集按换行计数的 SQL 跨数据库兼容'); + check(AdminRepository::album($legacy, 7, false) !== null, '旧字段仍可识别图集'); + check(AdminRepository::field($legacy, 'img') === "/legacy/a.webp\n/legacy/b.webp", '同步保留只有旧字段的图片'); + + // 原子替换:在 img/thumb 已重写之后,令后续字段插入失败。 + $snapshot = fields($cid); + Database::query($db->update(ImageRepository::table())->rows(['title' => 'Changed'])->where('id = ?', $b)); + failureTrigger(true, 'titles'); + try { + injectedFailure(static function () use ($cid) { ImageRepository::syncPostFields($cid); }, + '字段同步失败必须抛出数据库错误'); + } finally { failureTrigger(false); } + check(fields($cid) === $snapshot, '字段同步失败必须完整保留旧快照'); + ImageRepository::syncPostFields($cid); + check(json_decode(AdminRepository::field($cid, 'titles'), true)[0] === 'Changed', '重试能够更新全部字段'); + check(json_decode(AdminRepository::field($cid, 'months'), true) === ['2024-05', '2024-05'], 'EXIF 拍摄月份与图片逐项对齐'); + + // 调用方已有事务时,同步不得提交或丢弃调用方的其他修改。 + $outerSnapshot = fields($cid); + Database::query('BEGIN', Db::WRITE); + try { + AdminRepository::setField($cid, 'device', 'outer successful sync'); + Database::query($db->update(ImageRepository::table())->rows(['title' => 'Uncommitted image'])->where('id = ?', $b)); + ImageRepository::syncPostFields($cid); + check(json_decode(AdminRepository::field($cid, 'titles', true), true)[0] === 'Uncommitted image', '嵌套同步读取主库未提交图片'); + check(AdminRepository::field($cid, 'device') === 'before transaction', '嵌套同步不得提交调用方写入'); + } finally { Database::query('ROLLBACK', Db::WRITE); } + check(fields($cid) === $outerSnapshot, '调用方回滚可撤销成功的嵌套同步'); + + failureTrigger(true, 'titles'); + Database::query('BEGIN', Db::WRITE); + try { + AdminRepository::setField($cid, 'device', 'outer survives failure'); + injectedFailure(static function () use ($cid) { ImageRepository::syncPostFields($cid); }, '嵌套同步正确报告注入错误'); + check(AdminRepository::field($cid, 'device', true) === 'outer survives failure', '嵌套同步失败保留调用方此前写入'); + check(json_decode(AdminRepository::field($cid, 'titles', true), true)[0] === 'Changed', '嵌套同步失败后事务内标题恢复旧值'); + AdminRepository::setField($cid, 'device', 'outer remains usable'); + check(AdminRepository::field($cid, 'device', true) === 'outer remains usable', '同步失败后调用方仍可继续使用事务'); + } finally { + Database::query('ROLLBACK', Db::WRITE); + failureTrigger(false); + } + check(fields($cid) === $outerSnapshot, '同步失败后调用方回滚恢复全部字段'); + + if ($dialect === 'Mysql') { + // autocommit=0 的隐式事务也属于调用方,不能被同步函数提交。 + Database::query('SET autocommit = 0'); + try { + AdminRepository::setField($cid, 'device', 'autocommit-off'); + ImageRepository::syncPostFields($cid); + check(AdminRepository::field($cid, 'device', true) === 'autocommit-off', '同步保留 autocommit=0 事务中的修改'); + check(AdminRepository::field($cid, 'device') === 'before transaction', '同步不提交 autocommit=0 的调用方事务'); + } finally { + Database::query('ROLLBACK'); + Database::query('SET autocommit = 1'); + } + check(fields($cid) === $outerSnapshot, '隐式事务回滚后原字段完整保留'); + Database::query('ALTER TABLE ' . table('fields') . ' ENGINE=MyISAM'); + try { + rejected(static function () use ($cid) { ImageRepository::syncPostFields($cid); }, \RuntimeException::class, '不支持回滚的 MyISAM 必须拒绝同步'); + check(fields($cid) === $outerSnapshot, '拒绝 MyISAM 时不能改动任何字段'); + } finally { Database::query('ALTER TABLE ' . table('fields') . ' ENGINE=InnoDB'); } + } + + rejected(static function () use ($cid) { AdminWorkflow::publish($cid, 7, false, false); }, \DomainException::class, '贡献者不能发布图集'); + AdminWorkflow::publish($cid, 7, false, true); + check(AdminRepository::album($cid, 7, false)['status'] === 'publish', '编辑可发布非空图集'); + rejected(static function () use ($cid) { AdminWorkflow::requireAlbum($cid, 7, false, false); }, \DomainException::class, '贡献者不能修改已发布图集'); + check(AdminWorkflow::requireAlbum($cid, 7, false, true)['status'] === 'publish', '编辑可修改自己已发布的图集'); + + // 模拟真正滞后的副本:读库完全没有主库的图集、图片和自定义字段。 + $primaryDb = $db; + $primaryConfig = $primaryDb->getConfig(Db::WRITE)->toArray(); + $replicaConfig = $primaryConfig; + $replicaName = $prefix . 'replica'; + $replicaDb = null; + $splitDb = null; + $replicaCreated = false; + try { + if ($dialect === 'SQLite') { + $replicaConfig['file'] = $tmp . '/replica.sqlite'; + } else { + Database::query('CREATE DATABASE ' . ($dialect === 'Mysql' ? '`' : '"') . $replicaName . ($dialect === 'Mysql' ? '`' : '"')); + $replicaConfig['database'] = $replicaName; + $replicaCreated = true; + } + $replicaDb = new Db($adapter, $prefix); + $replicaDb->addServer($replicaConfig, Db::READ | Db::WRITE); + Db::set($replicaDb); + foreach (explode(';', $schema) as $statement) { + if (trim($statement) !== '') { Database::query($statement); } + } + $create->invoke(null); + $splitDb = new Db($adapter, $prefix); + $splitDb->addServer($primaryConfig, Db::WRITE); + $splitDb->addServer($replicaConfig, Db::READ); + Db::set($splitDb); + // 先连接主库,再连接副本,复现官方 Mysqli 的内部连接被后一次 connect 覆盖。 + $splitDb->selectDb(Db::WRITE); + check(AdminRepository::page(7, true)['total'] === 0, '滞后副本确实不含主库图集'); + check(AdminRepository::album($cid, 7, false) === null, '普通读取仍使用副本'); + check(AdminWorkflow::requireAlbum($cid, 7, false, true)['status'] === 'publish', '写权限检查必须读取主库'); + check(AdminRepository::field($cid, 'device', true) === 'before transaction', '字段写前检查必须读取主库'); + check((int)AdminRepository::draftForOperation(7, 'draft-operation-0001')['cid'] === $cid, '草稿重试必须读取主库'); + check(count(ImageRepository::rowsFor($cid)) === 2, '持锁图片读取必须使用主库'); + check(count(ImageRepository::rowsForCids([$cid])[$cid]) === 2, '持锁批量图片读取必须使用主库'); + check(AdminRepository::counts([$cid], true) === [$cid => 2], '发布计数必须读取主库'); + $isolatedDraft = AdminWorkflow::draft(7, ['title' => '主库新建草稿'], 'draft-primary-only'); + $newCid = (int)$isolatedDraft['cid']; + check($newCid > 0 && AdminRepository::album($newCid, 7, false) === null, '新增草稿返回主库 ID,副本仍为空'); + check(AdminRepository::album($newCid, 7, false, true) !== null, '新增草稿确实写入主库'); + $newPhoto = photo($newCid, ['title' => '主库新图片']); + check($newPhoto > 0 && count(ImageRepository::rowsFor($newCid)) === 1, '图片入库返回主库 ID'); + ImageRepository::syncPostFields($newCid); + check(AdminRepository::field($newCid, 'img', true) === '/usr/uploads/full/a.webp', '字段同步读取并写入主库'); + check(AdminRepository::field($newCid, 'img') === '', '字段同步没有污染副本'); + check(Database::query($splitDb->update(ImageRepository::table())->rows(['title' => '修改主库图片'])->where('id = ?', $newPhoto)) === 1, '更新返回主库实际影响行数'); + check(Database::query($splitDb->delete(ImageRepository::table())->where('id = ?', $newPhoto)) === 1, '删除返回主库实际影响行数'); + check(count(ImageRepository::rowsFor($newCid)) === 0, '删除确实发生在主库'); + Plugin::setOption('infinitytimeReplicaProbe', '仅主库'); + $probeQuery = $splitDb->select('value')->from('table.options')->where('name = ?', 'infinitytimeReplicaProbe'); + check(AdminRepository::readRow($probeQuery, true)['value'] === '仅主库', '选项写入只发生在主库'); + check($splitDb->fetchRow($probeQuery) === null, '选项写入没有污染副本'); + Plugin::setOption('infinitytimeSchemaVersion', '3'); + Plugin::migrateSchema(); + $versionQuery = $splitDb->select('value')->from('table.options')->where('name = ?', 'infinitytimeSchemaVersion'); + check((int)AdminRepository::readRow($versionQuery, true)['value'] === 4, '主库迁移完成后版本正确'); + check($splitDb->fetchRow($versionQuery) === null, '迁移没有写入副本版本'); + + } finally { + Db::set($primaryDb); + foreach ([$splitDb, $replicaDb] as $connection) { + if ($connection !== null) { + // native pg_connect 会缓存连接,显式断开后才允许 DROP DATABASE。 + if ($adapter === 'Pgsql') { + foreach ([Db::READ, Db::WRITE] as $operation) { + try { + $handle = $connection->selectDb($operation); + if ($handle !== $primaryDb->selectDb(Db::WRITE) && $handle !== $primaryDb->selectDb(Db::READ)) { pg_close($handle); } + } catch (\Throwable $ignored) {} + } + } + $connection->flushPool(); + } + } + unset($connection, $splitDb, $replicaDb, $probeQuery, $versionQuery); + if ($replicaCreated) { + Database::query('DROP DATABASE ' . ($dialect === 'Mysql' ? '`' : '"') . $replicaName . ($dialect === 'Mysql' ? '`' : '"')); + } + foreach (glob($tmp . '/replica.sqlite*') ?: [] as $file) { @unlink($file); } + } + + // 原子编辑、嵌套事务、故障注入与非事务引擎回归。 + require __DIR__ . '/edit-workflow.php'; + + // 真实删除事务:数据库字段失败、外层回滚都不能提前移除实体文件。 + $deleteDraft = AdminWorkflow::draft(7, ['title' => '删除事务验证', 'device' => '删除前设备'], 'draft-delete-tests'); + $deleteCid = (int)$deleteDraft['cid']; + $makeFiles = static function (string $name) use (&$fixtureFiles): array { + $paths = []; + foreach (['original' => 'original', 'full' => 'full', 'thumb' => 'thumb', 'mid' => 'full', 'avif' => 'full', 'mid_avif' => 'full'] as $variant => $directory) { + $relative = '/usr/uploads/' . $directory . '/' . $name . '-' . $variant . '.webp'; + $absolute = ImageRepository::toAbs($relative); + if (!is_dir(dirname($absolute)) && !mkdir(dirname($absolute), 0700, true)) { + throw new \RuntimeException('无法创建删除测试图片目录'); + } + if (file_put_contents($absolute, 'integration-image-' . $name . '-' . $variant) === false) { + throw new \RuntimeException('无法创建删除测试图片'); + } + $fixtureFiles[] = $absolute; + $paths[$variant] = $relative; + } + return $paths; + }; + $targetPaths = $makeFiles('delete-target'); + $keeperPaths = $makeFiles('delete-keeper'); + $allFilesExist = static function (array $paths): bool { + foreach ($paths as $relative) { + $absolute = ImageRepository::toAbs($relative); + clearstatcache(true, $absolute); + if (!is_file($absolute)) { return false; } + } + return true; + }; + $allFilesGone = static function (array $paths): bool { + foreach ($paths as $relative) { + $absolute = ImageRepository::toAbs($relative); + clearstatcache(true, $absolute); + if (file_exists($absolute)) { return false; } + } + return true; + }; + $deleteId = photo($deleteCid, $targetPaths + ['title' => '待删除图片'], 0); + $keeperId = photo($deleteCid, $keeperPaths + ['title' => '保留图片'], 1); + ImageRepository::syncPostFields($deleteCid); + $deleteSnapshot = fields($deleteCid); + check(!ImageRepository::deleteImage($deleteId, $deleteCid + 1000000), '删除必须同时匹配图片 ID 与图集 ID'); + check(count(ImageRepository::rowsFor($deleteCid)) === 2 && fields($deleteCid) === $deleteSnapshot, '图集 ID 不匹配时不改动图片或字段'); + check($allFilesExist($targetPaths), '图集 ID 不匹配时保留全部实体变体'); + failureTrigger(true, 'titles'); + try { + injectedFailure(static function () use ($deleteId, $deleteCid) { ImageRepository::deleteImage($deleteId, $deleteCid); }, '删除图片时字段失败必须抛出错误'); + } finally { failureTrigger(false); } + check(count(ImageRepository::rowsFor($deleteCid)) === 2, '删除后字段失败必须回滚图片行'); + check(fields($deleteCid) === $deleteSnapshot, '删除后字段失败必须恢复全部字段快照'); + check($allFilesExist($targetPaths) && $allFilesExist($keeperPaths), '删除失败必须保留全部实体变体'); + Database::query('BEGIN'); + try { + AdminRepository::setField($deleteCid, 'device', '调用方未提交设备'); + check(ImageRepository::deleteImage($deleteId, $deleteCid), '外层事务内允许删除图片记录'); + check(count(ImageRepository::rowsFor($deleteCid)) === 1, '外层事务内可见图片已删除'); + check(AdminRepository::field($deleteCid, 'img', true) === $keeperPaths['full'], '外层事务内字段同步保留剩余图片'); + check(AdminRepository::field($deleteCid, 'device', true) === '调用方未提交设备', '删除操作保留外层事务此前写入'); + check(AdminRepository::field($deleteCid, 'device') === '删除前设备', '删除操作不得提交外层事务'); + check($allFilesExist($targetPaths), '外层事务未结束前不能清理已删除图片的文件'); + } finally { Database::query('ROLLBACK'); } + check(count(ImageRepository::rowsFor($deleteCid)) === 2, '调用方回滚恢复被删除的图片行'); + check(fields($deleteCid) === $deleteSnapshot, '调用方回滚恢复删除前全部字段'); + check($allFilesExist($targetPaths), '调用方回滚后全部图片变体仍存在'); + if ($dialect === 'Mysql') { + Database::query('ALTER TABLE ' . table('infinitytime_images') . ' ENGINE=MyISAM'); + try { + rejected(static function () use ($deleteId, $deleteCid) { ImageRepository::deleteImage($deleteId, $deleteCid); }, \RuntimeException::class, '图片表为 MyISAM 时必须拒绝删除'); + check(count(ImageRepository::rowsFor($deleteCid)) === 2 && fields($deleteCid) === $deleteSnapshot, '拒绝 MyISAM 图片表时不改动数据库记录'); + check($allFilesExist($targetPaths), '拒绝 MyISAM 图片表时保留全部实体变体'); + } finally { Database::query('ALTER TABLE ' . table('infinitytime_images') . ' ENGINE=InnoDB'); } + } + check(ImageRepository::deleteImage($deleteId, $deleteCid), '独立删除事务成功提交'); + check(count(ImageRepository::rowsFor($deleteCid)) === 1, '提交后只删除目标图片行'); + check(AdminRepository::field($deleteCid, 'img') === $keeperPaths['full'], '提交后字段只保留剩余图片'); + check(json_decode(AdminRepository::field($deleteCid, 'photo_ids'), true) === [$keeperId], '删除后图片 ID 与路径保持对齐'); + check($allFilesGone($targetPaths), '独立事务提交后清理全部无引用图片变体'); + check($allFilesExist($keeperPaths), '删除目标图片不能清理其他图片变体'); + check(!ImageRepository::deleteImage($deleteId, $deleteCid), '重复删除返回未找到而不重复修改'); + $sharedId = photo($deleteCid, $keeperPaths + ['title' => '共用实体文件'], 2); + ImageRepository::syncPostFields($deleteCid); + check(ImageRepository::deleteImage($sharedId, $deleteCid), '允许删除引用共用文件的图片行'); + check($allFilesExist($keeperPaths), '仍被其他图片引用的全部变体必须保留'); + check(ImageRepository::deleteImage($keeperId, $deleteCid), '允许删除图集最后一张图片'); + check(ImageRepository::rowsFor($deleteCid) === [], '删除最后一张图片后图集无图片记录'); + check(AdminRepository::field($deleteCid, 'img') === '' && AdminRepository::field($deleteCid, 'photo_ids') === '', '删除最后一张图片清空路径及对齐元数据'); + check(AdminRepository::album($deleteCid, 7, false) !== null, '删除最后一张图片后仍保留图集身份'); + check($allFilesGone($keeperPaths), '最后一个引用删除后才清理共用变体文件'); + + // 整图集删除必须在同一事务内处理 images、fields 和 contents 三张表。 + $albumDraft = AdminWorkflow::draft(7, ['title' => '整图集删除', 'device' => '图集原设备'], 'draft-delete-album'); + $albumCid = (int)$albumDraft['cid']; + $sharedDraft = AdminWorkflow::draft(7, ['title' => '跨图集共享引用'], 'draft-shared-album'); + $sharedCid = (int)$sharedDraft['cid']; + $privateAlbumPaths = $makeFiles('album-private'); + $sharedAlbumPaths = $makeFiles('album-shared'); + photo($albumCid, $privateAlbumPaths, 0); + photo($albumCid, $sharedAlbumPaths, 1); + photo($sharedCid, $sharedAlbumPaths); + ImageRepository::syncPostFields($albumCid); + ImageRepository::syncPostFields($sharedCid); + $albumFields = fields($albumCid); + $albumImages = ImageRepository::rowsFor($albumCid); + $contentFor = static function (int $id): array { + return AdminRepository::readRow(Db::get()->select()->from('table.contents')->where('cid = ?', $id), true); + }; + $albumContent = $contentFor($albumCid); + $deleteTrigger = static function (bool $enabled, string $target) use ($prefix, $dialect): void { + if (!in_array($target, ['fields', 'contents'], true)) { throw new \InvalidArgumentException('删除触发器表无效'); } + $name = $prefix . 'reject_album_delete'; + $fn = $prefix . 'reject_album_delete_fn'; + if (!$enabled) { + Database::query('DROP TRIGGER IF EXISTS ' . $name . ($dialect === 'Pgsql' ? ' ON ' . table($target) : '')); + if ($dialect === 'Pgsql') { Database::query('DROP FUNCTION IF EXISTS ' . $fn . '()'); } + return; + } + if ($dialect === 'SQLite') { + $sql = 'CREATE TRIGGER ' . $name . ' BEFORE DELETE ON ' . table($target) . " BEGIN SELECT RAISE(ABORT, 'Integration failure'); END"; + } elseif ($dialect === 'Mysql') { + $sql = 'CREATE TRIGGER ' . $name . ' BEFORE DELETE ON ' . table($target) . " FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Integration failure'"; + } else { + Database::query('CREATE FUNCTION ' . $fn . "() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Integration failure'; RETURN OLD; END $$"); + $sql = 'CREATE TRIGGER ' . $name . ' BEFORE DELETE ON ' . table($target) . ' FOR EACH ROW EXECUTE PROCEDURE ' . $fn . '()'; + } + Database::query($sql); + }; + foreach (['fields', 'contents'] as $faultTable) { + $deleteTrigger(true, $faultTable); + try { + injectedFailure(static function () use ($albumCid) { ImageRepository::deleteAlbum($albumCid); }, '整图集删除遇到 ' . $faultTable . ' 故障必须失败'); + } finally { $deleteTrigger(false, $faultTable); } + check(ImageRepository::rowsFor($albumCid) === $albumImages, $faultTable . ' 删除失败后图片行完整回滚'); + check(fields($albumCid) === $albumFields && $contentFor($albumCid) === $albumContent, $faultTable . ' 删除失败后文章和全部字段完整回滚'); + check($allFilesExist($privateAlbumPaths) && $allFilesExist($sharedAlbumPaths), $faultTable . ' 删除失败后全部实体文件保留'); + } + Database::query('BEGIN'); + try { + AdminRepository::setField($cid, 'device', '整图集删除外层写入'); + check(ImageRepository::deleteAlbum($albumCid), '外层事务内整图集删除成功'); + check(ImageRepository::rowsFor($albumCid) === [] && $contentFor($albumCid) === [], '外层事务内可见图集已删除'); + check(AdminRepository::field($cid, 'device', true) === '整图集删除外层写入', '整图集删除保留调用方其他修改'); + check(AdminRepository::field($cid, 'device') === 'before transaction', '整图集删除不得提交调用方事务'); + check($allFilesExist($privateAlbumPaths) && $allFilesExist($sharedAlbumPaths), '外层事务内整图集删除不清理文件'); + } finally { Database::query('ROLLBACK'); } + check(ImageRepository::rowsFor($albumCid) === $albumImages && fields($albumCid) === $albumFields && $contentFor($albumCid) === $albumContent, '外层回滚恢复整图集三张表的全部记录'); + check($allFilesExist($privateAlbumPaths) && $allFilesExist($sharedAlbumPaths), '整图集外层回滚后全部文件仍存在'); + if ($dialect === 'Mysql') { + Database::query('ALTER TABLE ' . table('contents') . ' ENGINE=MyISAM'); + try { + rejected(static function () use ($albumCid) { ImageRepository::deleteAlbum($albumCid); }, \RuntimeException::class, 'contents 非 InnoDB 必须拒绝整图集删除'); + check(ImageRepository::rowsFor($albumCid) === $albumImages && fields($albumCid) === $albumFields && $contentFor($albumCid) === $albumContent, '拒绝非事务文章表时三表数据均不变'); + check($allFilesExist($privateAlbumPaths), '拒绝非事务文章表时不删除文件'); + } finally { Database::query('ALTER TABLE ' . table('contents') . ' ENGINE=InnoDB'); } + } + check(ImageRepository::deleteAlbum($albumCid), '独立整图集删除事务成功提交'); + check(ImageRepository::rowsFor($albumCid) === [] && fields($albumCid) === [] && $contentFor($albumCid) === [], '整图集提交后移除三表记录'); + check($allFilesGone($privateAlbumPaths), '整图集提交后清理无共享引用的全部变体'); + check($allFilesExist($sharedAlbumPaths) && $contentFor($sharedCid) !== [], '整图集删除保留其他图集的共享文件与记录'); + check(!ImageRepository::deleteAlbum($albumCid) && !ImageRepository::deleteAlbum(0), '重复或无效整图集删除不产生额外操作'); + check(ImageRepository::deleteAlbum($sharedCid) && $allFilesGone($sharedAlbumPaths), '最后一个共享图集删除后清理全部共用变体'); + + Database::query($db->delete(ImageRepository::table())->where('cid = ?', $cid)); + ImageRepository::syncPostFields($cid, true); + check(AdminRepository::field($cid, 'img') === '' && AdminRepository::field($cid, 'photo_ids') === '', '显式清空删除最后一张图的元数据'); + check(AdminRepository::album($cid, 7, false) !== null, '清空后的图集保留识别标记'); + + // 大相册后台分页、缺字段筛选、批量写入和独立封面。 + $batchDraft = AdminWorkflow::draft(7, ['title' => '批量整理'], 'batch-operation-001'); + $batchCid = (int)$batchDraft['cid']; $batchIds = []; + for ($i = 0; $i < 45; $i++) { $batchIds[] = photo($batchCid, ['title' => $i % 2 ? '有标题' : '', 'desc' => $i % 2 ? '有说明' : '', 'address' => ''], $i); } + $firstPage = ImageRepository::pageFor($batchCid); + $secondPage = ImageRepository::pageFor($batchCid, 2); + check($firstPage['total'] === 45 && count($firstPage['rows']) === 40 && count($secondPage['rows']) === 5 && !$firstPage['sortable'], '后台详情默认40张分页且不允许残缺排序'); + check(!array_key_exists('original', $firstPage['rows'][0]) && !array_key_exists('upload_key', $firstPage['rows'][0]), '分页读取不搬运私有原图或上传标识'); + check(ImageRepository::pageFor($batchCid, 1, 'desc')['total'] === 23, '缺说明筛选兼容保留字字段'); + check(count(ImageRepository::pageFor($batchCid, 1, '', true)['rows']) === 45, '用户明确全部排序时可加载完整相册'); + check(AdminWorkflow::batchEdit($batchCid, 7, false, false, array_slice($batchIds, 0, 3), ['desc' => '统一说明']) === 3, '贡献者可批量整理自己草稿'); + $batchRowsBefore = ImageRepository::rowsFor($batchCid); $batchFieldsBefore = fields($batchCid); + rejected(static function () use ($batchCid, $batchIds) { AdminWorkflow::batchEdit($batchCid, 8, false, false, [$batchIds[0]], ['address' => '越权']); }, \DomainException::class, '批量编辑拒绝他人相册'); + rejected(static function () use ($batchCid, $batchIds) { ImageRepository::editImages($batchCid, [$batchIds[0], $batchIds[0]], ['desc' => '重复']); }, \DomainException::class, '批量编辑拒绝重复标识'); + rejected(static function () use ($batchCid, $batchIds) { ImageRepository::editImages($batchCid, [$batchIds[0], 2147483647], ['desc' => '缺失']); }, \DomainException::class, '批量编辑拒绝部分失效标识'); + failureTrigger(true, 'titles'); + try { injectedFailure(static function () use ($batchCid, $batchIds) { ImageRepository::editImages($batchCid, array_slice($batchIds, 0, 3), ['desc' => '应回滚']); }, '批量聚合失败必须回滚图片修改'); } + finally { failureTrigger(false); } + check(ImageRepository::rowsFor($batchCid) === $batchRowsBefore && fields($batchCid) === $batchFieldsBefore, '批量失败保留所有照片与全部旧字段'); + AdminWorkflow::setCover($batchCid, 7, false, false, $batchIds[20]); + check(AdminRepository::field($batchCid, 'cover_id', true) === (string)$batchIds[20] && ImageRepository::rowsFor($batchCid) === $batchRowsBefore, '封面独立设置且不重排照片'); + rejected(static function () use ($batchCid) { AdminWorkflow::setCover($batchCid, 7, false, false, 2147483647); }, \DomainException::class, '封面必须属于当前相册'); + AdminWorkflow::publish($batchCid, 7, false, true); + rejected(static function () use ($batchCid, $batchIds) { AdminWorkflow::batchEdit($batchCid, 7, false, false, [$batchIds[0]], ['desc' => '越权']); }, \DomainException::class, '已发布相册批量编辑需编辑者权限'); + rejected(static function () use ($batchCid, $batchIds) { AdminWorkflow::setCover($batchCid, 7, false, false, $batchIds[1]); }, \DomainException::class, '已发布相册改封面需编辑者权限'); + // 重建改变公开资源版本而不改数据库路径;重复同秒重建也产生新版本。 + $versionCid = post([]); + $versionPhoto = photo($versionCid); + $rawBefore = AdminRepository::readRow($db->select()->from(ImageRepository::table())->where('id = ?', $versionPhoto), true); + ImageRepository::updateVariants($versionPhoto, ['width' => 1200, 'height' => 600]); + ImageRepository::syncPostFields($versionCid); + $rawAfter = AdminRepository::readRow($db->select()->from(ImageRepository::table())->where('id = ?', $versionPhoto), true); + $versionExif = json_decode($rawAfter['exif'], true); + check($rawAfter['full'] === $rawBefore['full'] && $rawAfter['thumb'] === $rawBefore['thumb'], '重建版本不改变物理路径'); + check($versionExif['make'] === 'Camera' && isset($versionExif['gps']), '重建版本保留私有原始EXIF'); + $firstUrl = AdminRepository::field($versionCid, 'img', true); + check(strpos($firstUrl, '?itv=' . $versionExif['_infinity_media_version']) !== false, '公开聚合图片URL带重建版本'); + $publicExif = json_decode(AdminRepository::field($versionCid, 'exif', true), true); + check(!isset($publicExif[0]['gps']) && !isset($publicExif[0]['_infinity_media_version']), '公开EXIF不包含GPS或内部版本键'); + ImageRepository::updateVariants($versionPhoto, ['width' => 1200, 'height' => 600]); + ImageRepository::syncPostFields($versionCid); + check(AdminRepository::field($versionCid, 'img', true) !== $firstUrl, '同秒再次重建也更新缓存标识'); + require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/HealthDiagnostics.php'; + $healthRowsBefore = fields($versionCid); + $health = \TypechoPlugin\InfinityTime\Lib\HealthDiagnostics::collect($db, $tmp, []); + $health = array_column($health, null, 'name'); + check($health['数据库事务']['status'] === ($dialect === 'Mysql' ? 'ok' : 'manual'), '真实适配器健康检查不虚报表引擎'); + check(fields($versionCid) === $healthRowsBefore, '健康检查不改动已存字段'); + // 真实旧表迁移:缺少元数据及重试列时,补齐列并保留已有记录。 + Database::query('DROP TABLE ' . table('infinitytime_images'), Db::WRITE); + $pk = $dialect === 'Mysql' ? 'id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY' + : ($dialect === 'Pgsql' ? 'id SERIAL PRIMARY KEY' : 'id INTEGER PRIMARY KEY AUTOINCREMENT'); + Database::query('CREATE TABLE ' . table('infinitytime_images') . ' (' . $pk . ', cid INTEGER, ' . ($dialect === 'Mysql' ? '`full`' : '"full"') . ' TEXT)', Db::WRITE); + Database::query($db->insert(ImageRepository::table())->rows(['cid' => 1, 'full' => '/preserved.webp'])); + Plugin::setOption('infinitytimeSchemaVersion', '2'); + Plugin::migrateSchema(); + $preserved = $db->fetchRow($db->select()->from(ImageRepository::table())); + check($preserved['full'] === '/preserved.webp' && $preserved['upload_key'] === null, '迁移保留已有图片,重试标识默认为 NULL'); + check(array_key_exists('mid_avif', $preserved) && array_key_exists('desc', $preserved), '迁移修复所有历史缺失列'); + Plugin::setOption('infinitytimeSchemaVersion', '3'); + Plugin::migrateSchema(); + check((int)Plugin::opt('infinitytimeSchemaVersion') === 4, '中断后重试正确验证已有唯一索引'); + check(rowCount('infinitytime_images') === 1, '重复迁移不得增加图片'); + // 同名但非唯一的索引不能被误认为成功升级。 + $index = 'it_upload_' . substr(sha1(ImageRepository::table()), 0, 16); + $quote = $dialect === 'Mysql' ? '`' : '"'; + $dropIndex = 'DROP INDEX ' . $quote . $index . $quote . ($dialect === 'Mysql' ? ' ON ' . table('infinitytime_images') : ''); + Database::query($dropIndex); + Database::query('CREATE INDEX ' . $quote . $index . $quote . ' ON ' . table('infinitytime_images') . ' (cid, upload_key)'); + Plugin::setOption('infinitytimeSchemaVersion', '3'); + rejected(static function () { Plugin::migrateSchema(); }, \RuntimeException::class, '错误的非唯一索引必须阻止升级'); + check((int)Plugin::opt('infinitytimeSchemaVersion') === 3, '索引验证失败不能推进版本'); + check(rowCount('infinitytime_images') === 1, '索引验证失败不能破坏图片'); + Database::query($dropIndex); + Plugin::migrateSchema(); + check((int)Plugin::opt('infinitytimeSchemaVersion') === 4, '管理员移除错误索引后可以重试升级'); + echo '通过:' . $checks . ' 项真实 Typecho ' . \Typecho\Common::VERSION . ' / PHP ' . PHP_VERSION . ' / ' . $adapter . " 数据库集成检查\n"; + } catch (\Throwable $e) { + $failed = $e; + } finally { + ImageRepository::unlockMedia(); + // 即使断言失败也只清理本次随机前缀的临时数据库对象。 + foreach (['infinitytime_images', 'fields', 'contents', 'comments', 'metas', 'options', 'relationships', 'users'] as $name) { + try { Database::query('DROP TABLE IF EXISTS ' . table($name) . ($dialect === 'Pgsql' ? ' CASCADE' : ''), Db::WRITE); } + catch (\Throwable $cleanup) { fwrite(STDERR, '清理提示:' . $cleanup->getMessage() . "\n"); } + } + if ($dialect === 'Pgsql') { + foreach (['comments', 'contents', 'metas', 'users'] as $name) { + try { Database::query('DROP SEQUENCE IF EXISTS ' . table($name . '_seq'), Db::WRITE); } catch (\Throwable $cleanup) {} + } + try { Database::query('DROP FUNCTION IF EXISTS ' . $prefix . 'reject_field_fn()', Db::WRITE); } catch (\Throwable $cleanup) {} + } + $db->flushPool(); + foreach (glob($tmp . '/database.sqlite*') ?: [] as $file) { @unlink($file); } + foreach ($fixtureFiles as $file) { @unlink($file); } + foreach (['original', 'full', 'thumb'] as $directory) { @rmdir($tmp . '/uploads/' . $directory); } + @unlink($tmp . '/uploads/.infinitytime-media.lock'); + @rmdir($tmp . '/uploads'); + @rmdir($tmp); + } + if ($failed) { + fwrite(STDERR, '失败:' . get_class($failed) . ': ' . $failed->getMessage() . "\n" . $failed->getTraceAsString() . "\n"); + exit(1); + } +} diff --git a/tests/upload-fallback.php b/tests/upload-fallback.php index 1d126c1..fb18fd9 100644 --- a/tests/upload-fallback.php +++ b/tests/upload-fallback.php @@ -9,6 +9,7 @@ public static function factory($name): object { return new \stdClass(); } // Typecho 最小启动替身;验证钩子失败返回约定,无需数据库或上传文件。 interface Typecho_Plugin_Interface {} require __DIR__ . '/../usr/plugins/InfinityTime/Lib/MediaProcessor.php'; + require_once __DIR__ . '/../usr/plugins/InfinityTime/Lib/Database.php'; require __DIR__ . '/../usr/plugins/InfinityTime/Plugin.php'; if (\TypechoPlugin\InfinityTime\Plugin::uploadHandle([]) !== false) { throw new RuntimeException('空上传应返回 false'); diff --git a/usr/plugins/InfinityTime/Lib/AdminRepository.php b/usr/plugins/InfinityTime/Lib/AdminRepository.php new file mode 100644 index 0000000..0d07c9f --- /dev/null +++ b/usr/plugins/InfinityTime/Lib/AdminRepository.php @@ -0,0 +1,167 @@ +getAdapterName(), 'mysql') !== false ? '`' : '"'; + return $q . str_replace($q, $q . $q, $db->getPrefix() . $name) . $q; + } + + private static function authorColumn(): string + { + return stripos(Db::get()->getAdapterName(), 'mysql') !== false ? 'c.`authorId`' : 'c."authorId"'; + } + + public static function readRow($query, bool $writer = false): array + { + $db = Db::get(); + if ($writer && is_object($query) && method_exists($query, 'prepare')) { $query = $query->prepare((string)$query); } + return $db->fetchRow($writer ? Database::query($query) : $query) ?: []; + } + + public static function readAll($query, bool $writer = false): array + { + $db = Db::get(); + if ($writer && is_object($query) && method_exists($query, 'prepare')) { $query = $query->prepare((string)$query); } + return $db->fetchAll($writer ? Database::query($query) : $query); + } + + /** 所有参数均由 Typecho 适配器转义;不直接拼接用户输入。 */ + public static function sql(string $sql, array $values = []): string + { + $query = Db::get()->select(); + foreach ($values as $value) { + $pos = strpos($sql, '?'); + if ($pos === false) { throw new \LogicException('SQL 参数数量不匹配'); } + $sql = substr_replace($sql, $query->quoteValue($value), $pos, 1); + } + return $query->prepare($sql); + } + + private static function identity(): string + { + $fields = self::table('fields'); + $images = self::table('infinitytime_images'); + return "(EXISTS (SELECT 1 FROM $images i WHERE i.cid = c.cid)" + . " OR EXISTS (SELECT 1 FROM $fields f WHERE f.cid = c.cid" + . " AND ((f.name = 'img' AND f.str_value <> '')" + . " OR (f.name = 'infinitytime_album' AND f.str_value = '1'))))"; + } + + public static function album(int $cid, int $uid, bool $admin, bool $writer = false): ?array + { + if ($cid <= 0 || $uid <= 0) { return null; } + $sql = 'SELECT c.cid, c.title, ' . self::authorColumn() . ', c.status, c.created FROM ' . self::table('contents') + . " c WHERE c.cid = ? AND c.type = 'post' AND c.status IN ('draft','publish') AND " . self::identity(); + $values = [$cid]; + if (!$admin) { $sql .= ' AND ' . self::authorColumn() . ' = ?'; $values[] = $uid; } + $row = self::readRow(self::sql($sql . ' LIMIT 1', $values), $writer); + return $row ?: null; + } + + public static function page(int $uid, bool $admin, int $page = 1, string $search = ''): array + { + $page = max(1, min(100000, $page)); + $search = trim(function_exists('mb_substr') ? mb_substr($search, 0, 100) : substr($search, 0, 100)); + $where = "c.type = 'post' AND c.status IN ('draft','publish') AND " . self::identity(); + $values = []; + if (!$admin) { $where .= ' AND ' . self::authorColumn() . ' = ?'; $values[] = $uid; } + if ($search !== '') { + // 按字面子串搜索;统一使用 ! 转义通配符,兼容各数据库适配器。 + $where .= " AND c.title LIKE ? ESCAPE '!'"; + $values[] = '%' . strtr($search, ['!' => '!!', '%' => '!%', '_' => '!_']) . '%'; + } + $from = ' FROM ' . self::table('contents') . ' c WHERE ' . $where; + $total = Db::get()->fetchRow(self::sql('SELECT COUNT(*) AS total' . $from, $values)); + $total = (int)($total['total'] ?? 0); + $pages = max(1, (int)ceil($total / self::PAGE_SIZE)); + $page = min($page, $pages); + $rows = Db::get()->fetchAll(self::sql('SELECT c.cid, c.title, c.status, c.created' . $from + . ' ORDER BY c.created DESC, c.cid DESC LIMIT ' . self::PAGE_SIZE + . ' OFFSET ' . (($page - 1) * self::PAGE_SIZE), $values)); + if ($rows) { + $cids = array_map(static function ($row) { return (int)$row['cid']; }, $rows); + $counts = self::counts($cids); + $fields = Db::get()->fetchAll('SELECT cid, name, str_value FROM ' . self::table('fields') + . ' WHERE cid IN (' . implode(',', $cids) . ") AND name IN ('device','tags','location')"); + $meta = []; + foreach ($fields as $field) { $meta[(int)$field['cid']][$field['name']] = $field['str_value']; } + foreach ($rows as &$row) { + $cid = (int)$row['cid']; + $row['img_count'] = $counts[$cid] ?? 0; + foreach (['device', 'tags', 'location'] as $name) { $row[$name] = (string)($meta[$cid][$name] ?? ''); } + } + unset($row); + } + return ['items' => $rows, 'page' => $page, 'pages' => $pages, 'total' => $total, 'query' => $search]; + } + + public static function counts(array $cids, bool $writer = false): array + { + $cids = array_values(array_filter(array_unique(array_map('intval', $cids)), static function ($cid) { return $cid > 0; })); + if (!$cids) { return []; } + $ids = implode(',', $cids); + $counts = []; + foreach (self::readAll('SELECT cid, COUNT(*) AS image_count FROM ' . self::table('infinitytime_images') + . " WHERE cid IN ($ids) GROUP BY cid", $writer) as $row) { + $counts[(int)$row['cid']] = (int)$row['image_count']; + } + // 仅有旧字段的历史图集:在 SQL 内聚合计数,不回传图片列表字段。 + $newline = stripos(Db::get()->getAdapterName(), 'pgsql') !== false || stripos(Db::get()->getAdapterName(), 'postgres') !== false ? 'CHR(10)' : 'CHAR(10)'; + foreach (self::readAll('SELECT cid, LENGTH(str_value) - LENGTH(REPLACE(str_value, ' . $newline . ", '')) + 1 AS image_count FROM " + . self::table('fields') . " WHERE cid IN ($ids) AND name = 'img' AND str_value <> ''", $writer) as $row) { + $cid = (int)$row['cid']; + if (!isset($counts[$cid])) { $counts[$cid] = (int)$row['image_count']; } + } + return $counts; + } + + public static function field(int $cid, string $name, bool $writer = false): string + { + $db = Db::get(); + $row = self::readRow($db->select('str_value')->from($db->getPrefix() . 'fields') + ->where('cid = ?', $cid)->where('name = ?', $name)->limit(1), $writer); + return (string)($row['str_value'] ?? ''); + } + + public static function setField(int $cid, string $name, string $value): void + { + self::setFields($cid, [$name => $value]); + } + + /** 多字段替换共享一个保存点,单字段也不会因插入失败丢失旧值。 */ + public static function setFields(int $cid, array $values): void + { + $db = Db::get(); + $table = $db->getPrefix() . 'fields'; + Database::transaction(static function () use ($db, $table, $cid, $values): void { + foreach ($values as $name => $value) { + if (!is_string($name) || !is_string($value)) { throw new \InvalidArgumentException('字段名称与值必须是字符串'); } + Database::query($db->delete($table)->where('cid = ?', $cid)->where('name = ?', $name)); + if ($value !== '') { + Database::query($db->insert($table)->rows(['cid' => $cid, 'name' => $name, 'type' => 'str', 'str_value' => $value])); + } + } + }, [$table]); + } + + public static function draftForOperation(int $uid, string $key): ?array + { + $sql = 'SELECT c.cid, c.title, c.status FROM ' . self::table('contents') . ' c WHERE ' . self::authorColumn() . ' = ?' + . " AND c.type = 'post' AND c.status IN ('draft','publish') AND " . self::identity() + . ' AND EXISTS (SELECT 1 FROM ' . self::table('fields') + . " f WHERE f.cid = c.cid AND f.name = 'infinitytime_upload' AND f.str_value = ?) LIMIT 1"; + $row = self::readRow(self::sql($sql, [$uid, $key]), true); + return $row ?: null; + } +} diff --git a/usr/plugins/InfinityTime/Lib/AdminSecurity.php b/usr/plugins/InfinityTime/Lib/AdminSecurity.php new file mode 100644 index 0000000..2df5655 --- /dev/null +++ b/usr/plugins/InfinityTime/Lib/AdminSecurity.php @@ -0,0 +1,17 @@ + true]; } + $title = trim((string)($data['title'] ?? '')); + if ($title === '') { throw new \InvalidArgumentException('请填写图集标题'); } + $db = Db::get(); + $now = time(); + $cid = 0; + Database::transaction(static function () use ($db, $title, $now, $uid, $key, $data, &$cid): void { + $cid = (int)Database::query($db->insert($db->getPrefix() . 'contents')->rows([ + 'title' => $title, 'slug' => 'album-' . date('YmdHis', $now) . '-' . bin2hex(random_bytes(6)), + 'created' => $now, 'modified' => $now, 'text' => '', 'authorId' => $uid, + 'type' => 'post', 'status' => 'draft', 'allowComment' => '0', 'allowPing' => '0', + 'allowFeed' => '0', 'template' => '', 'password' => '', + ])); + if ($cid <= 0) { throw new \RuntimeException('图集创建失败'); } + $fields = [AdminRepository::MARKER => '1', AdminRepository::OPERATION => $key]; + foreach (['device' => 'device', 'tags' => 'tags', 'location' => 'address'] as $name => $input) { + $fields[$name] = trim((string)($data[$input] ?? '')); + } + AdminRepository::setFields($cid, $fields); + }, [$db->getPrefix() . 'contents', $db->getPrefix() . 'fields']); + return ['cid' => $cid, 'title' => $title, 'status' => 'draft', 'replayed' => false]; + } + + public static function requireAlbum(int $cid, int $uid, bool $admin, bool $canPublish = false): array + { + $album = AdminRepository::album($cid, $uid, $admin, true); + if (!$album) { throw new \DomainException('图集不存在或没有权限'); } + if ($album['status'] === 'publish' && !$canPublish) { throw new \DomainException('已发布图集需要编辑或管理员权限才能修改'); } + return $album; + } + + /** 图集标题及全部手动字段同事务更新;权限在持锁后重新检查。 */ + public static function updateAlbum(int $cid, int $uid, bool $admin, bool $canPublish, array $data): void + { + ImageRepository::lockMedia(); + self::requireAlbum($cid, $uid, $admin, $canPublish); + $db = Db::get(); + $title = trim((string)($data['title'] ?? '')); + $fields = []; + foreach (['device' => 'device', 'tags' => 'tags', 'location' => 'address'] as $name => $input) { + $fields[$name] = trim((string)($data[$input] ?? '')); + } + Database::transaction(static function () use ($db, $cid, $title, $fields): void { + $values = ['modified' => time()]; + if ($title !== '') { $values['title'] = $title; } + Database::query($db->update($db->getPrefix() . 'contents')->rows($values)->where('cid = ?', $cid)); + AdminRepository::setFields($cid, $fields); + }, [$db->getPrefix() . 'contents', $db->getPrefix() . 'fields']); + } + + public static function batchEdit(int $cid, int $uid, bool $admin, bool $canPublish, array $ids, array $changes): int + { + ImageRepository::lockMedia(); + self::requireAlbum($cid, $uid, $admin, $canPublish); + return ImageRepository::editImages($cid, $ids, $changes); + } + + public static function setCover(int $cid, int $uid, bool $admin, bool $canPublish, int $rowId): void + { + ImageRepository::lockMedia(); + self::requireAlbum($cid, $uid, $admin, $canPublish); + $db = Db::get(); + $row = AdminRepository::readRow($db->select('id')->from(ImageRepository::table())->where('cid = ?', $cid)->where('id = ?', $rowId)->limit(1), true); + if (!$row) { throw new \DomainException('封面照片不存在或不属于当前相册'); } + AdminRepository::setField($cid, 'cover_id', (string)$rowId); + } + + /** 上传标识绑定文件内容与逐图元数据,不受转换设置变化影响。 */ + public static function fingerprint(array $file, array $data): string + { + $hash = @hash_file('sha256', (string)($file['tmp_name'] ?? '')); + if (!is_string($hash)) { throw new \InvalidArgumentException('无法读取上传文件'); } + return hash('sha256', json_encode([$hash, trim((string)($data['title'] ?? '')), + trim((string)($data['desc'] ?? '')), trim((string)($data['address'] ?? ''))], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + } + + public static function upload(int $cid, int $uid, bool $admin, array $file, array $data, string $key, bool $canPublish = false): array + { + if (!AdminSecurity::validKey($key)) { throw new \InvalidArgumentException('图片上传标识无效,请重新选择文件'); } + ImageRepository::lockMedia(); + self::requireAlbum($cid, $uid, $admin, $canPublish); + ImageRepository::ensureSchema(); + $managed = AdminRepository::readRow(Db::get()->select('id')->from(ImageRepository::table())->where('cid = ?', $cid)->limit(1), true); + if (!$managed && AdminRepository::field($cid, 'img', true) !== '') { + throw new \DomainException('此历史图集只有旧图片字段,暂不支持追加。请新建图集上传;原图集未改动'); + } + $error = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE); + if ($error !== UPLOAD_ERR_OK) { + $messages = [UPLOAD_ERR_INI_SIZE => '图片超过服务器上传限制', UPLOAD_ERR_FORM_SIZE => '图片超过表单上传限制', + UPLOAD_ERR_PARTIAL => '图片只上传了一部分,请重试', UPLOAD_ERR_NO_FILE => '没有收到图片文件', + UPLOAD_ERR_NO_TMP_DIR => '服务器缺少临时目录', UPLOAD_ERR_CANT_WRITE => '服务器无法写入临时文件', + UPLOAD_ERR_EXTENSION => '服务器扩展阻止了上传']; + throw new \InvalidArgumentException($messages[$error] ?? '上传失败'); + } + if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) { throw new \InvalidArgumentException('非法上传文件'); } + $bytes = @filesize($file['tmp_name']); + if ($bytes === false || $bytes <= 0 || $bytes > 64 * 1024 * 1024) { throw new \InvalidArgumentException('图片为空或超过 64MB 上限'); } + $fingerprint = self::fingerprint($file, $data); + $db = Db::get(); + $row = AdminRepository::readRow($db->select()->from(ImageRepository::table()) + ->where('cid = ?', $cid)->where('upload_key = ?', $key)->limit(1), true); + if ($row) { + if (!is_string($row['upload_fingerprint'] ?? null) || !hash_equals($row['upload_fingerprint'], $fingerprint)) { + throw new \DomainException('同一上传标识不能用于不同图片或描述,请重新选择文件'); + } + self::sync($cid, $row); + return ['cid' => $cid, 'rowId' => (int)$row['id'], 'replayed' => true]; + } + $dim = @getimagesize($file['tmp_name']); + if (is_array($dim) && (($dim[0] ?? 0) > 20000 || ($dim[1] ?? 0) > 20000)) { + throw new \InvalidArgumentException('图片单边超过 20000px 上限'); + } + $meta = ImageRepository::ingest($file, [ + 'quality' => (int)Plugin::opt('infinitytimeQuality', Plugin::DEFAULT_QUALITY), + 'thumb_max' => (int)Plugin::opt('infinitytimeThumbMax', Plugin::DEFAULT_THUMB_MAX), + 'max_width' => (int)Plugin::opt('infinitytimeMaxWidth', Plugin::DEFAULT_MAX_WIDTH), + 'full_quality' => (int)Plugin::opt('infinitytimeFullQuality', Plugin::DEFAULT_FULL_QUALITY), + 'pano_width' => (int)Plugin::opt('infinitytimePanoWidth', Plugin::DEFAULT_PANO_WIDTH), + 'pano_quality' => (int)Plugin::opt('infinitytimePanoQuality', Plugin::DEFAULT_PANO_QUALITY), + 'keep_original' => (bool)Plugin::opt('infinitytimeKeepOriginal', Plugin::DEFAULT_KEEP_ORIGINAL), + 'address' => trim((string)($data['address'] ?? '')), + ]); + if (!$meta) { throw new \RuntimeException(ImageRepository::$lastError ?: '图片转换失败,请重试'); } + $meta['title'] = trim((string)($data['title'] ?? '')); + $meta['desc'] = trim((string)($data['desc'] ?? '')); + $meta['upload_key'] = $key; + $meta['upload_fingerprint'] = $fingerprint; + $max = AdminRepository::readRow($db->select('MAX(sort) AS last_sort')->from(ImageRepository::table())->where('cid = ?', $cid), true); + $sort = isset($max['last_sort']) ? (int)$max['last_sort'] + 1 : 0; + // 标识与图片行原子入库;字段同步失败后仍可幂等重试。 + $rowId = ImageRepository::insertRow($cid, $meta, $sort); + if ($rowId <= 0) { throw new \RuntimeException('图片记录写入失败,请重试'); } + self::sync($cid, $meta); + return ['cid' => $cid, 'rowId' => $rowId, 'replayed' => false]; + } + + private static function sync(int $cid, array $row): void + { + ImageRepository::syncPostFields($cid); + // 保留手动填写的设备;否则从成功图片的 EXIF 提取设备。 + if (AdminRepository::field($cid, 'device', true) === '') { + $exif = is_array($row['exif'] ?? null) ? $row['exif'] : (json_decode((string)($row['exif'] ?? '{}'), true) ?: []); + $device = trim((string)($exif['make'] ?? '') . ' ' . (string)($exif['model'] ?? '')); + if ($device !== '') { AdminRepository::setField($cid, 'device', $device); } + } + } + + public static function publish(int $cid, int $uid, bool $admin, bool $canPublish = false): void + { + if (!$canPublish) { throw new \DomainException('发布图集需要编辑或管理员权限'); } + ImageRepository::lockMedia(); + self::requireAlbum($cid, $uid, $admin, $canPublish); + $counts = AdminRepository::counts([$cid], true); + if (($counts[$cid] ?? 0) < 1) { throw new \InvalidArgumentException('请先上传至少一张图片再发布'); } + ImageRepository::syncPostFields($cid); + $db = Db::get(); + Database::query($db->update($db->getPrefix() . 'contents')->rows(['status' => 'publish', 'modified' => time()])->where('cid = ?', $cid)); + } +} diff --git a/usr/plugins/InfinityTime/Lib/Database.php b/usr/plugins/InfinityTime/Lib/Database.php new file mode 100644 index 0000000..823cccf --- /dev/null +++ b/usr/plugins/InfinityTime/Lib/Database.php @@ -0,0 +1,126 @@ +getAttribute('action') : null; + $mysqli = strcasecmp($db->getAdapterName(), 'Mysqli') === 0; + if (!$mysqli) { + // Typecho 对 SELECT Query 强制改用 READ,即使传入 WRITE 也会覆盖。 + if ($action === 'SELECT') { $query = $query->prepare((string)$query); } + return $db->query($query, Db::WRITE); + } + + // 与 Db::query 一样,已经执行的结果句柄原样返回。 + if (!is_string($query) && !(is_object($query) && method_exists($query, 'prepare'))) { return $query; } + + $handle = $db->selectDb(Db::WRITE); + // Typecho Mysqli::query 忽略 handle,改用最近 connect 的内部对象。 + // 不能通过 flushPool 修复,否则会丢失调用方正在执行的事务。 + $sql = is_string($query) ? $query : $query->prepare((string)$query); + $result = $handle->query($sql); + if ($result === false) { throw new \RuntimeException($handle->error, $handle->errno); } + if ($action === 'INSERT') { return $handle->insert_id; } + if ($action === 'UPDATE' || $action === 'DELETE') { return $handle->affected_rows; } + return $result; + } + + /** + * 在主库保存点中执行受控写入,保留调用方事务边界。 + * $tables 仅由内部调用点给出真实表名,用于写入前验证 MySQL 事务引擎。 + * 返回 true 仅表示本方法创建且成功提交了事务;文件清理须以此为依据。 + */ + public static function transaction(callable $operation, array $tables): bool + { + $db = Db::get(); + $adapter = strtolower($db->getAdapterName()); + $handle = $db->selectDb(Db::WRITE); + $write = static function ($query) { + // 所有同步语句编译后固定走主库;不需要 INSERT 的生成主键。 + return self::query(is_string($query) ? $query : $query->prepare((string)$query)); + }; + $savepoint = 'infinitytime_fields_' . bin2hex(random_bytes(8)); + $owned = false; + $mysql = strpos($adapter, 'mysql') !== false; + if ($mysql) { + // MyISAM 等表忽略回滚,不能宣称字段替换是原子的;不自动 ALTER 用户表。 + foreach ($tables as $name) { + $query = $db->select(); + $sql = $query->prepare('SHOW TABLE STATUS WHERE Name = ' . $query->quoteValue($name)); + $table = $db->fetchRow($write($sql)); + if (strcasecmp((string)($table['Engine'] ?? ''), 'InnoDB') !== 0) { + throw new \RuntimeException('此操作需要相关表使用 InnoDB 事务引擎,请先备份并由管理员迁移表引擎'); + } + } + // MySQL 在 autocommit 下静默忽略 SAVEPOINT;必须在写入前验证它实际存在。 + // 不用 BEGIN 探测:它会隐式提交已有事务。也兼容调用方 SET autocommit=0。 + $write('SAVEPOINT ' . $savepoint); + try { + // 直接检查驱动错误码,避免 Typecho PDO 包装丢失 MySQL errno。 + $result = $handle->query('ROLLBACK TO SAVEPOINT ' . $savepoint); + if ($result === false) { + $code = $handle instanceof \PDO ? (int)($handle->errorInfo()[1] ?? 0) : (int)$handle->errno; + throw new \RuntimeException('无法验证字段同步保存点', $code); + } + } catch (\Throwable $e) { + $code = $e instanceof \PDOException ? (int)($e->errorInfo[1] ?? 0) : (int)$e->getCode(); + if ($code !== 1305) { throw $e; } + $write('BEGIN'); + $owned = true; + } + } elseif (strpos($adapter, 'pgsql') !== false || strpos($adapter, 'postgres') !== false) { + if ($handle instanceof \PDO) { + $active = $handle->inTransaction(); + } elseif (function_exists('pg_transaction_status')) { + $status = pg_transaction_status($handle); + if (!in_array($status, [PGSQL_TRANSACTION_IDLE, PGSQL_TRANSACTION_INTRANS], true)) { + throw new \RuntimeException('数据库事务不可用,请先结束当前失败的事务'); + } + $active = $status === PGSQL_TRANSACTION_INTRANS; + } else { + throw new \RuntimeException('无法安全检测数据库事务状态'); + } + if (!$active) { $write('BEGIN'); $owned = true; } + } elseif (strpos($adapter, 'sqlite') !== false) { + // SQLite BEGIN 不会隐式提交已有事务。PDO 可直接检测;原生 SQLite3 + // 没有跨版本可用的状态 API,只接受明确的“已有事务”错误,其余错误上抛。 + if (!($handle instanceof \PDO) || !$handle->inTransaction()) { + try { @$write('BEGIN'); $owned = true; } + catch (\Throwable $e) { + if (strpos($e->getMessage(), 'cannot start a transaction within a transaction') === false) { throw $e; } + } + } + } else { + throw new \RuntimeException('当前数据库适配器不支持安全的字段同步事务'); + } + // 嵌套 RELEASE 不会提交外层 BEGIN/SAVEPOINT;只有 owned 才能删除实体文件。 + $started = false; + try { + $write('SAVEPOINT ' . $savepoint); + $started = true; + $operation($write); + $write('RELEASE SAVEPOINT ' . $savepoint); + if ($owned) { $write('COMMIT'); } + } catch (\Throwable $e) { + try { + if ($owned) { $write('ROLLBACK'); } + elseif ($started) { + $write('ROLLBACK TO SAVEPOINT ' . $savepoint); + $write('RELEASE SAVEPOINT ' . $savepoint); + } + } catch (\Throwable $rollbackError) { + // 保留最初的错误,同时明确记录连接丢失等导致无法确认回滚的情况。 + \TypechoPlugin\InfinityTime\Plugin::log('transaction rollback failed: ' . $rollbackError->getMessage()); + } + throw $e; + } + return $owned; + } +} diff --git a/usr/plugins/InfinityTime/Lib/HealthDiagnostics.php b/usr/plugins/InfinityTime/Lib/HealthDiagnostics.php new file mode 100644 index 0000000..0735633 --- /dev/null +++ b/usr/plugins/InfinityTime/Lib/HealthDiagnostics.php @@ -0,0 +1,46 @@ + $name, 'status' => $status, 'detail' => $detail]; + }; + $directory = is_dir($uploadRoot); + $add('上传目录', $directory && is_writable($uploadRoot) ? 'ok' : 'warning', + $directory && is_writable($uploadRoot) ? '目录存在,PHP 报告可写;未实际写入测试文件。' : '目录不存在或 PHP 报告不可写,请由管理员检查上传目录。'); + $free = $directory && function_exists('disk_free_space') ? @disk_free_space($uploadRoot) : false; + $add('可用磁盘', $free === false ? 'manual' : ($free < 104857600 ? 'warning' : 'ok'), + $free === false ? '无法读取空间,请在主机面板核对。' : '当前可用约 ' . number_format($free / 1048576, 0) . ' MiB;转换时仍需为原图、中间文件和备份预留空间。'); + $gd = function_exists('imagewebp') && function_exists('imagecreatefromjpeg'); + $add('基础图片编码', $gd ? 'ok' : 'warning', $gd ? 'GD JPEG/WebP 函数可用;这不是编码样本测试。' : 'GD JPEG/WebP 函数不完整,请检查 PHP 扩展。'); + $advanced = !empty($tools['imagick']) || !empty($tools['magick']) || !empty($tools['convert']) || !empty($tools['heif']); + $add('HEIC/AVIF 工具', $advanced ? 'manual' : 'warning', $advanced ? '发现候选工具;实际格式支持取决于编译选项,升级后请用样本试传。' : '未发现候选工具,HEIC/AVIF 输入可能无法转换。'); + $adapter = strtolower((string)$db->getAdapterName()); + if (strpos($adapter, 'mysql') !== false) { + try { + $all = true; + foreach (['contents', 'fields', 'infinitytime_images'] as $suffix) { + $query = $db->select(); + $sql = $query->prepare('SHOW TABLE STATUS WHERE Name = ' . $query->quoteValue($db->getPrefix() . $suffix)); + $row = $db->fetchRow(Database::query($sql)); + if (strcasecmp((string)($row['Engine'] ?? ''), 'InnoDB') !== 0) { $all = false; } + } + $add('数据库事务', $all ? 'ok' : 'warning', $all ? '相关三张表均为 InnoDB;未执行写入或回滚演练。' : '相关表缺失或不是 InnoDB,原子编辑会拒绝;请先备份,再由管理员处理。'); + } catch (\Throwable $e) { + $add('数据库事务', 'warning', '无法读取表引擎信息;请检查数据库连接和只读诊断权限。'); + } + } else { + $known = strpos($adapter, 'sqlite') !== false || strpos($adapter, 'pgsql') !== false || strpos($adapter, 'postgres') !== false; + $add('数据库事务', $known ? 'manual' : 'warning', $known ? '适配器支持事务;本页未执行写入、锁竞争或真实恢复测试。' : '未识别数据库适配器,请先在隔离副本验证兼容性。'); + } + $add('上传限制', 'manual', 'PHP upload_max_filesize=' . (string)ini_get('upload_max_filesize') . ',post_max_size=' . (string)ini_get('post_max_size') . ',memory_limit=' . (string)ini_get('memory_limit') . '。代理限制和图片解码内存另需核对。'); + $add('私有原图保护', 'manual', '本页不能证明 Nginx/Apache/CDN 已禁止原图访问。请使用专用无敏感测试图片,核对 original 目录 URL 不能公开访问;不要用真实私密照片测试。'); + $add('备份与恢复', 'manual', '同时保存一致的数据库、上传目录和受保护的站点配置;在隔离环境按下方步骤恢复验证,备份文件存在不等于恢复成功。'); + return $checks; + } +} diff --git a/usr/plugins/InfinityTime/Lib/ImageRepository.php b/usr/plugins/InfinityTime/Lib/ImageRepository.php index 8c30764..c127647 100644 --- a/usr/plugins/InfinityTime/Lib/ImageRepository.php +++ b/usr/plugins/InfinityTime/Lib/ImageRepository.php @@ -26,6 +26,32 @@ public static function table(): string return \Typecho\Db::get()->getPrefix() . 'infinitytime_images'; } + /** 请求级互斥锁覆盖“文件落盘 → 入库”,进程退出自动释放;不删除锁文件。 */ + private static $mediaLock = null; + public static function lockMedia(): void + { + if (is_resource(self::$mediaLock)) { + return; + } + self::ensureDir(self::uploadRoot()); + $handle = @fopen(self::uploadRoot() . '/.infinitytime-media.lock', 'c'); + if (!$handle || !flock($handle, LOCK_EX | LOCK_NB)) { + if (is_resource($handle)) { fclose($handle); } + throw new \RuntimeException('图片上传或维护正在进行,请稍后重试'); + } + self::$mediaLock = $handle; + register_shutdown_function([self::class, 'unlockMedia']); + } + + public static function unlockMedia(): void + { + if (is_resource(self::$mediaLock)) { + flock(self::$mediaLock, LOCK_UN); + fclose(self::$mediaLock); + } + self::$mediaLock = null; + } + /** 是否 360 全景:等距圆柱即宽高比约 2:1(1.98~2.02),排除 XPAN 等更宽画幅。 */ public static function isPano(int $w, int $h): bool { @@ -119,6 +145,7 @@ public static function ingest(array $file, array $opts = []): ?array } try { + self::lockMedia(); $hash = hash_file('sha256', $src); $y = date('Y'); @@ -213,9 +240,11 @@ public static function defaultDirs(): array */ public static function insertRow(int $cid, array $meta, int $sort = 0): int { + self::ensureSchema(); + self::lockMedia(); $db = \Typecho\Db::get(); $table = self::table(); - return (int)$db->query($db->insert($table)->rows([ + return (int)Database::query($db->insert($table)->rows([ 'cid' => $cid, 'original' => $meta['original'], 'full' => $meta['full'], @@ -228,6 +257,8 @@ public static function insertRow(int $cid, array $meta, int $sort = 0): int 'size' => $meta['size'], 'sort' => $sort, 'hash' => $meta['hash'] ?? '', + 'upload_key' => $meta['upload_key'] ?? null, + 'upload_fingerprint' => $meta['upload_fingerprint'] ?? null, 'exif' => json_encode($meta['exif'] ?? [], JSON_UNESCAPED_UNICODE), 'gps_lat' => $meta['gps_lat'] ?? null, 'gps_lng' => $meta['gps_lng'] ?? null, @@ -238,18 +269,59 @@ public static function insertRow(int $cid, array $meta, int $sort = 0): int ])); } + /** 持锁后的变更及引用检查必须读取主库,避免从库延迟造成重复或误删。 */ + private static function fetchRows($db, $query): array + { + if (is_resource(self::$mediaLock)) { + // Typecho 会按 Query 类型重新选择连接池;先编译绑定参数,再显式选择主库。 + $query = Database::query($query->prepare((string)$query)); + } + return $db->fetchAll($query); + } + /** 从插件表取某 cid 的图片列表。 */ public static function rowsFor(int $cid): array { self::ensureSchema(); $db = \Typecho\Db::get(); - $rows = $db->fetchAll($db->select()->from(self::table())->where('cid = ?', $cid)->order('sort', \Typecho\Db::SORT_ASC)->order('id', \Typecho\Db::SORT_ASC)); + $query = $db->select()->from(self::table())->where('cid = ?', $cid)->order('sort', \Typecho\Db::SORT_ASC)->order('id', \Typecho\Db::SORT_ASC); + $rows = self::fetchRows($db, $query); return array_map(function ($r) { $r['exif'] = json_decode($r['exif'] ?? '{}', true); return $r; }, $rows); } + /** 后台详情默认分页;仅用户明确要求全部排序时读完整列表。 */ + public static function pageFor(int $cid, int $page = 1, string $missing = '', bool $all = false): array + { + self::ensureSchema(); + $db = \Typecho\Db::get(); + if (!in_array($missing, ['', 'title', 'desc', 'address'], true)) { $missing = ''; } + $filter = static function ($query) use ($cid, $missing, $db) { + $query->where('cid = ?', $cid); + if ($missing !== '') { + $quote = stripos($db->getAdapterName(), 'mysql') !== false ? '`' : '"'; + $query->where('TRIM(COALESCE(' . $quote . $missing . $quote . ", '')) = ?", ''); + } + return $query; + }; + $counts = self::fetchRows($db, $filter($db->select('COUNT(*) AS total')->from(self::table()))); + $total = (int)($counts[0]['total'] ?? 0); + $size = 40; + $pages = max(1, (int)ceil($total / $size)); + $page = max(1, min($page, $pages)); + $all = $all && $missing === ''; + $query = $filter($db->select('id', 'cid', 'sort', 'full', 'thumb', 'width', 'height', 'exif', 'title', 'desc', 'address', 'created')->from(self::table())) + ->order('sort', \Typecho\Db::SORT_ASC)->order('id', \Typecho\Db::SORT_ASC); + if (!$all) { $query->limit($size)->offset(($page - 1) * $size); } + $rows = self::fetchRows($db, $query); + foreach ($rows as &$row) { $exif = json_decode($row['exif'] ?? '{}', true); $row['exif'] = is_array($exif) ? $exif : []; } + unset($row); + return ['rows' => $rows, 'total' => $total, 'page' => $page, 'pages' => $pages, 'missing' => $missing, + 'all' => $all, 'sortable' => $missing === '' && ($all || $total <= $size)]; + } + /** 一次性取多个图集的图片,按 cid 分组,避免后台图集列表 N+1。 */ public static function rowsForCids(array $cids): array { @@ -261,7 +333,7 @@ public static function rowsForCids(array $cids): array } $db = \Typecho\Db::get(); $ph = implode(',', array_fill(0, count($cids), '?')); - $rows = $db->fetchAll( + $rows = self::fetchRows($db, $db->select()->from(self::table()) ->where('cid IN (' . $ph . ')', ...$cids) ->order('sort', \Typecho\Db::SORT_ASC) @@ -281,12 +353,13 @@ public static function ensureSchema(): void if (self::$schemaEnsured) { return; } - self::$schemaEnsured = true; // 按 schema 版本补齐缺失列(幂等;版本一致时 Plugin::migrateSchema 直接返回,不跑 DDL) try { Plugin::migrateSchema(); + self::$schemaEnsured = true; } catch (\Throwable $e) { Plugin::log('migrateSchema failed: ' . $e->getMessage()); + throw $e; } } @@ -294,30 +367,216 @@ public static function ensureSchema(): void public static function setImageMeta(int $rowId, string $title, string $desc, string $address): void { $db = \Typecho\Db::get(); - $db->query($db->update(self::table())->rows([ + Database::query($db->update(self::table())->rows([ 'title' => $title, 'desc' => $desc, 'address' => $address, ])->where('id = ?', $rowId)); } - /** 重建后更新某张图的响应式变体路径(mid / avif / mid_avif)。 */ + /** 图片元数据与公开聚合字段一并保存,失败保持两者的旧版本。 */ + public static function editImage(int $rowId, int $cid, string $title, string $desc, string $address): bool + { + $changed = false; + self::fieldTransaction(static function (callable $write) use ($rowId, $cid, $title, $desc, $address, &$changed): void { + $db = \Typecho\Db::get(); + $row = $db->fetchRow($write($db->select('id')->from(self::table())->where('id = ?', $rowId)->where('cid = ?', $cid)->limit(1))); + if (!$row) { return; } + $write($db->update(self::table())->rows(['title' => $title, 'desc' => $desc, 'address' => $address]) + ->where('id = ?', $rowId)->where('cid = ?', $cid)); + self::writePostFields($cid, false, $write); + $changed = true; + }, true); + return $changed; + } + + /** 批量元数据与聚合字段一起提交;任何越界/缺失照片均拒绝整批。 */ + public static function editImages(int $cid, array $ids, array $changes): int + { + if (!$ids || count($ids) > 100 || count(array_unique($ids)) !== count($ids)) { throw new \DomainException('请勾选1至100张不同照片'); } + foreach ($ids as $id) { if (!is_int($id) || $id <= 0) { throw new \DomainException('图片标识无效'); } } + if (!$changes || array_diff(array_keys($changes), ['desc', 'address'])) { throw new \DomainException('请选择需要统一修改的字段'); } + foreach ($changes as $value) { if (!is_string($value) || strlen($value) > 8000) { throw new \DomainException('字段内容过长或无效'); } } + self::fieldTransaction(static function (callable $write) use ($cid, $ids, $changes): void { + $db = \Typecho\Db::get(); + $marks = implode(',', array_fill(0, count($ids), '?')); + $rows = $db->fetchAll($write($db->select('id')->from(self::table())->where('cid = ?', $cid)->where('id IN (' . $marks . ')', ...$ids))); + if (count($rows) !== count($ids)) { throw new \DomainException('照片已变化或不属于当前相册,请刷新后重试'); } + $write($db->update(self::table())->rows($changes)->where('cid = ?', $cid)->where('id IN (' . $marks . ')', ...$ids)); + self::writePostFields($cid, false, $write); + }, true); + return count($ids); + } + + /** 校验完整当前清单后原子排序;中途失败不会留下半套顺序。 */ + public static function sortImages(int $cid, array $rowIds): void + { + self::fieldTransaction(static function (callable $write) use ($cid, $rowIds): void { + $db = \Typecho\Db::get(); + foreach ($rowIds as $id) { + if (!is_int($id) || $id <= 0) { throw new \DomainException('图片列表包含无效标识,请刷新后重试'); } + } + $rows = $db->fetchAll($write($db->select('id')->from(self::table())->where('cid = ?', $cid))); + $existing = array_map(static function ($row) { return (int)$row['id']; }, $rows); + $requested = $rowIds; sort($requested); sort($existing); + if ($requested !== $existing || count($rowIds) !== count(array_unique($rowIds))) { + throw new \DomainException('图片列表已变化或包含其他图集图片,请刷新后重试'); + } + foreach ($rowIds as $order => $rowId) { + $write($db->update(self::table())->rows(['sort' => $order])->where('id = ?', $rowId)->where('cid = ?', $cid)); + } + self::writePostFields($cid, false, $write); + }, true); + } + + /** 只改变公开 URL,数据库文件路径保持不变,删除和重建仍定位原文件。 */ + public static function versionUrl(string $url, array $exif): string + { + $version = $exif['_infinity_media_version'] ?? ''; + if ($url === '' || !is_string($version) || !preg_match('/^[a-f0-9]{32}$/D', $version)) { return $url; } + $fragment = explode('#', $url, 2); + $parts = explode('?', $fragment[0], 2); + $query = array_filter(explode('&', $parts[1] ?? ''), static function ($part) { + return $part !== '' && rawurldecode(explode('=', $part, 2)[0]) !== 'itv'; + }); + $query[] = 'itv=' . $version; + return $parts[0] . '?' . implode('&', $query) . (isset($fragment[1]) ? '#' . $fragment[1] : ''); + } + + /** 重建后同步实际输出尺寸、大小与响应式变体路径。 */ public static function updateVariants(int $rowId, array $result): void { + self::lockMedia(); $db = \Typecho\Db::get(); - $db->query($db->update(self::table())->rows([ + $rows = self::fetchRows($db, $db->select('exif')->from(self::table())->where('id = ?', $rowId)->limit(1)); + $row = $rows[0] ?? null; + if (!$row) { throw new \RuntimeException('图片记录已变化,请刷新后重试'); } + $exif = is_array($row['exif'] ?? null) ? $row['exif'] : (json_decode((string)($row['exif'] ?? '{}'), true) ?: []); + if (!is_array($exif)) { $exif = []; } + $exif['_infinity_media_version'] = bin2hex(random_bytes(16)); + $values = [ + 'exif' => json_encode($exif, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR), 'mid' => !empty($result['mid']) ? self::toWeb((string)$result['mid']) : null, 'avif' => !empty($result['avif']) ? self::toWeb((string)$result['avif']) : null, 'mid_avif' => !empty($result['mid_avif']) ? self::toWeb((string)$result['mid_avif']) : null, - ])->where('id = ?', $rowId)); + ]; + foreach (['width', 'height', 'size'] as $field) { + if (isset($result[$field])) { $values[$field] = (int)$result[$field]; } + } + Database::query($db->update(self::table())->rows($values)->where('id = ?', $rowId)); } - /** 按图片行的顺序同步路径与元数据,避免排序/删除后文章字段错位。 */ + /** 主库上的原子字段替换;保存点不会提交或回滚调用方的其他写入。 */ public static function syncPostFields(int $cid, bool $clearEmpty = false): void + { + self::fieldTransaction(static function (callable $write) use ($cid, $clearEmpty): void { + self::writePostFields($cid, $clearEmpty, $write); + }); + } + + /** 共用事务协议;返回 true 仅表示本方法已提交自己开启的事务。 */ + private static function fieldTransaction(callable $operation, bool $mutatesImages = false, bool $mutatesContents = false): bool + { + self::lockMedia(); + $db = \Typecho\Db::get(); + $tables = [$db->getPrefix() . 'fields']; + if ($mutatesImages) { $tables[] = self::table(); } + if ($mutatesContents) { $tables[] = $db->getPrefix() . 'contents'; } + return Database::transaction($operation, $tables); + } + + /** + * 删除图片行并同步文章字段。调用方先验证 cid 的编辑权限。 + * DB 回滚时保留实体文件;外层事务存在时不清理文件(留待提交后的孤儿清理)。 + * DB 与文件系统无法跨系统原子提交:提交后清理失败只留下可安全清理的孤儿。 + */ + public static function deleteImage(int $rowId, int $cid): bool + { + self::lockMedia(); + $deleted = false; + $candidates = []; + $committed = self::fieldTransaction(static function (callable $write) use ($rowId, $cid, &$deleted, &$candidates): void { + $db = \Typecho\Db::get(); + $row = $db->fetchRow($write($db->select()->from(self::table()) + ->where('id = ?', $rowId)->where('cid = ?', $cid)->limit(1))); + if (!$row) { return; } + $candidates = self::deletionCandidates($row); + $write($db->delete(self::table())->where('id = ?', $rowId)->where('cid = ?', $cid)); + self::writePostFields($cid, true, $write); + $deleted = true; + }, true); + if ($deleted && $committed) { self::cleanupDeletedFiles($candidates); } + return $deleted; + } + + /** 删除图集的现有三表数据;不扩大到评论、分类关系或其他 cid。调用方先验证编辑权限。 */ + public static function deleteAlbum(int $cid): bool + { + if ($cid <= 0) { return false; } + $deleted = false; + $candidates = []; + $committed = self::fieldTransaction(static function (callable $write) use ($cid, &$deleted, &$candidates): void { + $db = \Typecho\Db::get(); + $prefix = $db->getPrefix(); + $content = $db->fetchRow($write($db->select('cid')->from($prefix . 'contents')->where('cid = ?', $cid)->limit(1))); + if (!$content) { return; } + // 单次扫描本图集路径,避免逐图同步及反复扫描全站引用的 O(N²) 开销。 + $rows = $db->fetchAll($write($db->select('original', 'full', 'thumb', 'mid', 'avif', 'mid_avif') + ->from(self::table())->where('cid = ?', $cid))); + foreach ($rows as $row) { $candidates += self::deletionCandidates($row); } + $write($db->delete(self::table())->where('cid = ?', $cid)); + $write($db->delete($prefix . 'fields')->where('cid = ?', $cid)); + $write($db->delete($prefix . 'contents')->where('cid = ?', $cid)); + $deleted = true; + }, true, true); + // 不提交调用方事务;与单图删除相同,未自提交时保留文件供后续安全清理。 + if ($deleted && $committed) { self::cleanupDeletedFiles($candidates); } + return $deleted; + } + + private static function deletionCandidates(array $row): array + { + $candidates = []; + foreach (['original', 'full', 'thumb', 'mid', 'avif', 'mid_avif'] as $field) { + $path = $row[$field] ?? null; + // 污染路径不能经 toAbs 的容错归一化变成另一个有效文件。 + if (!is_string($path) || $path === '' || $path[0] !== '/' + || strpos($path, "\\") !== false || strpos($path, "\0") !== false + || preg_match('#(?:^|/)\.\.?(?:/|$)#', $path)) { continue; } + $candidate = self::orphanFingerprint(self::toAbs($path), false); + if ($candidate !== null) { $candidates[$candidate['path']] = $candidate; } + } + return $candidates; + } + + /** 仅在确认提交后调用;文件系统失败只留下孤儿,不改变已提交的成功结果。 */ + private static function cleanupDeletedFiles(array $candidates): void + { + if (!$candidates) { return; } + // 锁持续覆盖提交后的引用重查;任何异常都不能把已提交的删除伪报为失败。 + try { + $referenced = self::referencedPaths(); + foreach ($candidates as $path => $candidate) { + if (!isset($referenced[$path]) && self::orphanFingerprint($path, false) === $candidate) { + if (!@unlink($path)) { Plugin::log('image delete left an orphan file for later cleanup'); } + } + } + } catch (\Throwable $e) { + Plugin::log('image delete cleanup deferred: ' . $e->getMessage()); + } + } + + /** 按图片行的顺序同步路径与元数据,避免排序/删除后文章字段错位。 */ + private static function writePostFields(int $cid, bool $clearEmpty, callable $write): void { $db = \Typecho\Db::get(); $prefix = $db->getPrefix(); - $rows = self::rowsFor($cid); + // 不在调用方可能已开启的事务内运行 schema 迁移(MySQL DDL 会隐式提交)。 + $query = $db->select()->from(self::table())->where('cid = ?', $cid) + ->order('sort', \Typecho\Db::SORT_ASC)->order('id', \Typecho\Db::SORT_ASC); + $rows = $db->fetchAll($write($query)); + foreach ($rows as &$row) { $parsed = json_decode($row['exif'] ?? '{}', true); $row['exif'] = is_array($parsed) ? $parsed : []; } + unset($row); // 历史文章可能仅有自定义字段。无图片行时不覆盖,删除最后一张图则显式清空。 if (!$rows && !$clearEmpty) { return; @@ -329,11 +588,15 @@ public static function syncPostFields(int $cid, bool $clearEmpty = false): void $dims = []; $variants = []; $exifs = []; + $photoIds = []; + $months = []; $images = []; $thumbs = []; foreach ($rows as $r) { - $images[] = (string)($r['full'] ?? ''); - $thumbs[] = (string)($r['thumb'] ?? ''); + $photoIds[] = (int)($r['id'] ?? 0); + $months[] = self::photoMonth(is_array($r['exif'] ?? null) ? $r['exif'] : [], (int)($r['created'] ?? 0)); + $images[] = self::versionUrl((string)($r['full'] ?? ''), is_array($r['exif'] ?? null) ? $r['exif'] : []); + $thumbs[] = self::versionUrl((string)($r['thumb'] ?? ''), is_array($r['exif'] ?? null) ? $r['exif'] : []); $addresses[] = (string)($r['address'] ?? ''); $titles[] = (string)($r['title'] ?? ''); $descs[] = (string)($r['desc'] ?? ''); @@ -347,24 +610,24 @@ public static function syncPostFields(int $cid, bool $clearEmpty = false): void // 响应式变体:webp=[full, mid],avif=[full, mid](缺失的自动过滤) $variants[] = [ 'w' => $w, - 'webp' => array_values(array_filter([(string)($r['full'] ?? ''), (string)($r['mid'] ?? '')])), - 'avif' => array_values(array_filter([(string)($r['avif'] ?? ''), (string)($r['mid_avif'] ?? '')])), + 'webp' => array_values(array_filter(array_map(static function ($url) use ($r) { return self::versionUrl($url, $r['exif']); }, [(string)($r['full'] ?? ''), (string)($r['mid'] ?? '')]))), + 'avif' => array_values(array_filter(array_map(static function ($url) use ($r) { return self::versionUrl($url, $r['exif']); }, [(string)($r['avif'] ?? ''), (string)($r['mid_avif'] ?? '')]))), ]; // 重建文章里的 exif 字段时同样剔除 gps(历史数据也据此清掉) $e = is_array($r['exif'] ?? null) ? $r['exif'] : []; - unset($e['gps']); + unset($e['gps'], $e['_infinity_media_version']); $exifs[] = $e; } foreach (['img' => $images, 'thumb' => $thumbs] as $name => $paths) { - $db->query($db->delete($prefix . 'fields')->where('cid = ?', $cid)->where('name = ?', $name)); + $write($db->delete($prefix . 'fields')->where('cid = ?', $cid)->where('name = ?', $name)); // 保留空 img 标记,使删除最后一张图片后的空图集仍可识别。 - $db->query($db->insert($prefix . 'fields')->rows([ + $write($db->insert($prefix . 'fields')->rows([ 'cid' => $cid, 'name' => $name, 'type' => 'str', 'str_value' => implode("\n", $paths), ])); } - $map = ['addresses' => $addresses, 'titles' => $titles, 'descs' => $descs, 'panos' => $panos, 'dims' => $dims, 'variants' => $variants, 'exif' => $exifs]; - foreach (['addresses', 'titles', 'descs', 'panos', 'dims', 'variants', 'exif'] as $f) { - $db->query($db->delete($prefix . 'fields')->where('cid = ?', $cid)->where('name = ?', $f)); + $map = ['addresses' => $addresses, 'titles' => $titles, 'descs' => $descs, 'panos' => $panos, 'dims' => $dims, 'variants' => $variants, 'exif' => $exifs, 'photo_ids' => $photoIds, 'months' => $months]; + foreach (['addresses', 'titles', 'descs', 'panos', 'dims', 'variants', 'exif', 'photo_ids', 'months'] as $f) { + $write($db->delete($prefix . 'fields')->where('cid = ?', $cid)->where('name = ?', $f)); $val = $map[$f]; // 注意:variants 的元素是数组,不能直接用 array_filter($val, 'strlen')(PHP 8 会对数组调 strlen 报错) $nonEmpty = false; @@ -375,14 +638,25 @@ public static function syncPostFields(int $cid, bool $clearEmpty = false): void } } if ($nonEmpty) { - $db->query($db->insert($prefix . 'fields')->rows([ + $write($db->insert($prefix . 'fields')->rows([ 'cid' => $cid, 'name' => $f, 'type' => 'str', - 'str_value' => json_encode($val, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'str_value' => json_encode($val, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), ])); } } } + /** EXIF 日期按相机记录的日历日期解释;缺失时按入库时间的 UTC 月份回退。 */ + public static function photoMonth(array $exif, int $created): string + { + $date = (string)($exif['datetime'] ?? ''); + if (preg_match('/^(\d{4})[:-](\d{2})[:-](\d{2})(?:[ T]|$)/', $date, $parts) + && checkdate((int)$parts[2], (int)$parts[3], (int)$parts[1])) { + return $parts[1] . '-' . $parts[2]; + } + return $created > 0 ? gmdate('Y-m', $created) : ''; + } + /** 删除某 cid 的所有图片(含文件)。 */ public static function removeFor(int $cid): void { @@ -391,41 +665,124 @@ public static function removeFor(int $cid): void self::unlinkFiles($r['original'], $r['full'], $r['mid'] ?? null, $r['avif'] ?? null, $r['mid_avif'] ?? null, $r['thumb']); } $db = \Typecho\Db::get(); - $db->query($db->delete(self::table())->where('cid = ?', $cid)); + Database::query($db->delete(self::table())->where('cid = ?', $cid)); } - /** 清理:删除所有不再关联任何 cid 的孤儿文件与记录(可选保留空目录)。 */ - public static function cleanupOrphans(): array + /** 仅接受白名单真实目录内的普通文件;孤儿扫描额外跳过宽限期内的新文件。 */ + private static function orphanFingerprint(string $path, bool $oldOnly = true): ?array + { + clearstatcache(true, $path); + if (is_link($path) || !is_file($path)) { return null; } + $real = realpath($path); + if ($real === false || $real !== $path) { return null; } + $inside = false; + foreach (self::defaultDirs() as $webDir) { + $dir = self::toAbs($webDir); + if (is_link($dir) || realpath($dir) !== $dir) { continue; } + if (strpos($real, $dir . '/') === 0) { $inside = true; break; } + } + if (!$inside) { return null; } + $stat = @stat($path); + // 保守宽限期:给升级前的上传进程及失败后的人工恢复留出时间。 + if (!$stat || ($oldOnly && $stat['mtime'] > time() - 3600)) { return null; } + return ['path' => $path, 'dev' => $stat['dev'], 'ino' => $stat['ino'], + 'size' => $stat['size'], 'mtime' => $stat['mtime'], 'ctime' => $stat['ctime']]; + } + + private static function referencedPaths(): array { $db = \Typecho\Db::get(); - $rows = $db->fetchAll($db->select()->from(self::table())); - // 收集所有仍被引用的绝对路径 $referenced = []; - foreach ($rows as $r) { - foreach (['original', 'full', 'thumb', 'mid', 'avif', 'mid_avif'] as $f) { - if (!empty($r[$f])) { - $referenced[self::toAbs($r[$f])] = true; + $add = static function ($value) use (&$referenced): void { + if (!is_string($value) || trim($value) === '') { return; } + $value = trim($value); + if (preg_match('#^(?:https?:)?//#i', $value)) { $value = (string)parse_url($value, PHP_URL_PATH); } + if ($value === '') { return; } + // 历史字段可含缓存参数、片段和 URL 编码;浏览器仍在读取同一个实体文件。 + $urlPath = preg_split('/[?#]/', $value, 2)[0]; + foreach (array_unique([$value, $urlPath, rawurldecode($urlPath)]) as $candidate) { + if (strpos($candidate, "\0") !== false) { continue; } + // 浏览器会折叠 URL 点目录,即使被折叠的目录实际不存在也一样。 + // toAbs 的防穿越容错只删除 .. 段,不能用于判断这种历史 URL 引用。 + $segments = []; + foreach (explode('/', str_replace("\\", '/', $candidate)) as $segment) { + if ($segment === '' || $segment === '.') { continue; } + if ($segment === '..') { array_pop($segments); } + else { $segments[] = $segment; } + } + // 额外保留旧解释,仅扩大保护集合,不放宽删除候选的路径白名单。 + foreach (array_unique([$candidate, '/' . implode('/', $segments)]) as $protected) { + $path = self::toAbs($protected); + $referenced[$path] = true; + $real = realpath($path); + if ($real !== false) { $referenced[$real] = true; } } } - } - $removed = 0; - foreach (self::defaultDirs() as $type => $webDir) { - $absDir = self::toAbs($webDir); - if (!is_dir($absDir)) { - continue; + }; + // 只读引用路径;扫描孤儿文件不需要搬运可能很大的 EXIF/标题等元数据。 + foreach (self::fetchRows($db, $db->select('original', 'full', 'thumb', 'mid', 'avif', 'mid_avif')->from(self::table())) as $row) { + foreach (['original', 'full', 'thumb', 'mid', 'avif', 'mid_avif'] as $field) { + $add($row[$field] ?? null); } - $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($absDir, \FilesystemIterator::SKIP_DOTS)); - foreach ($it as $file) { - if ($file->isFile() && !isset($referenced[$file->getPathname()])) { - if (@unlink($file->getPathname())) { - $removed++; + } + // 历史图集可能只有文章字段,没有仓库行,也必须保留其公开图片。 + $fields = self::fetchRows($db, $db->select('name', 'str_value')->from($db->getPrefix() . 'fields') + ->where('name IN (?, ?, ?)', 'img', 'thumb', 'variants')); + foreach ($fields as $field) { + if (($field['name'] ?? '') === 'variants') { + $variants = json_decode((string)($field['str_value'] ?? ''), true); + if (!is_array($variants)) { continue; } + foreach ($variants as $variant) { + if (!is_array($variant)) { continue; } + foreach (['webp', 'avif'] as $format) { + if (!is_array($variant[$format] ?? null)) { continue; } + foreach ($variant[$format] as $path) { $add($path); } } } + } else { + foreach (preg_split('/\\r?\\n/', (string)($field['str_value'] ?? '')) as $path) { $add($path); } } - // 清理空目录 - self::pruneEmpty($absDir); } - return ['removed_files' => $removed]; + return $referenced; + } + + /** 快照仅是候选,删除前必须在同一锁内重新校验引用和文件身份。 */ + public static function orphanCandidates(): array + { + self::lockMedia(); + $referenced = self::referencedPaths(); + $list = []; + foreach (self::defaultDirs() as $webDir) { + $dir = self::toAbs($webDir); + if (!is_dir($dir) || is_link($dir)) { continue; } + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)); + foreach ($it as $file) { + $path = $file->getPathname(); + $candidate = self::orphanFingerprint($path); + if ($candidate !== null && !isset($referenced[$path])) { $list[] = $candidate; } + } + } + return $list; + } + + public static function removeOrphanCandidates(array $candidates): int + { + self::lockMedia(); + $referenced = self::referencedPaths(); + $removed = 0; + foreach ($candidates as $candidate) { + // 升级前缓存的纯路径列表也不得直接用于删除。 + if (!is_array($candidate) || !isset($candidate['path'])) { continue; } + $path = (string)$candidate['path']; + if (isset($referenced[$path]) || self::orphanFingerprint($path) !== $candidate) { continue; } + if (@unlink($path)) { $removed++; } + } + return $removed; + } + + public static function cleanupOrphans(): array + { + return ['removed_files' => self::removeOrphanCandidates(self::orphanCandidates())]; } /** @@ -436,6 +793,7 @@ public static function cleanupOrphans(): array */ public static function rebuild(array $opts = []): array { + self::lockMedia(); $opts = array_merge([ 'quality' => (int)Plugin::opt('infinitytimeQuality', Plugin::DEFAULT_QUALITY), 'thumb_max' => (int)Plugin::opt('infinitytimeThumbMax', Plugin::DEFAULT_THUMB_MAX), @@ -446,7 +804,7 @@ public static function rebuild(array $opts = []): array ], $opts); $db = \Typecho\Db::get(); - $rows = $db->fetchAll($db->select()->from(self::table())); + $rows = self::fetchRows($db, $db->select()->from(self::table())); $rebuilt = 0; $failed = 0; $syncedCids = []; @@ -537,3 +895,4 @@ private static function pruneEmpty(string $dir): void } } } + diff --git a/usr/plugins/InfinityTime/Lib/MaintenanceState.php b/usr/plugins/InfinityTime/Lib/MaintenanceState.php new file mode 100644 index 0000000..3ea8910 --- /dev/null +++ b/usr/plugins/InfinityTime/Lib/MaintenanceState.php @@ -0,0 +1,165 @@ + $failure['task'], 'id' => (int)$failure['id'], 'cid' => (int)($failure['cid'] ?? 0)]; + } + if (!$list) { throw new MaintenanceException('没有可单独重试的失败项'); } + $unlisted = max(0, (int)($state['failed'] ?? 0) - count($state['failures'] ?? [])); + $state = self::newState($job, count($list), $id); + $state['unlisted_failed'] = $unlisted; + $state['failed'] = $unlisted; + self::write(self::listFile($dir, $state), $list); + self::write($file, $state); + return [$state, $list]; + } + // 任务身份比短期租约更持久,不能以另一个种类覆盖未完成快照。 + if ($state && !$same && empty($state['finished'])) { + throw new MaintenanceException('另一个维护任务尚未完成,请先继续该任务'); + } + if ($same && (empty($state['finished']) || $mode !== 'start')) { + if (!empty($state['finished'])) { return [$state, []]; } + $oldId = (string)($state['job_id'] ?? ''); + if ($oldId !== '') { self::listFile($dir, $state); } + $listFile = $dir . '/' . $job . ($oldId !== '' ? '_' . $oldId : '') . '_list.json'; + if (!is_file($listFile)) { throw new MaintenanceException('维护清单缺失,不能安全继续,请恢复任务数据'); } + $list = self::read($listFile); + // 升级前的未完成任务沿用旧清单与偏移,再赋予身份以拒绝迟到请求。 + if ($oldId === '') { + $state['job_id'] = bin2hex(random_bytes(16)); + self::write(self::listFile($dir, $state), $list); + self::write($file, $state); + } + return [$state, $list]; + } + if ($mode !== 'start') { throw new MaintenanceException('没有可继续的维护任务'); } + $list = array_values($collect()); + $state = self::newState($job, count($list)); + self::write(self::listFile($dir, $state), $list); + self::write($file, $state); + return [$state, $list]; + } + + private static function newState(string $job, int $total, string $retryOf = ''): array + { + return ['job' => $job, 'job_id' => bin2hex(random_bytes(16)), 'retry_of' => $retryOf, 'total' => $total, + 'done' => 0, 'current' => '', 'failed' => 0, 'failures' => [], 'finished' => false]; + } + + /** 原因来自固定枚举;不将编码器、数据库异常或服务器路径送到客户端。 */ + public static function failure(array &$state, string $task, int $id, int $cid, string $code): void + { + $reasons = ['missing_original' => '原图缺失,恢复原图后重试', + 'conversion' => '图片编码失败,请检查原图与转换工具', + 'image_record' => '图片记录保存失败,请检查数据库后重试', + 'field_sync' => '图集字段同步失败;重试时只同步字段']; + if (!in_array($task, ['rebuild', 'resync'], true) || $id <= 0 || !isset($reasons[$code])) { + throw new MaintenanceException('无效的维护失败记录'); + } + self::prepareFailures($state); + $state['failures'][$task . ':' . $id] = ['task' => $task, 'id' => $id, 'cid' => $cid, 'code' => $code, 'reason' => $reasons[$code]]; + $state['failed'] = count($state['failures']) + $state['unlisted_failed']; + } + + public static function clearFailure(array &$state, string $task, int $id): void + { + self::prepareFailures($state); + unset($state['failures'][$task . ':' . $id]); + $state['failed'] = count($state['failures']) + $state['unlisted_failed']; + } + + private static function prepareFailures(array &$state): void + { + if (!isset($state['failures'])) { $state['failures'] = []; } + if (!isset($state['unlisted_failed'])) { $state['unlisted_failed'] = max(0, (int)($state['failed'] ?? 0) - count($state['failures'])); } + } + + /** 每个响应最多 50 条,所有记录均可用同一个任务身份翻页查看。 */ + public static function response(array $state, int $offset = 0): array + { + $failures = array_values($state['failures'] ?? []); + $total = count($failures); + $offset = max(0, min($offset, max(0, $total - 1))); + $offset = intdiv($offset, self::FAILURE_PAGE_SIZE) * self::FAILURE_PAGE_SIZE; + $out = array_intersect_key($state, array_flip(['job', 'job_id', 'retry_of', 'total', 'done', 'current', 'failed', 'finished'])); + $out['failures'] = array_slice($failures, $offset, self::FAILURE_PAGE_SIZE); + $out['failure_total'] = $total; + $out['failure_offset'] = $offset; + $out['failure_next'] = $offset + self::FAILURE_PAGE_SIZE < $total ? $offset + self::FAILURE_PAGE_SIZE : null; + $out['unlisted_failed'] = max(0, (int)($state['failed'] ?? 0) - $total); + return $out; + } + + public static function listFile(string $dir, array $state): string + { + $job = (string)($state['job'] ?? ''); + $id = (string)($state['job_id'] ?? ''); + if (!in_array($job, ['rebuild', 'cleanup', 'resync'], true) || !preg_match('/^[a-f0-9]{32}$/D', $id)) { + throw new MaintenanceException('无效的维护任务状态'); + } + return $dir . '/' . $job . '_' . $id . '_list.json'; + } + + public static function save(string $dir, array $state): void + { + self::write($dir . '/job.json', $state); + // 失败项留在原子检查点中,完成后可重试;无需保留全库候选清单。 + if (!empty($state['finished'])) { @unlink(self::listFile($dir, $state)); } + } +} diff --git a/usr/plugins/InfinityTime/Lib/MediaProcessor.php b/usr/plugins/InfinityTime/Lib/MediaProcessor.php index 94ca2fc..dec19d2 100644 --- a/usr/plugins/InfinityTime/Lib/MediaProcessor.php +++ b/usr/plugins/InfinityTime/Lib/MediaProcessor.php @@ -139,8 +139,8 @@ public static function process( // 全图宽度上限:先把超宽图片等比缩小,再编码 WebP if ($maxWidth > 0 && $width > $maxWidth) { $scale = $maxWidth / $width; - $nw = (int)round($width * $scale); - $nh = (int)round($height * $scale); + $nw = max(1, (int)round($width * $scale)); + $nh = max(1, (int)round($height * $scale)); $scaled = imagescale($img, $nw, $nh, IMG_BILINEAR_FIXED); if ($scaled) { $img = $scaled; @@ -152,9 +152,9 @@ public static function process( self::ensureDir(dirname($fullPath)); self::ensureDir(dirname($thumbPath)); - if (!imagewebp($img, $fullPath, $fullQuality)) { - throw new \RuntimeException('全图 WebP 写入失败(最常见是目录不可写): ' . $fullPath); - } + $staged = []; + try { + $staged[$fullPath] = self::stageWebp($img, $fullPath, $fullQuality); // 缩略图 if ($thumbMax > 0) { @@ -180,7 +180,7 @@ public static function process( // 裁好后缩放到缩略图目标最长边 $scale = min(1.0, $thumbMax / max($cropW, $cropH)); if ($scale < 1.0) { - $resized = imagescale($thumb, (int)round($cropW * $scale), (int)round($cropH * $scale), IMG_BILINEAR_FIXED); + $resized = imagescale($thumb, max(1, (int)round($cropW * $scale)), max(1, (int)round($cropH * $scale)), IMG_BILINEAR_FIXED); if ($resized) { $thumb = $resized; } @@ -188,8 +188,8 @@ public static function process( } } else { $scale = min(1.0, $thumbMax / max($width, $height)); - $tw = (int)round($width * $scale); - $th = (int)round($height * $scale); + $tw = max(1, (int)round($width * $scale)); + $th = max(1, (int)round($height * $scale)); if ($scale < 1.0) { $thumb = imagescale($img, $tw, $th, IMG_BILINEAR_FIXED); if (!$thumb) { @@ -200,13 +200,20 @@ public static function process( $thumb = $img; } } - if (!imagewebp($thumb, $thumbPath, max(60, $quality - 7))) { - throw new \RuntimeException('缩略图 WebP 写入失败(最常见是目录不可写): ' . $thumbPath); - } + $staged[$thumbPath] = self::stageWebp($thumb, $thumbPath, max(60, $quality - 7)); + } + + // 两份主输出全部编码/校验成功后才发布,缩略图编码失败不会提前覆盖旧全图。 + // 多文件 rename 不能跨文件系统原子提交;异常时已发布文件仍各自完整可读。 + foreach ($staged as $destination => $tmp) { + if (!@rename($tmp, $destination)) { throw new \RuntimeException('WebP 文件替换失败,请检查目录权限'); } + } + } finally { + foreach ($staged as $tmp) { if (is_file($tmp)) { @unlink($tmp); } } } // 先释放 GD 位图,再用 Imagick 生成变体:避免 8K 图「GD 全图 + Imagick 全图」内存叠加 - unset($img, $thumb); + unset($img, $thumb, $scaled, $resized); $variants = self::generateVariants($fullPath, $fullQuality); return [ @@ -221,6 +228,30 @@ public static function process( ]; } + /** 编码器可能返回 true 却输出空文件;只生成并验证临时结果,调用方统一发布。 */ + private static function stageWebp($image, string $path, int $quality): string + { + $width = imagesx($image); $height = imagesy($image); + // libwebp 的编码器单边硬限制;不为绕过限制而偷偷降低用户全景分辨率。 + if ($width > 16383 || $height > 16383) { + throw new \RuntimeException('WebP 单边不能超过 16383px,请明确调整输出尺寸后重试'); + } + $tmp = $path . '.tmp.' . bin2hex(random_bytes(8)); + try { + if (!imagewebp($image, $tmp, $quality)) { throw new \RuntimeException('WebP 编码或写入失败'); } + clearstatcache(true, $tmp); + $info = @getimagesize($tmp); + if (!is_file($tmp) || filesize($tmp) < 12 || !is_array($info) + || (int)$info[0] !== $width || (int)$info[1] !== $height || ($info['mime'] ?? '') !== 'image/webp') { + throw new \RuntimeException('WebP 输出不完整,旧图片已保留,请检查编码器和磁盘空间'); + } + return $tmp; + } catch (\Throwable $e) { + if (is_file($tmp)) { @unlink($tmp); } + throw $e; + } + } + /** * 生成响应式变体(Imagick 优先): * base@1600.webp 中间尺寸 WebP(手机/平板用) @@ -362,39 +393,25 @@ private static function decodeHeic(string $srcPath) } } - $jpeg = tempnam(sys_get_temp_dir(), 'pp_heic_'); - $done = false; - - if ($tools['magick']) { - $code = self::runCmd(escapeshellarg($tools['magick']) . ' ' . escapeshellarg($srcPath) . '[0] -colorspace sRGB -quality 92 ' . escapeshellarg($jpeg), 30); - $done = $code === 0 && is_file($jpeg) && filesize($jpeg) > 0; - } - if (!$done && $tools['convert']) { - $code = self::runCmd(escapeshellarg($tools['convert']) . ' ' . escapeshellarg($srcPath) . '[0] -colorspace sRGB -quality 92 ' . escapeshellarg($jpeg), 30); - $done = $code === 0 && is_file($jpeg) && filesize($jpeg) > 0; - } - if (!$done && $tools['heif']) { - $bin = is_string($tools['heif']) ? $tools['heif'] : 'heif-convert'; - // heif-convert 要求输出名带 .jpg;多图时会在扩展名前加 -N(base.jpg -> base-1.jpg) - $code = self::runCmd(escapeshellarg($bin) . ' ' . escapeshellarg($srcPath) . ' ' . escapeshellarg($jpeg . '.jpg'), 30); - $found = self::findProducedJpeg($jpeg); - $done = $code === 0 && $found !== null; - if ($done) { - $jpeg = $found; + $directory = self::conversionDirectory(); + try { + $jpeg = null; + foreach (['magick', 'convert'] as $tool) { + if (!$tools[$tool]) { continue; } + $candidate = $directory . '/' . $tool . '.jpg'; + $code = self::runCmd([$tools[$tool], $srcPath . '[0]', '-colorspace', 'sRGB', '-quality', '92', $candidate], 30); + clearstatcache(true, $candidate); + if ($code === 0 && is_file($candidate) && filesize($candidate) > 0) { $jpeg = $candidate; break; } } - } - - if (!$done || !$jpeg || !is_file($jpeg) || filesize($jpeg) === 0) { - @unlink($jpeg); - throw new \RuntimeException( - 'HEIC 解码失败:Imagick 无法读取该文件,且没有可用的 heif-convert/ImageMagick(或服务器 PHP 禁用了 exec/shell_exec)。' - . ' 请改传 JPG/PNG/WebP;若必须用 HEIC,请在插件设置填入 heif-convert 路径并确保 PHP 未禁用 exec。' - ); - } - - $img = imagecreatefromjpeg($jpeg); - @unlink($jpeg); - return $img; + if ($jpeg === null && $tools['heif']) { + $bin = is_string($tools['heif']) ? $tools['heif'] : 'heif-convert'; + $base = $directory . '/heif'; + $code = self::runCmd([$bin, $srcPath, $base . '.jpg'], 30); + if ($code === 0) { $jpeg = self::findProducedJpeg($base); } + } + if ($jpeg === null) { throw new \RuntimeException('HEIC 解码失败,请检查转换工具支持,或改传 JPG/PNG/WebP'); } + return self::readConvertedJpeg($jpeg); + } finally { self::clearConversionDirectory($directory); } } /** @@ -442,78 +459,89 @@ private static function decodeAvif(string $srcPath) return imagecreatefromstring($data); } - $jpeg = tempnam(sys_get_temp_dir(), 'pp_avif_'); - if ($tools['magick']) { - $code = self::runCmd(escapeshellarg($tools['magick']) . ' ' . escapeshellarg($srcPath) . '[0] -quality 92 ' . escapeshellarg($jpeg), 30); - if ($code === 0 && is_file($jpeg) && filesize($jpeg) > 0) { - $img = imagecreatefromjpeg($jpeg); - @unlink($jpeg); - return $img; + $directory = self::conversionDirectory(); + try { + $jpeg = $directory . '/decoded.jpg'; + if ($tools['magick']) { + $code = self::runCmd([$tools['magick'], $srcPath . '[0]', '-quality', '92', $jpeg], 30); + clearstatcache(true, $jpeg); + if ($code === 0 && is_file($jpeg) && filesize($jpeg) > 0) { return self::readConvertedJpeg($jpeg); } } + throw new \RuntimeException('AVIF 需要 ImageMagick(安装 libavif)支持'); + } finally { self::clearConversionDirectory($directory); } + } + + /** 临时解码结果可能含原图信息;使用随机私有目录隔离多帧和失败残留。 */ + private static function conversionDirectory(): string + { + $directory = rtrim(sys_get_temp_dir(), '/\\') . '/infinitytime-codec-' . bin2hex(random_bytes(16)); + if (!@mkdir($directory, 0700)) { throw new \RuntimeException('无法创建转换临时目录,请检查磁盘空间与临时目录权限'); } + return $directory; + } + + private static function readConvertedJpeg(string $path) + { + if (!is_file($path) || is_link($path)) { throw new \RuntimeException('转换工具未输出有效的普通图片文件'); } + // HEIC 等格式可能无法在初始 getimagesize 阶段读取尺寸,解码成 JPEG 后再次限制像素。 + self::assertImageSize($path); + $image = @imagecreatefromjpeg($path); + if (!$image) { throw new \RuntimeException('转换工具输出不是有效的 JPEG 图片'); } + return $image; + } + + private static function clearConversionDirectory(string $directory): void + { + if (is_link($directory)) { @unlink($directory); return; } + if (!is_dir($directory)) { return; } + try { + foreach (new \FilesystemIterator($directory, \FilesystemIterator::SKIP_DOTS) as $file) { + if ($file->isFile() || $file->isLink()) { @unlink($file->getPathname()); } + } + if (!@rmdir($directory)) { \TypechoPlugin\InfinityTime\Plugin::log('conversion temporary directory cleanup incomplete'); } + } catch (\Throwable $e) { + // 清理失败不能掩盖原来的转换错误;私有目录留给管理员核查。 + \TypechoPlugin\InfinityTime\Plugin::log('conversion temporary directory cleanup failed'); } - @unlink($jpeg); - throw new \RuntimeException('AVIF 需要 ImageMagick(安装 libavif)支持'); } /** - * 以带超时的方式运行外部命令。 - * - * PHP 的 exec() 会无限期阻塞在外部子进程上(max_execution_time 并不覆盖系统调用等待), - * 一旦 magick/heif-convert 卡死会拖住整个 PHP 工作进程。这里改用 proc_open 并强制超时, - * 超时即 kill,避免后端“网页卡死”。 - * - * @param string $cmd 拼接好的命令行(参数已 escapeshellarg) - * @param int $timeout 超时秒数 - * @return int 退出码 - * @throws \RuntimeException 进程无法启动或超时 + * 直接启动编码器,不经过额外 shell;超时终止的是实际工具进程。 + * PHP 7.4+ 支持 argv 数组。输出原本即被丢弃,直接接系统空设备, + * 避免海量日志占内存及 Windows 管道无法可靠非阻塞造成的等待。 + * 仅管理直接子进程;工具自行创建的后代进程仍受工具/主机进程管理约束。 */ - private static function runCmd(string $cmd, int $timeout = 60): int + private static function runCmd(array $command, int $timeout = 60): int { - if (!function_exists('proc_open')) { - throw new \RuntimeException('proc_open 被禁用,无法执行外部命令'); + foreach (['proc_open', 'proc_get_status', 'proc_terminate', 'proc_close'] as $function) { + if (!function_exists($function)) { throw new \RuntimeException('外部命令管理函数被禁用,无法安全启动转换工具'); } } - $proc = @proc_open($cmd, [ - 0 => ['file', '/dev/null', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ], $pipes); - if (!is_resource($proc)) { - throw new \RuntimeException('无法启动外部命令: ' . $cmd); + if (!$command || $timeout < 1) { throw new \InvalidArgumentException('外部命令或超时参数无效'); } + foreach ($command as $argument) { + if (!is_string($argument) || strpos($argument, "\0") !== false) { throw new \InvalidArgumentException('外部命令参数无效'); } } - stream_set_blocking($pipes[1], false); - stream_set_blocking($pipes[2], false); - + if ($command[0] === '' || (PHP_OS_FAMILY === 'Windows' && preg_match('/\.(?:bat|cmd)$/i', $command[0]))) { + throw new \RuntimeException('请配置可直接执行的转换工具,不支持 Windows 批处理包装'); + } + $null = PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null'; + $proc = @proc_open($command, [0 => ['file', $null, 'r'], 1 => ['file', $null, 'w'], 2 => ['file', $null, 'w']], $pipes, null, null, ['bypass_shell' => true]); + if (!is_resource($proc)) { throw new \RuntimeException('无法启动转换工具:' . basename($command[0])); } $start = microtime(true); - $status = proc_get_status($proc); - while ($status['running']) { - // 尽量抽空子进程输出,避免管道填满导致子进程阻塞 - @stream_get_contents($pipes[1]); - @stream_get_contents($pipes[2]); - - if ((microtime(true) - $start) > $timeout) { - proc_terminate($proc, 9); - usleep(300000); - if (proc_get_status($proc)['running']) { + $exit = -1; + try { + do { + $status = proc_get_status($proc); + if (!$status['running']) { $exit = (int)($status['exitcode'] ?? -1); break; } + if (microtime(true) - $start >= $timeout) { proc_terminate($proc, 9); - usleep(100000); + throw new \RuntimeException('外部转换工具超时(' . $timeout . 's):' . basename($command[0])); } - @fclose($pipes[1]); - @fclose($pipes[2]); - proc_close($proc); - throw new \RuntimeException('外部命令超时(' . $timeout . 's): ' . $cmd); - } - usleep(50000); - $status = proc_get_status($proc); - } - - $exit = isset($status['exitcode']) ? (int)$status['exitcode'] : -1; - @fclose($pipes[1]); - @fclose($pipes[2]); - $closed = proc_close($proc); - if ($closed !== -1) { - $exit = $closed; + usleep(50000); + } while (true); + } finally { + if (proc_get_status($proc)['running']) { proc_terminate($proc, 9); } + $closed = proc_close($proc); } - return $exit; + return $closed !== -1 ? $closed : $exit; } /** 依据 EXIF Orientation(1-8)对位图做旋转/镜像修正(GD 不会自动处理)。 */ diff --git a/usr/plugins/InfinityTime/Plugin.php b/usr/plugins/InfinityTime/Plugin.php index ca396cc..980d043 100644 --- a/usr/plugins/InfinityTime/Plugin.php +++ b/usr/plugins/InfinityTime/Plugin.php @@ -4,7 +4,7 @@ * * @package InfinityTime * @author InfinityTime - * @version 1.13.4 + * @version 1.15.0 * @link https://github.com/infinitytime/infinitytime */ @@ -15,6 +15,7 @@ use Typecho\Widget\Helper\Form\Element\Number; use Typecho\Widget\Helper\Form\Element\Radio; use Typecho\Widget\Helper\Form\Element\Text; +use TypechoPlugin\InfinityTime\Lib\Database; use TypechoPlugin\InfinityTime\Lib\ImageRepository; use TypechoPlugin\InfinityTime\Lib\MediaProcessor; @@ -26,7 +27,7 @@ // Typecho versions instead of being misclassified as an instant plugin. class Plugin implements \Typecho_Plugin_Interface { - public const VERSION = '1.13.4'; + public const VERSION = '1.15.0'; public const MENU_NAME = 'InfinityTime'; // 统一默认(“恢复默认/写入”)配置,避免各处写死不同数值 public const DEFAULT_QUALITY = 76; // 缩略图质量 @@ -268,9 +269,13 @@ public static function uploadHandle(array $file) // 记录为“未归档”图片(cid=0),清理时不会误删 try { - ImageRepository::insertRow(0, $meta, 0); + if (ImageRepository::insertRow(0, $meta, 0) <= 0) { + throw new \RuntimeException('图片记录写入失败'); + } } catch (\Throwable $e) { self::log('insertRow(cid=0): ' . $e->getMessage()); + // 不向 Typecho 返回没有数据库引用、将来可能被孤儿清理的产物。 + return self::defaultUploadHandle($file); } return [ @@ -368,6 +373,8 @@ private static function createTables(): void {$q}size{$q} integer DEFAULT 0, {$q}sort{$q} integer DEFAULT 0, {$q}hash{$q} varchar(64) DEFAULT '', + {$q}upload_key{$q} varchar(64) DEFAULT NULL, + {$q}upload_fingerprint{$q} varchar(64) DEFAULT NULL, {$q}exif{$q} text, {$q}gps_lat{$q} real, {$q}gps_lng{$q} real, @@ -379,7 +386,7 @@ private static function createTables(): void if ($isMysql) { $sql .= " engine=InnoDB DEFAULT CHARSET=utf8mb4"; } - $db->query($sql); + Database::query($sql); // 旧表升级:按 schema 版本补齐缺失列 self::migrateSchema(); @@ -387,9 +394,9 @@ private static function createTables(): void // 索引(跨库兼容:SQLite 支持 IF NOT EXISTS;MySQL/MariaDB 需 try/catch 忽略已存在) try { if ($isMysql) { - $db->query("CREATE INDEX idx_{$table}_cid ON {$q}{$table}{$q} (`cid`)"); + Database::query("CREATE INDEX idx_{$table}_cid ON {$q}{$table}{$q} (`cid`)"); } else { - $db->query("CREATE INDEX idx_cid ON {$q}{$table}{$q} ({$q}cid{$q})"); + Database::query("CREATE INDEX idx_cid ON {$q}{$table}{$q} ({$q}cid{$q})"); } } catch (\Throwable $e) { // 已存在或不受支持时忽略 @@ -397,7 +404,7 @@ private static function createTables(): void } /** 当前 schema 版本。新增列时 +1,并在 migrateSchema 里补对应 ALTER。 */ - private const SCHEMA_VERSION = 2; + private const SCHEMA_VERSION = 4; /** 按版本补齐旧表缺失的列(幂等;每进程/每次激活最多跑一次 DDL)。 */ public static function migrateSchema(): void @@ -410,26 +417,70 @@ public static function migrateSchema(): void if ($current >= self::SCHEMA_VERSION) { return; } - $cols = []; - if ($current < 1) { - $cols[] = 'title'; - $cols[] = 'desc'; - } - if ($current < 2) { - $cols[] = 'mid'; - $cols[] = 'avif'; - $cols[] = 'mid_avif'; - } - foreach ($cols as $col) { + // v3 一次性验证全部旧列,修复曾被 v2 错误标记成功的安装。 + $cols = ['title' => 'text', 'desc' => 'text', 'mid' => 'text', 'avif' => 'text', 'mid_avif' => 'text', + 'upload_key' => 'varchar(64) DEFAULT NULL', 'upload_fingerprint' => 'varchar(64) DEFAULT NULL']; + foreach ($cols as $col => $definition) { + // 使用限定列名,避免 SQLite 将不存在的双引号列名当作字符串。 + $probe = "SELECT {$q}{$table}{$q}.{$q}{$col}{$q} FROM {$q}{$table}{$q} WHERE 1 = 0"; + try { + Database::query($probe); + continue; + } catch (\Throwable $e) { + // 缺列时尝试补齐;其他读取错误也不能冒充成功。 + } + try { + Database::query("ALTER TABLE {$q}{$table}{$q} ADD COLUMN {$q}{$col}{$q} {$definition}"); + } catch (\Throwable $e) { + // 另一进程可能已加列;下面实际探测成功才允许继续。 + } try { - $db->query("ALTER TABLE {$q}{$table}{$q} ADD COLUMN {$q}{$col}{$q} text"); + Database::query($probe); } catch (\Throwable $e) { - // 列已存在则忽略 + throw new \RuntimeException('InfinityTime 数据库升级失败,请检查数据库权限后重试(列:' . $col . ')', 0, $e); } } + self::ensureUploadIndex($db, $table); self::setOption('infinitytimeSchemaVersion', (string)self::SCHEMA_VERSION); } + /** 可空键兼容已有记录,唯一索引为上传重试提供数据库级去重保护。 */ + private static function ensureUploadIndex($db, string $table): void + { + $adapter = strtolower($db->getAdapterName()); + $mysql = strpos($adapter, 'mysql') !== false; + $pgsql = strpos($adapter, 'pgsql') !== false || strpos($adapter, 'postgres') !== false; + $q = $mysql ? '`' : '"'; + $index = 'it_upload_' . substr(sha1($table), 0, 16); + try { + Database::query("CREATE UNIQUE INDEX {$q}{$index}{$q} ON {$q}{$table}{$q} ({$q}cid{$q}, {$q}upload_key{$q})"); + return; + } catch (\Throwable $e) { + // 建索引失败时,只有验证已存在的索引确为目标唯一索引才允许继续。 + } + $valid = false; + if ($mysql) { + $rows = $db->fetchAll(Database::query("SHOW INDEX FROM {$q}{$table}{$q} WHERE Key_name = '" . $index . "'")); + usort($rows, static function ($a, $b) { return (int)$a['Seq_in_index'] <=> (int)$b['Seq_in_index']; }); + $valid = count($rows) === 2 && (int)$rows[0]['Non_unique'] === 0 && (int)$rows[1]['Non_unique'] === 0 + && array_column($rows, 'Column_name') === ['cid', 'upload_key']; + } elseif ($pgsql) { + $rows = $db->fetchAll(Database::query("SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND indexname = '" . $index . "'")); + $definition = str_replace('"', '', (string)($rows[0]['indexdef'] ?? '')); + $valid = strpos($definition, 'CREATE UNIQUE INDEX ') === 0 + && preg_match('/\(cid,\s*upload_key\)/', $definition) === 1; + } else { + foreach ($db->fetchAll(Database::query("PRAGMA index_list(" . $q . $table . $q . ")")) as $row) { + if (($row['name'] ?? '') !== $index || (int)($row['unique'] ?? 0) !== 1) { continue; } + $columns = $db->fetchAll(Database::query("PRAGMA index_info(" . $q . $index . $q . ")")); + $valid = array_column($columns, 'name') === ['cid', 'upload_key']; + } + } + if (!$valid) { + throw new \RuntimeException('InfinityTime 上传去重索引创建失败,请检查数据库权限后重试'); + } + } + private static function safeName(string $name): string { $info = pathinfo($name); @@ -453,13 +504,12 @@ public static function setOption(string $name, $value): void $db = Db::get(); $prefix = $db->getPrefix(); $val = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : (string)$value; - $exists = $db->fetchRow( - $db->select('name')->from($prefix . 'options')->where('name = ?', $name)->limit(1) - ); + $query = $db->select('name')->from($prefix . 'options')->where('name = ?', $name)->limit(1); + $exists = $db->fetchRow(Database::query($query->prepare((string)$query))); if ($exists) { - $db->query($db->update($prefix . 'options')->rows(['value' => $val])->where('name = ?', $name)); + Database::query($db->update($prefix . 'options')->rows(['value' => $val])->where('name = ?', $name)); } else { - $db->query($db->insert($prefix . 'options')->rows(['name' => $name, 'user' => 0, 'value' => $val])); + Database::query($db->insert($prefix . 'options')->rows(['name' => $name, 'user' => 0, 'value' => $val])); } $options = Helper::options(); $options->{$name} = $value; @@ -473,3 +523,4 @@ public static function log(string $msg): void // 全局上传钩子注册(放在文件顶层,插件加载时执行) \Typecho\Plugin::factory('Widget_Upload')->uploadHandle = 'TypechoPlugin\\InfinityTime\\Plugin::uploadHandle'; + diff --git a/usr/plugins/InfinityTime/album-tools.php b/usr/plugins/InfinityTime/album-tools.php new file mode 100644 index 0000000..3b54d0e --- /dev/null +++ b/usr/plugins/InfinityTime/album-tools.php @@ -0,0 +1,27 @@ + $cid, 'image_page' => $page, 'image_missing' => $missing, 'image_all' => $all ? '1' : '0']) . '#albums'; +}; +?> +
+

本次筛选共 张;。

+ + +

分页或筛选时不拖拽排序,避免遗漏其他照片。加载全部照片后排序(大相册可能较慢)

+
+ +

统一修改本页勾选的照片(最多100张)。只覆盖下方勾选的字段;内容留空表示清空该字段。

+ + + +
+
diff --git a/usr/plugins/InfinityTime/assets/admin.css b/usr/plugins/InfinityTime/assets/admin.css index c62b529..a4b6e2b 100644 --- a/usr/plugins/InfinityTime/assets/admin.css +++ b/usr/plugins/InfinityTime/assets/admin.css @@ -442,3 +442,29 @@ summary { cursor: pointer; } .pp-card { padding: 18px 16px; } .pp-tabs { position: static; } } + +/* 串行上传状态:保留成功项,只重试失败项。 */ +.pp-up-state { font-size: 12px; line-height: 1.5; color: #52606d; overflow-wrap: anywhere; } +.pp-up-uploading, .pp-up-converting { border-color: var(--pp-brand); } +.pp-up-uploading .pp-up-state, .pp-up-converting .pp-up-state { color: var(--pp-brand-dark); font-weight: 600; } +.pp-up-done { border-color: #b9d9b5; background: #fcfefb; } +.pp-up-done .pp-up-state { color: #2e7d32; } +.pp-up-failed { border-color: #e5a69f; } +.pp-up-failed .pp-up-state { color: var(--pp-danger); } +.pp-up-remove:disabled { opacity: .35; cursor: not-allowed; } +.pp-up-tit:disabled, .pp-up-desc:disabled { color: #52606d; background: #f6f8fa; } +.pp-up-saved { display: flex; align-items: center; justify-content: center; color: #2e7d32; font-size: 13px; } +.pp-up-missing { border: 1px dashed #d6dbe0; } +.pp-dropzone.disabled { opacity: .55; cursor: not-allowed; } +#pp-upload-target { display: block; color: #3e4c59; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; } +.pp-wrap [hidden] { display: none !important; } +.pp-album-search, .pp-pagination { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 12px 0; } +.pp-pagination a, .pp-pagination span { padding: 6px 10px; border-radius: 6px; } +.pp-pagination [aria-current="page"] { background: var(--pp-brand); color: #fff; } + +.pp-album-search input[type="search"] { min-width: 0; max-width: 100%; padding: 7px 10px; border: 1px solid #d6dbe0; border-radius: 8px; } + +.pp-album-tools { grid-column: 1 / -1; padding: 12px; border: 1px solid #ddd; border-radius: 8px; } +.pp-album-tools nav { display: flex; flex-wrap: wrap; gap: 14px; margin: 10px 0; } +.pp-batch-form { display: grid; gap: 8px; max-width: 640px; } +.pp-batch-form textarea, .pp-batch-form input[type="text"] { width: 100%; box-sizing: border-box; } diff --git a/usr/plugins/InfinityTime/assets/admin.js b/usr/plugins/InfinityTime/assets/admin.js index c847ace..a6dd5e8 100644 --- a/usr/plugins/InfinityTime/assets/admin.js +++ b/usr/plugins/InfinityTime/assets/admin.js @@ -9,6 +9,7 @@ var ENDPOINT = CFG.url || ''; var TOKEN = CFG.token || ''; var JOB = CFG.job || null; + var CAN_PUBLISH = CFG.canPublish === true; var $ = function (sel, root) { return (root || document).querySelector(sel); }; var $$ = function (sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }; @@ -40,21 +41,34 @@ if (action) fd.set('action', action); if (TOKEN) fd.set('_', TOKEN); return fetch(ENDPOINT, { method: 'POST', body: fd, credentials: 'same-origin' }) - .then(function (r) { return r.json().catch(function () { return { ok: false, msg: '服务器返回异常' }; }); }); + .then(function (r) { + return r.json().catch(function () { return { ok: false, msg: '服务器返回异常' }; }) + .then(function (d) { if (!r.ok) return { ok: false, msg: (d && d.msg) || '请求失败,请重试' }; return d; }); + }); + } + + function readURL(job, params) { + var query = 'ajax=1&job=' + encodeURIComponent(job); + Object.keys(params || {}).forEach(function (key) { query += '&' + encodeURIComponent(key) + '=' + encodeURIComponent(params[key]); }); + return ENDPOINT + (ENDPOINT.indexOf('?') === -1 ? '?' : '&') + query; } + var albumsRefresh = 0; function refreshAlbums() { - fetch(ENDPOINT + (ENDPOINT.indexOf('?') === -1 ? '?' : '&') + 'ajax=1&job=albums_html', { credentials: 'same-origin' }) - .then(function (r) { return r.text(); }) + var cur = $('#pp-albums-card'); + if (!cur) return Promise.resolve(); + var request = ++albumsRefresh; + return fetch(readURL('albums_html', { page: cur.dataset.page || '1', q: cur.dataset.query || '' }), { credentials: 'same-origin' }) + .then(function (r) { if (!r.ok) throw new Error('list'); return r.text(); }) .then(function (html) { + if (request !== albumsRefresh) return; var fresh = new DOMParser().parseFromString(html, 'text/html').querySelector('#pp-albums-card'); - var cur = $('#pp-albums-card'); - if (fresh && cur) { - cur.outerHTML = fresh.outerHTML; - Albums.bind(); - } + var current = $('#pp-albums-card'); + if (!fresh || !current) throw new Error('list'); + current.outerHTML = fresh.outerHTML; + Albums.bind(); }) - .catch(function () {}); + .catch(function () { notice('操作已保存,但图集列表刷新失败,请刷新页面查看。', 'error'); }); } /* ---------- 标签页 ---------- */ @@ -80,220 +94,410 @@ } }; - /* ---------- 上传发布 ---------- */ + /* ---------- 串行上传:先保存草稿,再明确发布 ---------- */ var Upload = { sel: [], + running: false, + append: function () {}, init: function () { - var form = $('#pp-upload-form'); - var input = $('#pp-files-input'); - var wrap = $('#pp-upload-previews'); + var form = $('#pp-upload-form'), input = $('#pp-files-input'), wrap = $('#pp-upload-previews'); if (!form || !input || !wrap) return; - // JS 按选中队列校验;拖入文件的浏览器不一定支持回写 input.files。 input.required = false; - var submitBtn = form.querySelector('button[type=submit]'); - var dropzone = $('#pp-dropzone'); - var bar = $('#pp-upload-bar'); - var barOuter = $('#pp-upload-progress'); + var submitBtn = $('#pp-upload-submit') || form.querySelector('button[type=submit]'); + var retryBtn = $('#pp-upload-retry'), publishBtn = $('#pp-upload-publish'), newBtn = $('#pp-upload-new'); + var cidInput = $('#pp-upload-cid'), target = $('#pp-upload-target'); + var operationInput = form.querySelector('[name="operation_key"]'); + var dropzone = $('#pp-dropzone'), bar = $('#pp-upload-bar'), barOuter = $('#pp-upload-progress'); var barMsg = barOuter ? barOuter.querySelector('.pp-msg') : null; - + var summaryEl = $('#pp-upload-summary'), formFields = {}; + var storageKey = 'InfinityTime.upload:' + ENDPOINT + ':' + (CFG.userId || 'current'); + var cid = '', albumTitle = '', albumStatus = 'draft', albumCount = 0, verified = false; + var operationKey = '', activeProgress = 0; + var initialTarget = CFG.uploadTarget || null; + var labels = { queued: '等待上传', uploading: '正在上传', converting: '正在转换并保存', done: '已保存', failed: '失败' }; + + function field(name) { return formFields[name] || (formFields[name] = form.querySelector('[name="' + name + '"]')); } + function value(name) { var el = field(name); return el ? el.value : ''; } + function key() { + if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID(); + return 'it-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2) + '-' + Math.random().toString(36).slice(2); + } function fileKey(f) { return f.name + '|' + f.size + '|' + (f.lastModified || 0); } - + function humanSize(n) { + if (!n) return '0 B'; + var units = ['B', 'KB', 'MB', 'GB'], i = 0; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return n.toFixed(i ? 1 : 0) + ' ' + units[i]; + } + function savedFields() { + return { title: value('title'), address: value('address'), device: value('device'), tags: value('tags') }; + } + function persist() { + if (cidInput) cidInput.value = cid; + if (operationInput) operationInput.value = operationKey; + try { + window.sessionStorage.setItem(storageKey, JSON.stringify({ + cid: cid, title: albumTitle, status: albumStatus, operationKey: operationKey, fields: savedFields(), + items: Upload.sel.map(function (it) { + return { fingerprint: it.fingerprint, name: it.name, size: it.size, itemKey: it.itemKey, + title: it.title, desc: it.desc, address: it.address, state: it.state, rowId: it.rowId || null }; + }) + })); + } catch (e) {} + } function syncInput() { try { var dt = new DataTransfer(); - Upload.sel.forEach(function (item) { dt.items.add(item.file); }); + Upload.sel.forEach(function (item) { if (item.file && item.state !== 'done') dt.items.add(item.file); }); input.files = dt.files; - } catch (e) {} + } catch (e) { input.value = ''; } } - - function humanSize(n) { - if (!n) return '0 B'; - var u = ['B', 'KB', 'MB', 'GB'], i = 0; - while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } - return n.toFixed(i ? 1 : 0) + ' ' + u[i]; + function discard() { + Upload.sel.forEach(function (item) { if (item.url) URL.revokeObjectURL(item.url); }); + Upload.sel = []; + input.value = ''; } - - function updateSummary() { - var el = $('#pp-upload-summary'); - if (!el) return; - if (!Upload.sel.length) { el.textContent = ''; return; } - var total = Upload.sel.reduce(function (s, it) { return s + (it.file.size || 0); }, 0); - el.textContent = '已选 ' + Upload.sel.length + ' 张,共 ' + humanSize(total); + function readOnlyTarget() { return !!cid && albumStatus === 'publish' && !CAN_PUBLISH; } + function controls() { + var busy = Upload.running || readOnlyTarget(); + input.disabled = busy; + ['title', 'device', 'tags'].forEach(function (name) { var el = field(name); if (el) el.disabled = busy || !!cid; }); + var address = field('address'); if (address) address.disabled = busy; + if (submitBtn) { submitBtn.disabled = busy; submitBtn.textContent = readOnlyTarget() ? '已发布图集(只读)' : (busy ? '处理中…' : (cid ? (verified ? '保存新增图片' : '重新核验图集') : '保存草稿并上传')); } + if (retryBtn) { retryBtn.hidden = !Upload.sel.some(function (it) { return it.state === 'failed'; }); retryBtn.disabled = busy; } + if (publishBtn) { + publishBtn.hidden = !CAN_PUBLISH || !cid || albumStatus !== 'draft'; + publishBtn.disabled = busy || !verified || !albumCount || Upload.sel.some(function (it) { return it.state !== 'done'; }); + } + if (newBtn) { newBtn.disabled = Upload.running; newBtn.hidden = !cid && !Upload.sel.length && !operationKey; } + if (dropzone) { dropzone.classList.toggle('disabled', busy); dropzone.setAttribute('aria-disabled', String(busy)); } + if (target) target.textContent = cid ? + (albumStatus === 'publish' ? (readOnlyTarget() ? '已发布图集(只读):' : '追加到已发布图集:') : '当前草稿:') + albumTitle + '(#' + cid + ',已保存 ' + albumCount + ' 张)' + (!verified ? ',待核验' : '') + (!CAN_PUBLISH && albumStatus === 'draft' ? '。请编辑或管理员审核发布。' : '') : + (CAN_PUBLISH ? '新建草稿:图片保存后,点击“发布图集”才会公开。' : '新建草稿:保存后需编辑或管理员审核发布。'); } - - function render() { - // 回收旧预览图的 object URL,避免反复增删造成内存累积 - $$('.pp-up-thumb', wrap).forEach(function (img) { - if (img.dataset.objectUrl) { try { URL.revokeObjectURL(img.dataset.objectUrl); } catch (e) {} } + function summary() { + var counts = { queued: 0, uploading: 0, converting: 0, done: 0, failed: 0 }, total = 0; + Upload.sel.forEach(function (it) { counts[it.state]++; total += it.size || 0; }); + if (summaryEl) summaryEl.textContent = Upload.sel.length ? '本次 ' + Upload.sel.length + ' 张(' + humanSize(total) + '):已保存 ' + counts.done + ',待上传 ' + counts.queued + ',失败 ' + counts.failed : ''; + if (barOuter) barOuter.style.display = Upload.sel.length ? 'flex' : 'none'; + var complete = counts.done + counts.failed; + if (bar) bar.style.width = (Upload.sel.length ? Math.round((complete + activeProgress) * 100 / Upload.sel.length) : 0) + '%'; + if (barMsg) barMsg.textContent = Upload.running ? '已处理 ' + complete + ' / ' + Upload.sel.length : '已保存 ' + counts.done + ' / ' + Upload.sel.length; + controls(); + } + function renderCard(item, idx) { + var card = document.createElement('div'); + card.className = 'pp-up-item pp-up-' + item.state; + card.dataset.idx = idx; + card.dataset.state = item.state; + var thumb = document.createElement(item.state === 'done' && !item.file ? 'div' : 'img'); + thumb.className = 'pp-up-thumb'; thumb.alt = item.name; + if (item.state === 'done' && !item.file) { thumb.classList.add('pp-up-saved'); thumb.textContent = '✓ 图片已保存在图集中'; } + if (item.file && !item.url) { try { item.url = URL.createObjectURL(item.file); } catch (e) {} } + if (item.url) thumb.src = item.url; + else thumb.classList.add('pp-up-missing'); + var rm = document.createElement('button'); + rm.type = 'button'; rm.className = 'pp-up-remove'; rm.textContent = '×'; + rm.title = item.state === 'done' ? '已保存图片请在图集内管理' : '从待上传队列移除'; + rm.disabled = Upload.running || item.state === 'done'; + rm.addEventListener('click', function () { + if (Upload.running || item.state === 'done') return; + if (item.url) URL.revokeObjectURL(item.url); + Upload.sel.splice(idx, 1); syncInput(); persist(); render(); }); + var name = document.createElement('div'); name.className = 'pp-up-name'; name.textContent = item.name; + var state = document.createElement('div'); state.className = 'pp-up-state'; state.setAttribute('role', 'status'); + state.textContent = labels[item.state] + (item.error ? ':' + item.error : ''); + var tit = document.createElement('input'); + tit.type = 'text'; tit.className = 'pp-up-tit'; tit.placeholder = '图片标题(可选)'; tit.value = item.title; + var desc = document.createElement('textarea'); + desc.rows = 2; desc.className = 'pp-up-desc'; desc.placeholder = '图片描述(可选)'; desc.value = item.desc; + tit.disabled = desc.disabled = Upload.running || item.state === 'done' || typeof item.address !== 'undefined'; + tit.addEventListener('input', function () { item.title = tit.value; persist(); }); + desc.addEventListener('input', function () { item.desc = desc.value; persist(); }); + card.appendChild(thumb); card.appendChild(rm); card.appendChild(name); card.appendChild(state); card.appendChild(tit); card.appendChild(desc); + Upload.bindDrag(card, idx, function () { syncInput(); persist(); render(); }); + return card; + } + function render() { wrap.innerHTML = ''; - Upload.sel.forEach(function (item, idx) { - var card = document.createElement('div'); - card.className = 'pp-up-item'; - card.dataset.idx = idx; - - var thumb = document.createElement('img'); - thumb.className = 'pp-up-thumb'; - thumb.alt = item.file.name; - var url = ''; - try { url = URL.createObjectURL(item.file); } catch (e) {} - if (url) { thumb.dataset.objectUrl = url; thumb.src = url; } - - var rm = document.createElement('button'); - rm.type = 'button'; - rm.className = 'pp-up-remove'; - rm.textContent = '×'; - rm.title = '移除这张图片'; - rm.addEventListener('click', function () { - Upload.sel.splice(idx, 1); - syncInput(); render(); updateSummary(); - }); - - var name = document.createElement('div'); - name.className = 'pp-up-name'; - name.textContent = item.file.name; - - var tit = document.createElement('input'); - tit.type = 'text'; tit.className = 'pp-up-tit'; tit.placeholder = '图片标题(可选)'; tit.value = item.title; - tit.addEventListener('input', function () { item.title = tit.value; card.classList.add('touched'); }); - - var desc = document.createElement('textarea'); - desc.rows = 2; desc.className = 'pp-up-desc'; desc.placeholder = '图片描述(可选)'; desc.value = item.desc; - desc.addEventListener('input', function () { item.desc = desc.value; card.classList.add('touched'); }); - - card.appendChild(thumb); card.appendChild(rm); card.appendChild(name); card.appendChild(tit); card.appendChild(desc); - Upload.bindDrag(card, idx, render); - wrap.appendChild(card); - }); - updateSummary(); + Upload.sel.forEach(function (item, idx) { wrap.appendChild(renderCard(item, idx)); }); + summary(); + } + // 上传期间队列顺序不变,只替换状态变化的卡片,避免每张图片反复重建整个队列。 + function renderItem(item) { + var idx = Upload.sel.indexOf(item), previous = wrap.children[idx]; + if (idx < 0 || !previous) { render(); return; } + wrap.insertBefore(renderCard(item, idx), previous); + wrap.removeChild(previous); + summary(); } - function addFiles(files) { - var existing = {}; - Upload.sel.forEach(function (item) { existing[fileKey(item.file)] = true; }); + if (Upload.running || readOnlyTarget()) return; Array.prototype.forEach.call(files, function (f) { - var k = fileKey(f); - if (existing[k]) return; - existing[k] = true; - Upload.sel.push({ file: f, title: '', desc: '' }); + var fingerprint = fileKey(f); + var existing = Upload.sel.filter(function (it) { return it.fingerprint === fingerprint; })[0]; + if (existing) { + if (!existing.file && existing.state !== 'done') { existing.file = f; existing.error = '文件已重新选择,请点击“重试失败项”'; } + return; + } + Upload.sel.push({ file: f, fingerprint: fingerprint, name: f.name, size: f.size, + itemKey: key(), title: '', desc: '', state: 'queued', error: '' }); }); - syncInput(); render(); + syncInput(); persist(); render(); } - - input.addEventListener('change', function () { addFiles(input.files); }); - - if (dropzone) { - ['dragenter', 'dragover'].forEach(function (ev) { - dropzone.addEventListener(ev, function (e) { e.preventDefault(); dropzone.classList.add('over'); }); - }); - ['dragleave', 'drop'].forEach(function (ev) { - dropzone.addEventListener(ev, function (e) { e.preventDefault(); dropzone.classList.remove('over'); }); - }); - dropzone.addEventListener('drop', function (e) { - if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) addFiles(e.dataTransfer.files); - }); - dropzone.addEventListener('click', function () { input.click(); }); + function verifyTarget() { + if (!cid) return Promise.resolve(); + return fetch(readURL('album_status', { cid: cid }), { credentials: 'same-origin' }) + .then(function (r) { if (!r.ok) throw new Error('无法核验图集,请稍后重试。'); return r.json(); }) + .then(function (d) { + if (!d || !d.ok) throw new Error((d && d.msg) || '图集不存在或无权限,请选择其他图集或新建草稿。'); + albumTitle = d.title || albumTitle; albumStatus = d.status || 'draft'; albumCount = Number(d.count) || 0; + verified = true; persist(); controls(); + if (readOnlyTarget()) throw new Error('已发布图集为只读,追加图片需编辑或管理员权限。'); + }); } - - form.addEventListener('submit', function (e) { - e.preventDefault(); - if (!Upload.sel.length) { notice('请至少选择一张图片', 'error'); return; } - $$('input[name="img_titles[]"], textarea[name="img_descs[]"]', form).forEach(function (el) { el.parentNode.removeChild(el); }); - Upload.sel.forEach(function (item) { - var t = document.createElement('input'); - t.type = 'hidden'; t.name = 'img_titles[]'; t.value = item.title || ''; - form.appendChild(t); - var d = document.createElement('textarea'); - d.name = 'img_descs[]'; d.value = item.desc || ''; d.style.display = 'none'; - form.appendChild(d); + function ensureDraft() { + if (cid) return verified ? Promise.resolve() : verifyTarget(); + operationKey = operationKey || key(); + persist(); + var data = savedFields(); data.operation_key = operationKey; + return post('create_draft', data).then(function (d) { + if (!d || !d.ok || !d.cid) throw new Error((d && d.msg) || '草稿保存失败,请重试。'); + cid = String(d.cid); albumTitle = data.title; albumStatus = d.status || 'draft'; verified = true; + persist(); controls(); }); - syncInput(); - if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '发布中…'; } - if (barOuter) barOuter.style.display = 'flex'; - if (bar) bar.style.width = '0%'; - - var fd = new FormData(form); - // 以预览队列为唯一顺序来源,不依赖 DataTransfer 回写是否成功。 - fd.delete(input.name); - Upload.sel.forEach(function (item) { fd.append(input.name, item.file, item.file.name); }); - fd.set('ajax', '1'); - if (TOKEN) fd.set('_', TOKEN); - var xhr = new XMLHttpRequest(); - xhr.open('POST', ENDPOINT, true); - xhr.withCredentials = true; - xhr.upload.onprogress = function (ev) { - if (!ev.lengthComputable) return; - if (bar) bar.style.width = Math.round(ev.loaded * 100 / ev.total) + '%'; - if (barMsg) barMsg.textContent = humanSize(ev.loaded) + ' / ' + humanSize(ev.total); - }; - xhr.onload = function () { - var d = null; - try { d = JSON.parse(xhr.responseText); } catch (e) { d = { ok: false, msg: '服务器返回异常' }; } - if (d && d.ok) { - notice(d.msg || '已发布图集', 'success'); - Upload.sel = []; - form.reset(); - try { input.value = ''; } catch (e) {} - render(); - refreshAlbums(); - } else { - notice((d && d.msg) ? d.msg : '发布失败', 'error'); + } + function uploadOne(item) { + return new Promise(function (resolve) { + if (!item.file) { item.state = 'failed'; item.error = '请重新选择此文件,浏览器刷新后不会保留文件内容'; persist(); renderItem(item); resolve(); return; } + item.state = 'uploading'; item.error = ''; activeProgress = 0; + if (typeof item.address === 'undefined') item.address = value('address'); + persist(); renderItem(item); + var fd = new FormData(); + fd.set('action', 'upload_image'); fd.set('ajax', '1'); fd.set('cid', cid); fd.set('item_key', item.itemKey); + fd.set('title', item.title || ''); fd.set('desc', item.desc || ''); fd.set('address', item.address || ''); + if (TOKEN) fd.set('_', TOKEN); + fd.append('file', item.file, item.name); + var xhr = new XMLHttpRequest(), settled = false; + function finish(d) { + if (settled) return; + settled = true; + if (d && d.ok) { + item.state = 'done'; item.rowId = d.rowId; item.error = ''; + // 服务器确认已保存后保留幂等键和元数据,不再占用原图文件/预览资源。 + if (item.rowId) { + if (item.url) URL.revokeObjectURL(item.url); + item.url = ''; item.file = null; + } + if (!d.replayed) albumCount++; + } else { + item.state = 'failed'; + item.error = (d && d.msg) || '网络或上传出错,请重试;同一图片不会重复保存'; + } + activeProgress = 0; persist(); renderItem(item); resolve(); } - }; - xhr.onerror = function () { notice('网络/上传出错,请重试', 'error'); }; - xhr.onloadend = function () { - if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '发布图集'; } - if (barOuter) setTimeout(function () { barOuter.style.display = 'none'; }, 600); - }; - xhr.send(fd); + xhr.open('POST', ENDPOINT, true); xhr.withCredentials = true; + // 超时后的保存结果不确定:重试时保留原幂等键。 + xhr.timeout = 180000; + xhr.upload.onprogress = function (ev) { + if (settled || !ev.lengthComputable || !ev.total) return; + activeProgress = Math.min(0.95, ev.loaded / ev.total * 0.95); + if (ev.loaded >= ev.total && item.state === 'uploading') { item.state = 'converting'; persist(); renderItem(item); } + else summary(); + }; + xhr.upload.onload = function () { if (!settled && item.state === 'uploading') { item.state = 'converting'; persist(); renderItem(item); } }; + xhr.onload = function () { + var d; try { d = JSON.parse(xhr.responseText); } catch (e) { d = { ok: false, msg: '服务器响应异常,请重试此项' }; } + if (xhr.status < 200 || xhr.status >= 300) d = { ok: false, msg: (d && d.msg) || '上传请求失败,请重试' }; + finish(d); + }; + xhr.onerror = xhr.onabort = function () { finish(null); }; + xhr.ontimeout = function () { finish({ ok: false, msg: '请求超时,保存结果未确认,请重试此项' }); }; + try { xhr.send(fd); } catch (e) { finish(null); } + }); + } + function run(onlyFailed) { + if (Upload.running) return; + if (readOnlyTarget()) { notice('已发布图集为只读,请新建草稿或联系编辑 / 管理员。', 'error'); return; } + var queue = Upload.sel.filter(function (it) { return it.state === (onlyFailed ? 'failed' : 'queued'); }); + if (!queue.length && cid && !verified) { + Upload.running = true; render(); + verifyTarget().then(function () { notice('图集状态已更新。', 'success'); }) + .catch(function (err) { notice(err.message, 'error'); }) + .then(function () { Upload.running = false; render(); }); + return; + } + if (!queue.length) { notice(onlyFailed ? '没有失败项需要重试。' : '请先选择图片;失败项请使用“重试失败项”。', 'error'); return; } + if (!cid && !value('title').trim()) { notice('请填写图集标题。', 'error'); return; } + if (queue.some(function (it) { return !it.file; })) { notice('请重新选择未完成的原文件,再重试失败项。已保存图片仍在图集中。', 'error'); return; } + // 文件由队列持有;清空原生 FileList,成功项释放时不会被文件输入框继续引用。 + input.value = ''; + Upload.running = true; render(); + ensureDraft().then(function () { + return queue.reduce(function (previous, item) { return previous.then(function () { return uploadOne(item); }); }, Promise.resolve()); + }).then(function () { + // 根据服务器持久化状态核对未确认响应及幂等重试结果。 + return verifyTarget().catch(function () { verified = false; }); + }).then(function () { + var failed = Upload.sel.filter(function (it) { return it.state === 'failed'; }).length; + notice(!verified ? '上传已结束,但图集状态核验失败。已保存图片不会丢失,请点击“重新核验图集”。' : (failed ? '已保留成功图片,' + failed + ' 项失败,请重试失败项。' : (albumStatus === 'draft' ? (CAN_PUBLISH ? '图片已保存为草稿,确认后点击“发布图集”。' : '图片已保存为草稿,请编辑或管理员审核发布。') : '图片已追加到已发布图集。')), (!verified || failed) ? 'error' : 'success'); + refreshAlbums(); + }).catch(function (err) { notice(err.message || '草稿保存失败,请重试。', 'error'); }) + .then(function () { Upload.running = false; activeProgress = 0; persist(); render(); }); + } + input.addEventListener('change', function () { addFiles(input.files); }); + ['title', 'device', 'tags', 'address'].forEach(function (name) { var el = field(name); if (el) el.addEventListener('input', persist); }); + if (dropzone) { + ['dragenter', 'dragover'].forEach(function (event) { dropzone.addEventListener(event, function (e) { e.preventDefault(); if (!Upload.running && !readOnlyTarget()) dropzone.classList.add('over'); }); }); + ['dragleave', 'drop'].forEach(function (event) { dropzone.addEventListener(event, function (e) { e.preventDefault(); dropzone.classList.remove('over'); }); }); + dropzone.addEventListener('drop', function (e) { if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files); }); + dropzone.addEventListener('click', function () { if (!Upload.running && !readOnlyTarget()) input.click(); }); + } + form.addEventListener('submit', function (e) { e.preventDefault(); run(false); }); + if (retryBtn) retryBtn.addEventListener('click', function () { run(true); }); + if (publishBtn) publishBtn.addEventListener('click', function () { + if (!CAN_PUBLISH || Upload.running || !cid || albumStatus !== 'draft') return; + if (Upload.sel.some(function (it) { return it.state !== 'done'; })) { notice('请先处理未完成项,或将其从队列移除。', 'error'); return; } + Upload.running = true; render(); + verifyTarget().then(function () { + if (!albumCount) throw new Error('草稿中还没有图片,请先上传。'); + return post('publish_album', { cid: cid }); + }).then(function (d) { + if (!d || !d.ok) throw new Error((d && d.msg) || '发布失败,请重试。'); + albumStatus = 'publish'; persist(); notice(d.msg || '图集已发布。', 'success'); refreshAlbums(); + }).catch(function (err) { notice(err.message || '发布请求未确认,请刷新图集状态后重试。', 'error'); }) + .then(function () { Upload.running = false; render(); }); }); - + function resetTarget() { + discard(); cid = ''; albumTitle = ''; albumCount = 0; albumStatus = 'draft'; verified = false; operationKey = ''; + form.reset(); + ['title', 'device', 'tags', 'address'].forEach(function (name) { var el = field(name); if (el) el.value = ''; }); + persist(); render(); + } + if (newBtn) newBtn.addEventListener('click', function () { + if (Upload.running) return; + if (Upload.sel.some(function (it) { return it.state !== 'done'; }) && !confirm('清空当前未完成的上传队列并新建草稿?已保存的图片和草稿会保留。')) return; + resetTarget(); + }); + Upload.append = function (nextCid, title) { + if (Upload.running) { notice('请等待当前上传完成后再切换图集。', 'error'); return; } + if (cid && cid !== String(nextCid)) { + if (Upload.sel.some(function (it) { return it.state !== 'done'; }) && !confirm('切换图集会清空当前未完成的队列,已保存内容会保留。继续?')) return; + discard(); + } + cid = String(nextCid); albumTitle = title || ''; albumCount = 0; verified = false; operationKey = ''; + var titleField = field('title'); if (titleField) titleField.value = albumTitle; + Upload.running = true; persist(); render(); + var tab = document.querySelector('.pp-tab[data-tab="upload"]'); if (tab) tab.click(); + verifyTarget().catch(function (err) { notice(err.message, 'error'); }) + .then(function () { Upload.running = false; render(); }); + }; + window.addEventListener('beforeunload', function (e) { + persist(); + if (Upload.running || Upload.sel.some(function (it) { return it.state !== 'done'; })) { e.preventDefault(); e.returnValue = ''; } + }); + try { + var stored = JSON.parse(window.sessionStorage.getItem(storageKey) || 'null'); + if (stored && typeof stored === 'object' && (!initialTarget || String(initialTarget.cid) === String(stored.cid))) { + cid = /^\d+$/.test(String(stored.cid)) ? String(stored.cid) : ''; + operationKey = typeof stored.operationKey === 'string' ? stored.operationKey : ''; + albumTitle = stored.title || ''; albumStatus = stored.status || 'draft'; + ['title', 'device', 'tags', 'address'].forEach(function (name) { var el = field(name); if (el && stored.fields && typeof stored.fields[name] === 'string') el.value = stored.fields[name]; }); + (Array.isArray(stored.items) ? stored.items : []).forEach(function (it) { + if (!it.itemKey || !it.fingerprint) return; + Upload.sel.push({ fingerprint: it.fingerprint, name: it.name, size: it.size, itemKey: it.itemKey, + title: it.title || '', desc: it.desc || '', address: it.address, rowId: it.rowId, state: it.state === 'done' ? 'done' : 'failed', error: it.state === 'done' ? '' : '请重新选择原文件后重试' }); + }); + } + } catch (e) {} + if (initialTarget && initialTarget.cid) { + cid = String(initialTarget.cid); albumTitle = initialTarget.title || ''; albumStatus = initialTarget.status || 'draft'; + albumCount = Number(initialTarget.count) || 0; verified = false; operationKey = ''; + var initialTitle = field('title'); if (initialTitle) initialTitle.value = albumTitle; + } render(); + if (cid) { + Upload.running = true; render(); + verifyTarget().then(function () { + notice('已恢复图集“' + albumTitle + '”,服务器已保存 ' + albumCount + ' 张。' + (Upload.sel.some(function (it) { return it.state !== 'done'; }) ? '未完成项需重新选择原文件;浏览器不会保留文件内容。' : '可在图集列表查看已保存图片,或继续追加。'), 'success'); + }).catch(function (err) { notice(err.message, 'error'); }) + .then(function () { Upload.running = false; render(); }); + } else if (Upload.sel.length) notice('已恢复未完成项的信息,请重新选择原文件并点击“重试失败项”。', 'error'); }, - // 预览卡片拖拽排序 bindDrag: function (card, idx, rerender) { - // 只有从非输入区域按下时才允许拖拽,避免拖动标题/描述时误拖整张卡片 card.draggable = false; - card.addEventListener('mousedown', function (e) { - card.draggable = !(e.target && e.target.closest && e.target.closest('input, textarea, button, a, select')); - }); + function movable() { return !Upload.running && Upload.sel[idx] && Upload.sel[idx].state !== 'done'; } + card.addEventListener('mousedown', function (e) { card.draggable = movable() && !(e.target && e.target.closest && e.target.closest('input, textarea, button, a, select')); }); card.addEventListener('dragstart', function (e) { - e.dataTransfer.setData('text/plain', String(idx)); - e.dataTransfer.effectAllowed = 'move'; - card.classList.add('dragging'); + if (!movable()) { e.preventDefault(); return; } + e.dataTransfer.setData('text/plain', String(idx)); e.dataTransfer.effectAllowed = 'move'; card.classList.add('dragging'); }); - card.addEventListener('dragend', function () { card.classList.remove('dragging'); }); - card.addEventListener('dragover', function (e) { e.preventDefault(); card.classList.add('drop-target'); }); + card.addEventListener('dragend', function () { card.classList.remove('dragging'); card.draggable = false; }); + card.addEventListener('dragover', function (e) { if (movable()) { e.preventDefault(); card.classList.add('drop-target'); } }); card.addEventListener('dragleave', function () { card.classList.remove('drop-target'); }); card.addEventListener('drop', function (e) { - e.preventDefault(); - card.classList.remove('drop-target'); + e.preventDefault(); card.classList.remove('drop-target'); + if (!movable() || !e.dataTransfer) return; var from = parseInt(e.dataTransfer.getData('text/plain'), 10); - if (isNaN(from) || from < 0 || from >= Upload.sel.length || from === idx) return; - var moved = Upload.sel.splice(from, 1)[0]; - Upload.sel.splice(idx, 0, moved); - rerender(); + if (isNaN(from) || from < 0 || from >= Upload.sel.length || from === idx || Upload.sel[from].state === 'done') return; + Upload.sel.splice(idx, 0, Upload.sel.splice(from, 1)[0]); rerender(); }); } }; /* ---------- 已发布图集 ---------- */ var Albums = { + loadPage: function (box, query) { + if (!box || box.__ppLoading) return Promise.resolve(false); + box.__ppLoading = true; + box.setAttribute('aria-busy', 'true'); + query = query || box.__ppQuery || {}; + return fetch(readURL('album_images', Object.assign({ cid: box.getAttribute('data-cid') }, query)), { credentials: 'same-origin' }) + .then(function (r) { if (!r.ok) throw new Error('detail'); return r.text(); }) + .then(function (html) { + if (box.isConnected === false) return false; + box.innerHTML = html; box.__ppQuery = query; box.setAttribute('data-loaded', '1'); box.__ppSortBound = false; Albums.bind(); + return true; + }) + .catch(function () { + if (box.isConnected === false) return false; + box.setAttribute('data-loaded', '0'); + var old = box.querySelector('.pp-detail-retry'); if (old) old.remove(); + var retry = document.createElement('button'); + retry.type = 'button'; retry.className = 'pp-btn gray pp-small pp-detail-retry'; retry.textContent = '加载失败,点击重试'; + retry.addEventListener('click', function () { Albums.loadPage(box, query); }); box.appendChild(retry); + return false; + }).then(function (result) { box.__ppLoading = false; box.setAttribute('aria-busy', 'false'); return result; }); + }, bind: function () { - // 图集展开时才拉取图片列表(避免图集多时首屏 DOM 过大) + // 详情请求失败时保持可重试状态,不将图集标记为已加载。 $$('details.pp-album').forEach(function (d) { if (d.__ppLazyBound) return; d.__ppLazyBound = true; - d.addEventListener('toggle', function () { + function load() { if (!d.open) return; var box = d.querySelector('.pp-thumbs[data-cid]'); - if (!box || box.getAttribute('data-loaded') === '1') return; - var cid = box.getAttribute('data-cid'); - box.setAttribute('data-loaded', '1'); - fetch(ENDPOINT + (ENDPOINT.indexOf('?') === -1 ? '?' : '&') + 'ajax=1&job=album_images&cid=' + encodeURIComponent(cid), { credentials: 'same-origin' }) - .then(function (r) { return r.text(); }) - .then(function (html) { box.innerHTML = html; Albums.bind(); }) - .catch(function () { box.setAttribute('data-loaded', '0'); box.innerHTML = '
加载失败,请重试
'; }); + if (!box || box.getAttribute('data-loaded') === '1' || box.__ppLoading) return; + Albums.loadPage(box); + } + d.addEventListener('toggle', load); + if (d.open) load(); + }); + $$('[data-image-page]').forEach(function (link) { + if (link.__ppPageBound) return; + link.__ppPageBound = true; + link.addEventListener('click', function (event) { + event.preventDefault(); + var box = link.closest('.pp-thumbs'); + Albums.loadPage(box, { image_page: link.getAttribute('data-image-page'), image_missing: link.getAttribute('data-image-missing') || '', image_all: link.getAttribute('data-image-all') || '0' }); + }); + }); + $$('[data-append-cid]').forEach(function (button) { + if (button.__ppAppendBound) return; + button.__ppAppendBound = true; + button.addEventListener('click', function (e) { + e.preventDefault(); Upload.append(button.getAttribute('data-append-cid'), button.getAttribute('data-append-title')); }); }); $$('.pp-thumbs').forEach(function (box) { @@ -303,6 +507,7 @@ }); }, bindSort: function (box) { + if (box.querySelector('[data-album-sortable="0"]')) return; var dragging = null; $$('.pp-img', box).forEach(function (card) { card.draggable = false; @@ -397,19 +602,79 @@ } /* ---------- 维护任务 ---------- */ - function runJob(job) { + var runningJob = ''; + var pendingJobs = {}; + var loadingFailures = false; + function maintenanceBusy(busy) { + $$('[data-run]').forEach(function (button) { button.disabled = busy; }); + var retry = $('#pp-retry-failures'); + if (retry) retry.disabled = busy || !JOB || !JOB.finished || !JOB.failure_total; + var prev = $('#pp-failure-prev'), next = $('#pp-failure-next'); + if (prev) prev.disabled = busy || loadingFailures || !JOB || !(JOB.failure_offset > 0); + if (next) next.disabled = busy || loadingFailures || !JOB || JOB.failure_next == null; + } + function renderFailures(state) { + var panel = $('#pp-maintenance-failures'); + var list = $('#pp-failure-list'); + var summary = $('#pp-failure-summary'); + if (!panel || !list || !summary) return; + var failures = state && state.failures || []; + var total = state && state.failure_total || 0; + var unlisted = state && state.unlisted_failed || 0; + panel.hidden = !(total || unlisted); + list.textContent = ''; + list.start = (state && state.failure_offset || 0) + 1; + failures.forEach(function (failure) { + var row = document.createElement('li'); + var identity = failure.task === 'resync' ? '相册 ID ' + failure.id : '图片 ID ' + failure.id + (failure.cid > 0 ? ' / 相册 ID ' + failure.cid : ''); + row.textContent = identity + ':' + failure.reason; + list.appendChild(row); + }); + summary.textContent = total ? ('共 ' + total + ' 项失败,显示 ' + (list.start) + '–' + ((state.failure_offset || 0) + failures.length) + ' 项。') : ''; + if (unlisted) summary.textContent += ' 另有 ' + unlisted + ' 项来自旧版任务,未保存明细,无法单独重试。'; + var retry = $('#pp-retry-failures'); + if (retry) retry.textContent = unlisted ? '重试有明细的失败项' : '仅重试失败项'; + maintenanceBusy(!!runningJob); + } + function failurePage(offset) { + if (runningJob || loadingFailures || !JOB || !JOB.job_id) return; + loadingFailures = true; + maintenanceBusy(false); + var id = JOB.job_id; + post('maintenance', { job: JOB.job, mode: 'errors', job_id: id, offset: offset }) + .then(function (data) { + if (!data || data.ok === false) throw new Error((data && data.msg) || '无法读取失败清单'); + if (JOB && JOB.job_id === id && data.job_id === id) { JOB = data; renderFailures(JOB); } + }) + .catch(function (error) { + if (JOB && JOB.job_id === id) { var summary = $('#pp-failure-summary'); if (summary) summary.textContent = error.message; } + }) + .then(function () { loadingFailures = false; maintenanceBusy(!!runningJob); }); + } + function runJob(job, requestedMode) { + if (runningJob) return; + runningJob = job; var bar = $('#pp-bar-' + job); var msg = $('#pp-msg-' + job); - var btn = document.querySelector('[data-run="' + job + '"]'); - if (btn) btn.disabled = true; - if (bar) bar.style.width = '0%'; // 每次开始先归零,避免上一次的 100% 残留 - if (msg) msg.textContent = '准备中…'; - var url = ENDPOINT + (ENDPOINT.indexOf('?') === -1 ? '?' : '&') + 'ajax=1&job=' + job + '&_=' + encodeURIComponent(TOKEN); + var mode = requestedMode === true ? 'resume' : (requestedMode || 'start'); + var jobId = mode !== 'start' && JOB ? (JOB.job_id || '') : ''; + // 请求响应丢失后沿用原始身份,尤其不能把完成的定向重试当作一次全库 start。 + if (pendingJobs[job]) { mode = pendingJobs[job].mode; jobId = pendingJobs[job].id; } + else if (mode === 'start' && JOB && JOB.job === job && !JOB.finished) { mode = 'resume'; jobId = JOB.job_id || ''; } + maintenanceBusy(true); + if (bar) bar.style.width = '0%'; + if (msg) msg.textContent = mode === 'retry' ? '准备重试失败项…' : '准备中…'; + function stop() { runningJob = ''; maintenanceBusy(false); } function tick() { - fetch(url, { credentials: 'same-origin' }) - .then(function (r) { return r.json(); }) + pendingJobs[job] = { mode: mode, id: jobId }; + post('maintenance', { job: job, mode: mode, job_id: jobId }) .then(function (d) { - if (d.msg && d.finished) { if (msg) msg.textContent = d.msg; if (btn) btn.disabled = false; return; } + if (!d || d.ok === false) throw new Error((d && d.msg) || '出错,请重试'); + if (d.msg && d.finished) { if (msg) msg.textContent = d.msg; stop(); return; } + jobId = d.job_id || jobId; + mode = 'poll'; + JOB = d; + renderFailures(d); var pct = d.total ? Math.round(d.done * 100 / d.total) : 100; if (bar) bar.style.width = pct + '%'; if (d.total > 0) { @@ -419,12 +684,13 @@ if (msg) msg.textContent = noJob[job] || '没有需要处理的项目'; } if (!d.finished) { setTimeout(tick, 300); return; } - if (msg) msg.textContent += d.failed > 0 ? (' ✓ 完成(' + d.failed + ' 张失败,请查看日志)') : ' ✓ 完成'; - if (btn) btn.disabled = false; + if (msg) msg.textContent += d.failed > 0 ? (' ✓ 完成(' + d.failed + ' 项失败,请查看下方清单)') : ' ✓ 完成'; + delete pendingJobs[job]; + stop(); }) - .catch(function () { - if (msg) msg.textContent = '出错,请重试'; - if (btn) btn.disabled = false; + .catch(function (err) { + if (msg) msg.textContent = err.message || '出错,请重试'; + stop(); }); } tick(); @@ -527,6 +793,7 @@ if (h2 && h2.nextSibling) card.insertBefore(empty, h2.nextSibling); else card.appendChild(empty); } notice((d && d.msg) ? d.msg : '已删除', 'success'); + refreshAlbums(); } else { if (btn) { btn.disabled = false; if (btn.dataset.loading) btn.textContent = '删除'; } notice((d && d.msg) ? d.msg : '删除失败', 'error'); @@ -543,16 +810,24 @@ if (!form || form.id === 'pp-upload-form' || form.classList.contains('pp-delete-album')) return; var actEl = form.querySelector('input[name="action"]'); var act = actEl ? actEl.value : ''; - var actions = ['save_site', 'save_contacts', 'save_settings', 'update_album', 'set_image_meta', 'delete_image']; + var actions = ['save_site', 'save_contacts', 'save_settings', 'update_album', 'set_image_meta', 'delete_image', 'publish_album', 'batch_image_meta', 'set_album_cover']; if (actions.indexOf(act) === -1) return; + if (act === 'publish_album' && !CAN_PUBLISH) { e.preventDefault(); notice('发布图集需编辑或管理员权限。', 'error'); return; } + if (act === 'publish_album' && Upload.running) { e.preventDefault(); notice('请等待当前上传完成后再发布。', 'error'); return; } + if (form.__ppSaving) { e.preventDefault(); return; } if (act === 'delete_image' && !confirm('删除这张图片及其文件?')) { e.preventDefault(); return; } e.preventDefault(); + form.__ppSaving = true; var btn = form.querySelector('button[type="submit"]'); if (btn) btn.disabled = true; var fd = new FormData(form); post('', fd).then(function (d) { if (d && d.ok) { - if (act === 'delete_image') { + if (act === 'batch_image_meta' || act === 'set_album_cover') { + Albums.loadPage(form.closest('.pp-thumbs')); + } else if (act === 'publish_album') { + refreshAlbums(); + } else if (act === 'delete_image') { var pic = form.closest('.pp-img'); if (pic && pic.parentNode) pic.parentNode.removeChild(pic); } else if (act === 'update_album') { @@ -564,11 +839,21 @@ } notice((d && d.msg) ? d.msg : '已保存', (d && d.ok) ? 'success' : 'error'); if (btn) btn.disabled = false; - }).catch(function () { notice('网络/保存出错,请重试', 'error'); if (btn) btn.disabled = false; }); + form.__ppSaving = false; + }).catch(function () { notice('网络/保存出错,请重试', 'error'); if (btn) btn.disabled = false; form.__ppSaving = false; }); }); - // 上次维护任务未完成:提示可继续 - if (JOB && JOB.job && JOB.total > 0 && !JOB.finished) { + renderFailures(JOB); + var retryFailures = $('#pp-retry-failures'); + if (retryFailures) retryFailures.addEventListener('click', function () { + if (JOB && JOB.finished && JOB.failure_total > 0) runJob(JOB.job, 'retry'); + }); + var previousFailures = $('#pp-failure-prev'), nextFailures = $('#pp-failure-next'); + if (previousFailures) previousFailures.addEventListener('click', function () { if (JOB) failurePage(Math.max(0, (JOB.failure_offset || 0) - 50)); }); + if (nextFailures) nextFailures.addEventListener('click', function () { if (JOB && JOB.failure_next != null) failurePage(JOB.failure_next); }); + + // 上次维护任务未完成:提示可继续(包括总数为零、尚未保存终态的任务)。 + if (JOB && JOB.job && !JOB.finished) { var names = { rebuild: '重建缩略图/全图', cleanup: '清理孤儿文件', resync: '重建尺寸字段' }; var tip = document.createElement('div'); tip.className = 'notice success'; @@ -579,8 +864,9 @@ if (container) container.insertBefore(tip, container.firstElementChild); tip.querySelector('button').addEventListener('click', function () { tip.parentNode.removeChild(tip); - runJob(JOB.job); + runJob(JOB.job, true); }); } }); })(); + diff --git a/usr/plugins/InfinityTime/health-panel.php b/usr/plugins/InfinityTime/health-panel.php new file mode 100644 index 0000000..49e9de1 --- /dev/null +++ b/usr/plugins/InfinityTime/health-panel.php @@ -0,0 +1,26 @@ +pass('administrator', true)) { return; } +require_once __DIR__ . '/Lib/HealthDiagnostics.php'; +$ppHealth = \TypechoPlugin\InfinityTime\Lib\HealthDiagnostics::collect($db, \TypechoPlugin\InfinityTime\Lib\ImageRepository::uploadRoot(), $tools); +$ppHealthLabels = ['ok' => '已检查', 'warning' => '需处理', 'manual' => '需人工核对']; +?> +
+

只读健康检查

+

刷新后台时读取当前环境,不写测试文件、不修改数据库、不探测外部地址。以下结果不代替真实图片上传与备份恢复演练。

+
    + +
  • · +

    +
  • + +
+
+ 在隔离环境做一次恢复演练 +
    +
  1. 记录当前主题、插件和 Typecho 版本,安排一致备份窗口;同时备份数据库、上传目录及受保护的站点配置。备份放在网站公开目录之外。
  2. +
  3. 将备份恢复到隔离副本,使用副本专用数据库和上传目录。暂停该副本的邮件、通知、计划任务及任何外部写入,避免影响正式站点。
  4. +
  5. 核对相册与照片数量、草稿权限、排序和中文说明;抽查普通图、全景图、缩略图及原图访问保护。用无敏感样本测试上传、编辑和重建。
  6. +
  7. 记录备份时间、恢复耗时、缺失项及验证结果;确认能够恢复后再清理过期备份。这里不会自动恢复或覆盖正式数据。
  8. +
+
+
diff --git a/usr/plugins/InfinityTime/panel.php b/usr/plugins/InfinityTime/panel.php index ff00be8..15476c0 100644 --- a/usr/plugins/InfinityTime/panel.php +++ b/usr/plugins/InfinityTime/panel.php @@ -13,6 +13,12 @@ use TypechoPlugin\InfinityTime\Plugin; use TypechoPlugin\InfinityTime\Lib\ImageRepository; use TypechoPlugin\InfinityTime\Lib\MediaProcessor; +use TypechoPlugin\InfinityTime\Lib\AdminRepository; +use TypechoPlugin\InfinityTime\Lib\AdminSecurity; +use TypechoPlugin\InfinityTime\Lib\AdminWorkflow; +use TypechoPlugin\InfinityTime\Lib\MaintenanceState; +use TypechoPlugin\InfinityTime\Lib\Database; +require_once __DIR__ . '/Lib/MaintenanceState.php'; $db = Db::get(); $user = User::alloc(); @@ -25,7 +31,7 @@ /* ---------------------------------- 工具函数 ---------------------------------- */ -function pp_reply(string $msg = '', string $type = 'success'): void +function pp_reply(string $msg = '', string $type = 'success', int $cid = 0): void { $options = Helper::options(); $url = Helper::url('InfinityTime/panel.php'); @@ -33,16 +39,19 @@ function pp_reply(string $msg = '', string $type = 'success'): void $sep = strpos($url, '?') === false ? '?' : '&'; $url .= $sep . 'notice=' . urlencode($msg) . '¬iceType=' . $type; } + if ($cid > 0) { $url .= (strpos($url, '?') === false ? '?' : '&') . 'append=' . $cid . '#upload'; } \Typecho\Response::getInstance()->setStatus(302); @header('Location: ' . $url); exit; } /** 供 AJAX 使用的 JSON 响应。 */ -function pp_reply_json(bool $ok, string $msg): void +function pp_reply_json(bool $ok, string $msg, array $data = [], int $status = 200): void { + http_response_code($status); header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['ok' => $ok, 'msg' => $msg], JSON_UNESCAPED_UNICODE); + header('Cache-Control: no-store'); + echo json_encode(['ok' => $ok, 'msg' => $msg] + $data, JSON_UNESCAPED_UNICODE); exit; } @@ -53,11 +62,17 @@ function pp_is_admin(): bool return $user->pass('administrator', true); } +function pp_can_publish(): bool +{ + global $user; + return $user->pass('editor', true); +} + /** 统一权限拒绝:AJAX 返回 JSON,普通请求跳回面板提示。 */ function pp_deny(string $msg): void { if (!empty($_POST['ajax']) || !empty($_GET['ajax'])) { - pp_reply_json(false, _t($msg)); + pp_reply_json(false, _t($msg), [], 403); } pp_reply(_t($msg), 'error'); } @@ -70,18 +85,12 @@ function pp_require_admin(): void } } -/** 当前用户能否编辑指定图集:管理员,或该图集的作者本人。 */ +/** 管理员与作者都必须指向真实的 InfinityTime 图集。 */ function pp_can_edit_cid(int $cid): bool { - global $user, $db, $prefix; - if ($cid <= 0) { - return false; - } - if (pp_is_admin()) { - return true; - } - $r = $db->fetchRow($db->select('authorId')->from($prefix . 'contents')->where('cid = ?', $cid)->limit(1)); - return $r && (int)$r['authorId'] === (int)$user->uid; + global $user; + $album = AdminRepository::album($cid, (int)$user->uid, pp_is_admin(), ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST'); + return $album !== null && ($album['status'] === 'draft' || pp_can_publish()); } /** 后台 CSRF token(绑定当前登录用户,同一会话内稳定)。 */ @@ -94,21 +103,21 @@ function pp_csrf_token(): string } } -/** 校验 CSRF:优先比对 token;缺失时回退到“非空且同源 referer”。 */ +/** 严格校验,不回退到 Referer;原生表单及维护任务同样适用。 */ function pp_csrf_check(): bool { - $t = pp_csrf_token(); - if ($t !== '' && isset($_POST['_']) && hash_equals($t, (string)$_POST['_'])) { - return true; - } - // AJAX 请求必须带有效 token(前端统一附带);token 不可用/原生提交时回退到同源 referer - if (!empty($_POST['ajax']) && $t !== '') { - return false; - } - $ref = (string)($_SERVER['HTTP_REFERER'] ?? ''); - $reqHost = (string)($_SERVER['HTTP_HOST'] ?? ''); - $refHost = $ref !== '' ? (string)parse_url($ref, PHP_URL_HOST) : ''; - return $refHost !== '' && strcasecmp($refHost, $reqHost) === 0; + return AdminSecurity::validMutation((string)($_SERVER['REQUEST_METHOD'] ?? ''), pp_csrf_token(), $_POST['_'] ?? null); +} + +function pp_csrf_input(): string +{ + return ''; +} + +function pp_panel_url(array $query = []): string +{ + $url = Helper::url('InfinityTime/panel.php'); + return $url . ($query ? (strpos($url, '?') === false ? '?' : '&') . http_build_query($query) : ''); } /** 清洗“关于介绍”里的 HTML:保留常规排版标签,去掉脚本/事件/危险协议。 */ @@ -185,14 +194,7 @@ function pp_clear_site_avatar(string $url, ?string $keepAbs = null): void function pp_set_field(int $cid, string $name, string $value): void { - $db = Db::get(); - $prefix = $db->getPrefix(); - $db->query($db->delete($prefix . 'fields')->where('cid = ?', $cid)->where('name = ?', $name)); - if ($value !== '') { - $db->query($db->insert($prefix . 'fields')->rows([ - 'cid' => $cid, 'name' => $name, 'type' => 'str', 'str_value' => $value, - ])); - } + AdminRepository::setField($cid, $name, $value); } function pp_data_file(): string @@ -206,15 +208,12 @@ function pp_data_file(): string function pp_read_json(string $file): array { - return is_file($file) ? (json_decode((string)@file_get_contents($file), true) ?: []) : []; + return MaintenanceState::read($file); } function pp_write_json(string $file, array $data): void { - $tmp = $file . '.tmp.' . getmypid(); - if (@file_put_contents($tmp, json_encode($data, JSON_UNESCAPED_UNICODE)) !== false) { - @rename($tmp, $file); - } + MaintenanceState::write($file, $data); } /** 清理 original/full/thumb 下因删除文件而空出的目录(自底向上)。 */ @@ -265,62 +264,61 @@ function pp_exif_summary(array $exif): string /* ---------------------------------- AJAX 进度 ---------------------------------- */ -if (!empty($_GET['ajax'])) { - $job = (string)($_GET['job'] ?? ''); - // 写操作类 job 需要 CSRF token:否则可被 之类的 GET 请求触发 - if (in_array($job, ['rebuild', 'cleanup', 'resync'], true)) { - // 维护任务会批量改动全站文件:仅管理员 - if (!$user->pass('administrator', true)) { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['finished' => true, 'total' => 0, 'done' => 0, 'current' => '', 'failed' => 0, 'msg' => '需要管理员权限'], JSON_UNESCAPED_UNICODE); - exit; - } - $__t = pp_csrf_token(); - $__ok = $__t !== '' && isset($_GET['_']) && hash_equals($__t, (string)$_GET['_']); - if (!$__ok && $__t === '') { - // token 不可用时回退到同源 referer(GET 由前端同源 fetch 发起) - $__ref = (string)($_SERVER['HTTP_REFERER'] ?? ''); - $__refHost = $__ref !== '' ? (string)parse_url($__ref, PHP_URL_HOST) : ''; - $__ok = $__refHost !== '' && strcasecmp($__refHost, (string)($_SERVER['HTTP_HOST'] ?? '')) === 0; - } - if (!$__ok) { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['finished' => true, 'total' => 0, 'done' => 0, 'current' => '', 'failed' => 0, 'msg' => '安全校验失败,请刷新后台页面后重试'], JSON_UNESCAPED_UNICODE); - exit; - } +$ppMethod = (string)($_SERVER['REQUEST_METHOD'] ?? 'GET'); +$ppAction = is_string($_POST['action'] ?? null) ? $_POST['action'] : ''; +$ppMaintenance = $ppMethod === 'POST' && $ppAction === 'maintenance'; +if ($ppMethod === 'POST') { + if (empty($_POST) && empty($_FILES) && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) { + pp_reply_json(false, '没有收到表单内容,可能超过服务器 post_max_size 限制。请减少单次上传大小后重试', [], 413); + } + if (!pp_csrf_check()) { + pp_reply_json(false, _t('安全校验失败,请刷新后台页面后重试'), [], 403); + } + // 锁覆盖权限检查、数据库及文件写入,避免并发丢失更新。 + try { ImageRepository::lockMedia(); } catch (\Throwable $e) { + pp_reply_json(false, $e->getMessage(), ['retryable' => true, 'finished' => true], 409); + } +} +if (!$ppMaintenance && in_array((string)($_GET['job'] ?? ''), ['rebuild', 'cleanup', 'resync'], true)) { + header('Allow: POST'); + pp_reply_json(false, '维护任务只接受 POST 请求', ['finished' => true], 405); +} + +if (!empty($_GET['ajax']) || $ppMaintenance) { + header('Cache-Control: no-store'); + $job = $ppMaintenance ? (string)($_POST['job'] ?? '') : (string)($_GET['job'] ?? ''); + if ($ppMaintenance && !in_array($job, ['rebuild', 'cleanup', 'resync'], true)) { + pp_reply_json(false, '未知维护任务', ['finished' => true], 400); + } + if ($ppMaintenance) { + pp_require_admin(); // 简单并发锁:同一时间只允许一个维护任务(不同管理员 90 秒内不能抢跑) $__uid = (int)($user->uid ?? 0); $__lockFile = pp_data_file() . '/job.lock'; $__lock = is_file($__lockFile) ? (json_decode((string)@file_get_contents($__lockFile), true) ?: []) : []; - if ($__lock && (time() - (int)($__lock['time'] ?? 0)) < 90 && (int)($__lock['uid'] ?? 0) !== $__uid) { + if ($__lock && (time() - (int)($__lock['time'] ?? 0)) < 90 && ((int)($__lock['uid'] ?? 0) !== $__uid || (string)($__lock['job'] ?? '') !== $job)) { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['finished' => true, 'total' => 0, 'done' => 0, 'current' => '', 'failed' => 0, 'msg' => '另一个维护任务正在进行,请稍后再试'], JSON_UNESCAPED_UNICODE); exit; } - @file_put_contents($__lockFile, json_encode(['uid' => $__uid, 'job' => $job, 'time' => time()])); + } set_time_limit(60); $result = ['finished' => true, 'total' => 0, 'done' => 0, 'current' => '']; + try { + if ($ppMaintenance && ($_POST['mode'] ?? '') === 'errors') { + [$state] = MaintenanceState::open(pp_data_file(), $job, 'errors', (string)($_POST['job_id'] ?? ''), static function () { return []; }); + pp_reply_json(true, '', MaintenanceState::response($state, max(0, (int)($_POST['offset'] ?? 0)))); + } if ($job === 'rebuild') { - $listFile = pp_data_file() . '/rebuild_list.json'; - $jobFile = pp_data_file() . '/job.json'; - $state = pp_read_json($jobFile); - if (($state['job'] ?? '') !== 'rebuild' || !file_exists($listFile)) { - $rows = $db->fetchAll($db->select()->from(ImageRepository::table())); - $list = []; - foreach ($rows as $r) { - if (!empty($r['original'])) { - $list[] = [$r['id'], $r['original'], $r['full'], $r['thumb'], (int)($r['cid'] ?? 0)]; - } - } - pp_write_json($listFile, $list); - pp_write_json($jobFile, ['job' => 'rebuild', 'total' => count($list), 'done' => 0, 'current' => '', 'failed' => 0]); - } - $list = pp_read_json($listFile); - $state = pp_read_json($jobFile); + [$state, $list] = MaintenanceState::open(pp_data_file(), $job, (string)($_POST['mode'] ?? 'start'), (string)($_POST['job_id'] ?? ''), static function () use ($db) { + $rows = AdminRepository::readAll($db->select('id')->from(ImageRepository::table())->where('original <> ?', ''), true); + return array_map(static function ($row) { return (int)$row['id']; }, $rows); + }); + if (!empty($state['finished'])) { pp_reply_json(true, '', MaintenanceState::response($state)); } + pp_write_json($__lockFile, ['uid' => $__uid, 'job' => $job, 'time' => time()]); $idx = (int)($state['done'] ?? 0); - $failed = (int)($state['failed'] ?? 0); $batch = 3; $quality = (int)Plugin::opt('infinitytimeQuality', Plugin::DEFAULT_QUALITY); $thumbMax = (int)Plugin::opt('infinitytimeThumbMax', Plugin::DEFAULT_THUMB_MAX); @@ -337,116 +335,97 @@ function pp_exif_summary(array $exif): string if ((microtime(true) - $started) > $budget) { break; } - $item = $list[$idx]; - $src = ImageRepository::toAbs($item[1]); - if (is_file($src)) { - $mw = $maxWidth; - $fq = $fullQuality; - $__info = @getimagesize($src); - if (is_array($__info) && ($__info[0] ?? 0) > 0 && ($__info[1] ?? 0) > 0 - && ImageRepository::isPano((int)$__info[0], (int)$__info[1])) { - $mw = $panoWidth > 0 ? (int)min((int)$__info[0], $panoWidth) : 0; // 全景:独立宽度,0=不裁剪 - $fq = $panoQuality; - } - try { - $res = MediaProcessor::process($src, ImageRepository::toAbs($item[2]), ImageRepository::toAbs($item[3]), $thumbMax, $quality, $mw, $fq); - ImageRepository::updateVariants((int)$item[0], $res); - if (!empty($item[4])) { - $syncedCids[(int)$item[4]] = true; + $snapshot = $list[$idx]; + // 字段失败的重试只同步该图集,绝不重复图片编码。 + if (is_array($snapshot) && ($snapshot['task'] ?? '') === 'resync') { + $syncedCids[(int)$snapshot['id']] = true; + $idx++; + continue; + } + $rowId = is_array($snapshot) ? (int)($snapshot['id'] ?? $snapshot[0] ?? 0) : (int)$snapshot; + $cid = 0; + $stage = 'image_record'; + $state['current'] = '图片 ID ' . $rowId; + try { + // 快照只决定候选身份;路径与归属在每批主库重读,避免删除/改绑后重建旧记录。 + $live = AdminRepository::readRow($db->select()->from(ImageRepository::table())->where('id = ?', $rowId)->limit(1), true); + if (!$live) { MaintenanceState::clearFailure($state, 'rebuild', $rowId); $idx++; continue; } + $cid = (int)($live['cid'] ?? 0); + $src = ImageRepository::toAbs((string)$live['original']); + if (empty($live['original']) || !is_file($src)) { + MaintenanceState::failure($state, 'rebuild', $rowId, $cid, 'missing_original'); + $state['current'] .= '(缺原图)'; + } else { + $mw = $maxWidth; + $fq = $fullQuality; + $__info = @getimagesize($src); + if (is_array($__info) && ($__info[0] ?? 0) > 0 && ($__info[1] ?? 0) > 0 + && ImageRepository::isPano((int)$__info[0], (int)$__info[1])) { + $mw = $panoWidth > 0 ? (int)min((int)$__info[0], $panoWidth) : 0; + $fq = $panoQuality; } - } catch (\Throwable $e) { - Plugin::log('rebuild ajax: id=' . $item[0] . ' ' . $e->getMessage()); - $failed++; + $stage = 'conversion'; + $res = MediaProcessor::process($src, ImageRepository::toAbs((string)$live['full']), ImageRepository::toAbs((string)$live['thumb']), $thumbMax, $quality, $mw, $fq); + $stage = 'image_record'; + ImageRepository::updateVariants($rowId, $res); + MaintenanceState::clearFailure($state, 'rebuild', $rowId); + if ($cid > 0) { $syncedCids[$cid] = true; } } - $state['current'] = basename($src); - } else { - // 原图缺失:无法重建,计为失败(不要静默跳过) - $failed++; - $state['current'] = basename($src) . '(缺原图)'; + } catch (\Throwable $e) { + Plugin::log('rebuild ajax: id=' . $rowId . ' ' . $e->getMessage()); + MaintenanceState::failure($state, 'rebuild', $rowId, $cid, $stage); } $idx++; } foreach (array_keys($syncedCids) as $__c) { - try { ImageRepository::syncPostFields((int)$__c); } catch (\Throwable $e) {} + try { + ImageRepository::syncPostFields((int)$__c); + MaintenanceState::clearFailure($state, 'resync', (int)$__c); + } catch (\Throwable $e) { + Plugin::log('rebuild sync: cid=' . $__c . ' ' . $e->getMessage()); + MaintenanceState::failure($state, 'resync', (int)$__c, (int)$__c, 'field_sync'); + $state['current'] = 'cid ' . $__c . '(字段同步失败,可重建字段重试)'; + } } $state['done'] = $idx; - $state['failed'] = $failed; $state['finished'] = $idx >= $total; - if ($state['finished']) { - $state = ['job' => 'rebuild', 'total' => 0, 'done' => 0, 'current' => '', 'finished' => true, 'failed' => $failed]; - } - pp_write_json($jobFile, $state); - $result = ['finished' => $idx >= $total, 'total' => $total, 'done' => $idx, 'current' => $state['current'], 'failed' => $failed]; + MaintenanceState::save(pp_data_file(), $state); + $result = MaintenanceState::response($state); } elseif ($job === 'cleanup') { - $listFile = pp_data_file() . '/cleanup_list.json'; - $jobFile = pp_data_file() . '/job.json'; - $state = pp_read_json($jobFile); - if (($state['job'] ?? '') !== 'cleanup' || !file_exists($listFile)) { - $rows = $db->fetchAll($db->select()->from(ImageRepository::table())); - $ref = []; - foreach ($rows as $r) { - foreach (['original', 'full', 'mid', 'avif', 'mid_avif', 'thumb'] as $k) { - if (!empty($r[$k])) { - $ref[ImageRepository::toAbs($r[$k])] = true; - } - } - } - $list = []; - foreach (ImageRepository::defaultDirs() as $type => $webDir) { - $abs = ImageRepository::toAbs($webDir); - if (!is_dir($abs)) { - continue; - } - $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS)); - foreach ($it as $file) { - if ($file->isFile() && !isset($ref[$file->getPathname()])) { - $list[] = $file->getPathname(); - } - } - } - pp_write_json($listFile, $list); - pp_write_json($jobFile, ['job' => 'cleanup', 'total' => count($list), 'done' => 0, 'current' => '']); - } - $list = pp_read_json($listFile); - $state = pp_read_json($jobFile); + [$state, $list] = MaintenanceState::open(pp_data_file(), $job, (string)($_POST['mode'] ?? 'start'), (string)($_POST['job_id'] ?? ''), static function () { + return ImageRepository::orphanCandidates(); + }); + if (!empty($state['finished'])) { pp_reply_json(true, '', MaintenanceState::response($state)); } + pp_write_json($__lockFile, ['uid' => $__uid, 'job' => $job, 'time' => time()]); $idx = (int)($state['done'] ?? 0); $batch = 50; + ImageRepository::removeOrphanCandidates(array_slice($list, $idx, $batch)); for ($i = 0; $i < $batch && $idx < count($list); $i++) { - @unlink($list[$idx]); - $state['current'] = basename($list[$idx]); + $state['current'] = basename(is_array($list[$idx]) ? (string)($list[$idx]['path'] ?? '') : (string)$list[$idx]); $idx++; } $state['done'] = $idx; $state['finished'] = $idx >= count($list); - if ($state['finished']) { - pp_prune_empty_dirs(); - $state = ['job' => 'cleanup', 'total' => 0, 'done' => 0, 'current' => '', 'finished' => true]; - } - pp_write_json($jobFile, $state); - $result = ['finished' => $idx >= count($list), 'total' => count($list), 'done' => $idx, 'current' => $state['current']]; + MaintenanceState::save(pp_data_file(), $state); + $result = MaintenanceState::response($state); } elseif ($job === 'resync') { // 重算每篇图集的聚合字段(addresses/titles/descs/panos,并补上 dims 宽高数组)。 // 用于给已发布的历史文章补齐文章字段,使首页瀑布流能拿到图片比例做占位。 - $listFile = pp_data_file() . '/resync_list.json'; - $jobFile = pp_data_file() . '/job.json'; - $state = pp_read_json($jobFile); - if (($state['job'] ?? '') !== 'resync' || !file_exists($listFile)) { + [$state, $list] = MaintenanceState::open(pp_data_file(), $job, (string)($_POST['mode'] ?? 'start'), (string)($_POST['job_id'] ?? ''), static function () use ($db, $prefix) { $cids = []; - foreach ($db->fetchAll($db->select('cid')->from(ImageRepository::table())) as $r) { + foreach (AdminRepository::readAll($db->select('cid')->from(ImageRepository::table()), true) as $r) { $cids[(int)$r['cid']] = true; } - foreach ($db->fetchAll($db->select('cid')->from($prefix . 'fields')->where('name = ?', 'img')) as $r) { + foreach (AdminRepository::readAll($db->select('cid')->from($prefix . 'fields')->where('name = ?', 'img'), true) as $r) { $cids[(int)$r['cid']] = true; } $list = array_values(array_filter(array_map('intval', array_keys($cids)))); sort($list); - pp_write_json($listFile, $list); - pp_write_json($jobFile, ['job' => 'resync', 'total' => count($list), 'done' => 0, 'current' => '']); - } - $list = pp_read_json($listFile); - $state = pp_read_json($jobFile); + return $list; + }); + if (!empty($state['finished'])) { pp_reply_json(true, '', MaintenanceState::response($state)); } + pp_write_json($__lockFile, ['uid' => $__uid, 'job' => $job, 'time' => time()]); $idx = (int)($state['done'] ?? 0); - $failed = (int)($state['failed'] ?? 0); $batch = 12; // 每次处理 12 篇,避免单次 AJAX 超时 $started = microtime(true); $budget = 20; @@ -455,206 +434,173 @@ function pp_exif_summary(array $exif): string if ((microtime(true) - $started) > $budget) { break; } - $cid = (int)($list[$idx] ?? 0); + $cid = is_array($list[$idx]) ? (int)($list[$idx]['id'] ?? 0) : (int)$list[$idx]; if ($cid > 0) { try { ImageRepository::syncPostFields($cid); + MaintenanceState::clearFailure($state, 'resync', $cid); $state['current'] = 'cid ' . $cid; } catch (\Throwable $e) { Plugin::log('resync ajax: cid=' . $cid . ' ' . $e->getMessage()); - $failed++; + MaintenanceState::failure($state, 'resync', $cid, $cid, 'field_sync'); $state['current'] = 'cid ' . $cid . '(失败)'; } } $idx++; } $state['done'] = $idx; - $state['failed'] = $failed; $state['finished'] = $idx >= $total; - if ($state['finished']) { - $state = ['job' => 'resync', 'total' => 0, 'done' => 0, 'current' => '', 'finished' => true, 'failed' => $failed]; - @unlink($listFile); - } - pp_write_json($jobFile, $state); - $result = ['finished' => $idx >= $total, 'total' => $total, 'done' => $idx, 'current' => $state['current'], 'failed' => $failed]; + MaintenanceState::save(pp_data_file(), $state); + $result = MaintenanceState::response($state); } elseif ($job === 'albums_html') { // 局部刷新图集列表:只返回卡片 HTML,避免为刷新列表重新渲染整个后台页面 header('Content-Type: text/html; charset=utf-8'); - echo pp_render_albums_card(pp_albums($prefix, pp_is_admin() ? 0 : (int)$user->uid), $options); + echo pp_render_albums_card(pp_albums(), $options); exit; + } elseif ($job === 'album_status') { + $cid = (int)($_GET['cid'] ?? 0); + $album = AdminRepository::album($cid, (int)$user->uid, pp_is_admin()); + if (!$album) { pp_reply_json(false, '图集不存在或没有权限', [], 404); } + $counts = AdminRepository::counts([$cid]); + pp_reply_json(true, '', ['cid' => $cid, 'title' => (string)$album['title'], 'status' => $album['status'], 'count' => $counts[$cid] ?? 0]); } elseif ($job === 'album_images') { // 图集图片按需加载:展开某个图集时才拉取它的图片列表 header('Content-Type: text/html; charset=utf-8'); $cid = (int)($_GET['cid'] ?? 0); - if (!pp_can_edit_cid($cid)) { - echo '
没有权限查看该图集
'; + if (!AdminRepository::album($cid, (int)$user->uid, pp_is_admin())) { + http_response_code(403); + echo '
图集不存在或没有权限查看
'; exit; } - echo pp_render_album_thumbs($cid > 0 ? ImageRepository::rowsFor($cid) : []); + echo pp_render_album_page($cid, pp_can_edit_cid($cid)); exit; } + } catch (\Throwable $e) { + Plugin::log('maintenance: ' . $e->getMessage()); + $safeMessage = $e instanceof \TypechoPlugin\InfinityTime\Lib\MaintenanceException ? $e->getMessage() : '维护请求未完成,请刷新核对任务状态并检查服务器日志'; + pp_reply_json(false, $safeMessage, ['retryable' => true], 500); + } + + if ($ppMaintenance && !empty($result['finished'])) { @unlink(pp_data_file() . '/job.lock'); } + if ($ppMaintenance && empty($_POST['ajax'])) { + pp_reply('维护任务已处理 ' . (int)($result['done'] ?? 0) . ' / ' . (int)($result['total'] ?? 0) . (!empty($result['finished']) ? ',已完成' : ';再次提交可继续')); + } header('Content-Type: application/json'); - if (!empty($result['finished'])) { @unlink(pp_data_file() . '/job.lock'); } echo json_encode($result, JSON_UNESCAPED_UNICODE); exit; } /* ---------------------------------- POST 处理 ---------------------------------- */ -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - // CSRF:优先校验 Typecho token;旧表单/无 token 时至少要求“非空且同源”的 referer - if (!pp_csrf_check()) { - pp_reply_json(false, _t('安全校验失败,请刷新后台页面后重试')); - } - $action = (string)($_POST['action'] ?? ''); +if ($ppMethod === 'POST') { + $action = $ppAction; + $ajax = !empty($_POST['ajax']); - if ($action === 'create_album') { - $ajax = !empty($_POST['ajax']); - $title = trim((string)($_POST['title'] ?? '')); - $address = trim((string)($_POST['address'] ?? '')); - $device = trim((string)($_POST['device'] ?? '')); - $tags = trim((string)($_POST['tags'] ?? '')); - if ($title === '') { - if ($ajax) { pp_reply_json(false, _t('请填写图集标题')); } - pp_reply(_t('请填写图集标题'), 'error'); - } - // When post_max_size is exceeded PHP drops the entire $_FILES array. - // Report that explicitly instead of silently returning to the panel. - if (empty($_FILES['files']) && !empty($_SERVER['CONTENT_LENGTH'])) { - if ($ajax) { pp_reply_json(false, _t('上传内容超过服务器 post_max_size 限制,请调大 post_max_size 后重试')); } - pp_reply(_t('上传内容超过服务器 post_max_size 限制,请调大 post_max_size 后重试'), 'error'); - } - if (empty($_FILES['files']['name'][0]) || !is_array($_FILES['files']['name'])) { - if ($ajax) { pp_reply_json(false, _t('请至少选择一张图片')); } - pp_reply(_t('请至少选择一张图片'), 'error'); - } - - $now = time(); - $slug = 'album-' . date('YmdHis', $now) . '-' . random_int(100, 999); - $cid = (int)$db->query($db->insert($prefix . 'contents')->rows([ - 'title' => $title, 'slug' => $slug, 'created' => $now, 'modified' => $now, - 'text' => '', 'authorId' => $user->uid, 'type' => 'post', 'status' => 'publish', - 'allowComment' => '0', 'allowPing' => '0', 'allowFeed' => '0', 'template' => '', 'password' => '', - ])); - - $imgs = []; $thumbs = []; $exifs = []; $addrs = []; $titles = []; $descs = []; $panos = []; - $firstExif = null; $index = 0; $fail = 0; - $quality = (int)Plugin::opt('infinitytimeQuality', Plugin::DEFAULT_QUALITY); - $thumbMax = (int)Plugin::opt('infinitytimeThumbMax', Plugin::DEFAULT_THUMB_MAX); - $maxWidth = (int)Plugin::opt('infinitytimeMaxWidth', Plugin::DEFAULT_MAX_WIDTH); - $fullQuality = (int)Plugin::opt('infinitytimeFullQuality', Plugin::DEFAULT_FULL_QUALITY); - $keep = (bool)Plugin::opt('infinitytimeKeepOriginal', Plugin::DEFAULT_KEEP_ORIGINAL); - $panoWidth = (int)Plugin::opt('infinitytimePanoWidth', Plugin::DEFAULT_PANO_WIDTH); - $panoQuality = (int)Plugin::opt('infinitytimePanoQuality', Plugin::DEFAULT_PANO_QUALITY); - - $count = count($_FILES['files']['name']); - // 逐图标题/描述(与 files[] 同序,后端据此写入每张图的 title/desc) - $postTitles = (array)($_POST['img_titles'] ?? []); - $postDescs = (array)($_POST['img_descs'] ?? []); - $uploadErr = 0; - $oversize = 0; - $overpx = 0; - $maxFileBytes = 64 * 1024 * 1024; // 单文件 64MB 上限 - $maxPixels = 20000; // 单边 20000px 上限 - for ($i = 0; $i < $count; $i++) { - $fe = (int)($_FILES['files']['error'][$i] ?? UPLOAD_ERR_NO_FILE); - if ($fe !== UPLOAD_ERR_OK) { - if ($uploadErr === 0) { - $uploadErr = $fe; - } - $fail++; - continue; + if (in_array($action, ['create_draft', 'upload_image', 'publish_album', 'create_album'], true)) { + try { + if ($action === 'create_draft') { + $draft = AdminWorkflow::draft((int)$user->uid, $_POST, (string)($_POST['operation_key'] ?? '')); + pp_reply_json(true, '草稿已保存', $draft); } - // 前置校验:超大文件 / 超大像素直接跳过,避免拖垮 GD 内存 - if ((int)($_FILES['files']['size'][$i] ?? 0) > $maxFileBytes) { - $oversize++; - $fail++; - continue; + $cid = (int)($_POST['cid'] ?? 0); + if ($action === 'publish_album') { + AdminWorkflow::publish($cid, (int)$user->uid, pp_is_admin(), pp_can_publish()); + if ($ajax) { pp_reply_json(true, '图集已发布', ['cid' => $cid, 'status' => 'publish']); } + pp_reply('图集已发布'); } - $__dim = @getimagesize((string)($_FILES['files']['tmp_name'][$i] ?? '')); - if (is_array($__dim) && (($__dim[0] ?? 0) > $maxPixels || ($__dim[1] ?? 0) > $maxPixels)) { - $overpx++; - $fail++; - continue; + if ($action === 'upload_image') { + $result = AdminWorkflow::upload($cid, (int)$user->uid, pp_is_admin(), (array)($_FILES['file'] ?? []), $_POST, (string)($_POST['item_key'] ?? ''), pp_can_publish()); + pp_reply_json(true, !empty($result['replayed']) ? '图片已保存(已恢复上传结果)' : '图片已保存', $result); } - $meta = ImageRepository::ingest([ - 'name' => $_FILES['files']['name'][$i], - 'tmp_name' => $_FILES['files']['tmp_name'][$i], - 'size' => $_FILES['files']['size'][$i] ?? 0, - 'error' => $_FILES['files']['error'][$i] ?? UPLOAD_ERR_OK, - ], ['quality' => $quality, 'thumb_max' => $thumbMax, 'max_width' => $maxWidth, 'pano_width' => $panoWidth, 'pano_quality' => $panoQuality, 'full_quality' => $fullQuality, 'keep_original' => $keep, 'address' => $address]); - if (!$meta) { - $fail++; - continue; + // 无 JavaScript 的原生回退:沿用草稿与幂等规则,每次处理有上限的一批。 + $key = (string)($_POST['operation_key'] ?? ''); + if (!AdminSecurity::validKey($key)) { throw new \InvalidArgumentException('上传标识无效,请刷新页面'); } + if (empty($_FILES['files']['name']) || !is_array($_FILES['files']['name'])) { + throw new \InvalidArgumentException('没有收到图片,请检查上传限制并重新选择文件'); } - $rowId = ImageRepository::insertRow($cid, $meta, $index); - $imgTitle = isset($postTitles[$i]) ? trim((string)$postTitles[$i]) : ''; - $imgDesc = isset($postDescs[$i]) ? trim((string)$postDescs[$i]) : ''; - if ($imgTitle !== '' || $imgDesc !== '') { - ImageRepository::setImageMeta($rowId, $imgTitle, $imgDesc, (string)$address); + if ($cid > 0) { AdminWorkflow::requireAlbum($cid, (int)$user->uid, pp_is_admin(), pp_can_publish()); } + else { + $draft = AdminWorkflow::draft((int)$user->uid, $_POST, $key); + $cid = (int)$draft['cid']; } - $imgs[] = $meta['full']; $thumbs[] = $meta['thumb']; $exifs[] = $meta['exif']; $addrs[] = $address; - $titles[] = $imgTitle; $descs[] = $imgDesc; - $pw = (int)($meta['width'] ?? 0); $ph = (int)($meta['height'] ?? 0); - $panos[] = ImageRepository::isPano($pw, $ph) ? 1 : 0; - if ($firstExif === null) { - $firstExif = $meta['exif']; + $saved = 0; $failed = 0; $errors = []; + $count = count($_FILES['files']['name']); + for ($i = 0; $i < min(50, $count); $i++) { + $file = []; + foreach (['name', 'tmp_name', 'size', 'error'] as $field) { $file[$field] = $_FILES['files'][$field][$i] ?? null; } + try { + AdminWorkflow::upload($cid, (int)$user->uid, pp_is_admin(), $file, [ + 'title' => $_POST['img_titles'][$i] ?? '', 'desc' => $_POST['img_descs'][$i] ?? '', + 'address' => $_POST['address'] ?? '', + ], hash('sha256', $key . ':' . $i), pp_can_publish()); + $saved++; + } catch (\Throwable $e) { + $failed++; + $errors[] = (string)$file['name'] . ':' . $e->getMessage(); + } } - $index++; - } - - if (empty($imgs)) { - ImageRepository::removeFor($cid); - $db->query($db->delete($prefix . 'contents')->where('cid = ?', $cid)); - $reason = trim((string)(ImageRepository::$lastError ?? '')); - $msg = $uploadErr !== 0 - ? pp_upload_error($uploadErr) - : ($oversize > 0 - ? sprintf(_t('有 %d 张图片超过 64MB 上限,已跳过'), $oversize) - : ($overpx > 0 - ? sprintf(_t('有 %d 张图片单边超过 20000px 上限,已跳过'), $overpx) - : ($reason !== '' ? $reason : _t('没有图片成功入库(可能格式不支持或缺少转换工具)')))); - if ($ajax) { pp_reply_json(false, $msg); } - pp_reply($msg, 'error'); + $failed += max(0, $count - 50); + $msg = '图集 #' . $cid . ' 已保存 ' . $saved . ' 张图片。新图集保留为草稿,请在图集列表中确认发布。'; + if ($failed) { $msg .= '失败 ' . $failed . ' 张;成功项目已保留。' . implode(';', array_slice($errors, 0, 3)); } + if ($ajax) { pp_reply_json(!$failed, $msg, ['cid' => $cid, 'saved' => $saved, 'failed' => $failed]); } + pp_reply($msg, $failed ? 'error' : 'success', $cid); + } catch (\Throwable $e) { + Plugin::log('admin upload workflow: ' . $e->getMessage()); + if ($ajax) { pp_reply_json(false, $e->getMessage(), ['retryable' => $e instanceof \RuntimeException], $e instanceof \DomainException ? 403 : 400); } + pp_reply($e->getMessage(), 'error'); } + } - foreach ([ - 'img' => implode("\n", $imgs), - 'thumb' => implode("\n", $thumbs), - 'exif' => json_encode($exifs, JSON_UNESCAPED_UNICODE), - 'addresses' => json_encode($addrs, JSON_UNESCAPED_UNICODE), - 'titles' => json_encode($titles, JSON_UNESCAPED_UNICODE), - 'descs' => json_encode($descs, JSON_UNESCAPED_UNICODE), - 'panos' => json_encode($panos, JSON_UNESCAPED_UNICODE), - 'device' => $device !== '' ? $device : trim((string)($firstExif['make'] ?? '') . ' ' . (string)($firstExif['model'] ?? '')), - 'location' => $address, - ] as $name => $val) { - pp_set_field($cid, $name, $val); - } - if ($tags !== '') { - pp_set_field($cid, 'tags', $tags); + if ($action === 'update_album') { + $ajax = !empty($_POST['ajax']); + $cid = (int)($_POST['cid'] ?? 0); + if (!pp_can_edit_cid($cid)) { pp_deny('没有权限修改该图集'); } + try { + AdminWorkflow::updateAlbum($cid, (int)$user->uid, pp_is_admin(), pp_can_publish(), $_POST); + } catch (\Throwable $e) { + Plugin::log('update_album: ' . $e->getMessage()); + if ($ajax) { pp_reply_json(false, '图集信息暂未确认保存,请刷新核对后重试', ['retryable' => true], 500); } + pp_reply('图集信息暂未确认保存,请刷新核对后重试', 'error'); } - $msg = sprintf(_t('已发布图集「%s」,共 %d 张图片'), $title, count($imgs)); - if ($ajax) { pp_reply_json(true, $msg); } - pp_reply($msg); + if ($ajax) { pp_reply_json(true, _t('已更新图集信息')); } + pp_reply(_t('已更新图集信息')); } - if ($action === 'update_album') { + if (in_array($action, ['batch_image_meta', 'set_album_cover'], true)) { $ajax = !empty($_POST['ajax']); $cid = (int)($_POST['cid'] ?? 0); if (!pp_can_edit_cid($cid)) { pp_deny('没有权限修改该图集'); } - if ($cid > 0) { - $title = trim((string)($_POST['title'] ?? '')); - if ($title !== '') { - $db->query($db->update($prefix . 'contents')->rows(['title' => $title])->where('cid = ?', $cid)); + try { + if ($action === 'set_album_cover') { + AdminWorkflow::setCover($cid, (int)$user->uid, pp_is_admin(), pp_can_publish(), (int)($_POST['rowId'] ?? 0)); + $message = '封面已保存,照片顺序未改变'; + } else { + $ids = []; + foreach ((array)($_POST['rowIds'] ?? []) as $id) { + if (!(is_int($id) || (is_string($id) && ctype_digit($id))) || (int)$id <= 0) { throw new \DomainException('图片标识无效'); } + $ids[] = (int)$id; + } + $changes = []; + foreach (['desc', 'address'] as $field) { + if (($_POST['apply_' . $field] ?? '') === '1') { + if (!is_string($_POST[$field] ?? null)) { throw new \DomainException('字段内容无效'); } + $changes[$field] = trim($_POST[$field]); + } + } + $count = AdminWorkflow::batchEdit($cid, (int)$user->uid, pp_is_admin(), pp_can_publish(), $ids, $changes); + $message = '已更新 ' . $count . ' 张勾选照片'; } - pp_set_field($cid, 'device', trim((string)($_POST['device'] ?? ''))); - pp_set_field($cid, 'tags', trim((string)($_POST['tags'] ?? ''))); - pp_set_field($cid, 'location', trim((string)($_POST['address'] ?? ''))); - if ($ajax) { pp_reply_json(true, _t('已更新图集信息')); } - pp_reply(_t('已更新图集信息')); + } catch (\DomainException $e) { + if ($ajax) { pp_reply_json(false, $e->getMessage(), [], 409); } + pp_reply($e->getMessage(), 'error', $cid); + } catch (\Throwable $e) { + Plugin::log('batch/cover: ' . $e->getMessage()); + if ($ajax) { pp_reply_json(false, '保存暂未确认,请刷新核对后重试', ['retryable' => true], 500); } + pp_reply('保存暂未确认,请刷新核对后重试', 'error', $cid); } + if ($ajax) { pp_reply_json(true, $message); } + pp_reply($message, 'success', $cid); } if ($action === 'set_image_meta') { @@ -664,11 +610,17 @@ function pp_exif_summary(array $exif): string $desc = trim((string)($_POST['desc'] ?? '')); $addr = trim((string)($_POST['address'] ?? '')); if ($rowId > 0) { - $row = $db->fetchRow($db->select('cid')->from(ImageRepository::table())->where('id = ?', $rowId)->limit(1)); + $row = AdminRepository::readRow($db->select('cid')->from(ImageRepository::table())->where('id = ?', $rowId)->limit(1), true); if (!$row || !pp_can_edit_cid((int)($row['cid'] ?? 0))) { pp_deny('没有权限修改该图片'); } - ImageRepository::setImageMeta($rowId, $title, $desc, $addr); - if ($row && (int)$row['cid'] > 0) { - ImageRepository::syncPostFields((int)$row['cid']); + try { + if (!ImageRepository::editImage($rowId, (int)$row['cid'], $title, $desc, $addr)) { + if ($ajax) { pp_reply_json(false, '图片已变化,请刷新后重试', [], 409); } + pp_reply('图片已变化,请刷新后重试', 'error'); + } + } catch (\Throwable $e) { + Plugin::log('set_image_meta: ' . $e->getMessage()); + if ($ajax) { pp_reply_json(false, '图片信息暂未确认保存,请刷新核对后重试', ['retryable' => true], 500); } + pp_reply('图片信息暂未确认保存,请刷新核对后重试', 'error'); } } else { pp_deny('没有权限修改该图片'); @@ -681,18 +633,22 @@ function pp_exif_summary(array $exif): string $ajax = !empty($_POST['ajax']); $cid = (int)($_POST['cid'] ?? 0); if (!pp_can_edit_cid($cid)) { pp_deny('没有权限修改该图集'); } - $rowIds = (array)($_POST['rowIds'] ?? []); - $order = 0; - foreach ($rowIds as $rid) { - $rid = (int)$rid; - if ($rid <= 0) { - continue; + $rowIds = []; + foreach ((array)($_POST['rowIds'] ?? []) as $value) { + if (!(is_int($value) || (is_string($value) && ctype_digit($value))) || (int)$value <= 0) { + pp_deny('图片列表包含无效标识,请刷新后重试'); } - $db->query($db->update(ImageRepository::table())->rows(['sort' => $order])->where('id = ?', $rid)->where('cid = ?', $cid)); - $order++; + $rowIds[] = (int)$value; } - if ($cid > 0) { - ImageRepository::syncPostFields($cid); + try { + ImageRepository::sortImages($cid, $rowIds); + } catch (\DomainException $e) { + if ($ajax) { pp_reply_json(false, $e->getMessage(), [], 409); } + pp_reply($e->getMessage(), 'error'); + } catch (\Throwable $e) { + Plugin::log('sort_images: ' . $e->getMessage()); + if ($ajax) { pp_reply_json(false, '图片顺序暂未确认保存,请刷新核对后重试', ['retryable' => true], 500); } + pp_reply('图片顺序暂未确认保存,请刷新核对后重试', 'error'); } if ($ajax) { pp_reply_json(true, _t('已保存图片顺序')); } pp_reply(_t('已保存图片顺序')); @@ -702,11 +658,19 @@ function pp_exif_summary(array $exif): string $ajax = !empty($_POST['ajax']); $rowId = (int)($_POST['rowId'] ?? 0); if ($rowId > 0) { - $row = $db->fetchRow($db->select()->from(ImageRepository::table())->where('id = ?', $rowId)->limit(1)); + $row = AdminRepository::readRow($db->select()->from(ImageRepository::table())->where('id = ?', $rowId)->limit(1), true); if (!$row || !pp_can_edit_cid((int)($row['cid'] ?? 0))) { pp_deny('没有权限删除该图片'); } - ImageRepository::unlinkFiles($row['original'], $row['full'], $row['mid'] ?? null, $row['avif'] ?? null, $row['mid_avif'] ?? null, $row['thumb']); - $db->query($db->delete(ImageRepository::table())->where('id = ?', $rowId)); - ImageRepository::syncPostFields((int)$row['cid'], true); + try { + if (!ImageRepository::deleteImage($rowId, (int)$row['cid'])) { + if ($ajax) { pp_reply_json(false, '图片已变化,请刷新后重试', [], 409); } + pp_reply('图片已变化,请刷新后重试', 'error'); + } + } catch (\Throwable $e) { + Plugin::log('delete_image: ' . $e->getMessage()); + // 提交结果未知时不能声称数据库已回滚;实体文件仍保留供核对与重试。 + if ($ajax) { pp_reply_json(false, '删除暂未确认,请刷新核对后重试', ['retryable' => true], 500); } + pp_reply('删除暂未确认,请刷新核对后重试', 'error'); + } } else { pp_deny('没有权限删除该图片'); } @@ -718,18 +682,18 @@ function pp_exif_summary(array $exif): string pp_require_admin(); // 一键清理全站非插件文章:仅管理员 // 汇总“本插件发布的图集”cid:有 img 自定义字段 或 在 infinitytime_images 表里 $pluginCids = []; - foreach ($db->fetchAll($db->select('cid')->from($prefix . 'fields')->where('name = ?', 'img')) as $f) { + foreach (AdminRepository::readAll($db->select('cid')->from($prefix . 'fields')->where('name IN (?, ?)', 'img', AdminRepository::MARKER), true) as $f) { $pluginCids[(int)$f['cid']] = true; } - foreach ($db->fetchAll($db->select('cid')->from(ImageRepository::table())) as $f) { + foreach (AdminRepository::readAll($db->select('cid')->from(ImageRepository::table()), true) as $f) { $pluginCids[(int)$f['cid']] = true; } - $rows = $db->fetchAll($db->select('cid', 'title')->from($prefix . 'contents')->where('type = ?', 'post')); + $rows = AdminRepository::readAll($db->select('cid', 'title')->from($prefix . 'contents')->where('type = ?', 'post'), true); $nonCount = 0; $titles = []; $isDelete = ($action === 'delete_non_plugin'); // 注意:SQLite 只支持 BEGIN,不支持 MySQL 的 START TRANSACTION(BEGIN 在两者都可用) - if ($isDelete) { $db->query('BEGIN'); } + if ($isDelete) { Database::query('BEGIN'); } try { foreach ($rows as $r) { $cid = (int)$r['cid']; @@ -738,15 +702,15 @@ function pp_exif_summary(array $exif): string } $nonCount++; if ($isDelete) { - $db->query($db->delete($prefix . 'fields')->where('cid = ?', $cid)); - $db->query($db->delete($prefix . 'contents')->where('cid = ?', $cid)); + Database::query($db->delete($prefix . 'fields')->where('cid = ?', $cid)); + Database::query($db->delete($prefix . 'contents')->where('cid = ?', $cid)); } elseif (count($titles) < 20) { $titles[] = (string)($r['title'] ?? ''); } } - if ($isDelete) { $db->query('COMMIT'); } + if ($isDelete) { Database::query('COMMIT'); } } catch (\Throwable $e) { - if ($isDelete) { $db->query('ROLLBACK'); } + if ($isDelete) { Database::query('ROLLBACK'); } Plugin::log('delete_non_plugin failed: ' . $e->getMessage()); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => false, 'msg' => '清理失败:' . $e->getMessage()], JSON_UNESCAPED_UNICODE); @@ -779,16 +743,19 @@ function pp_exif_summary(array $exif): string $ajax = !empty($_POST['ajax']); if (!pp_can_edit_cid($cid)) { pp_deny('没有权限删除该图集'); } if ($cid > 0) { - ImageRepository::removeFor($cid); - $db->query($db->delete($prefix . 'contents')->where('cid = ?', $cid)); - $db->query($db->delete($prefix . 'fields')->where('cid = ?', $cid)); - } - if ($ajax) { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['ok' => true, 'msg' => _t('已删除图集及其图片文件')], JSON_UNESCAPED_UNICODE); - exit; + try { + if (!ImageRepository::deleteAlbum($cid)) { + if ($ajax) { pp_reply_json(false, '图集已变化,请刷新后重试', [], 409); } + pp_reply('图集已变化,请刷新后重试', 'error'); + } + } catch (\Throwable $e) { + Plugin::log('delete_album: ' . $e->getMessage()); + if ($ajax) { pp_reply_json(false, '删除暂未确认,请刷新核对后重试', ['retryable' => true], 500); } + pp_reply('删除暂未确认,请刷新核对后重试', 'error'); + } } - pp_reply(_t('已删除图集及其图片文件')); + if ($ajax) { pp_reply_json(true, _t('已删除图集')); } + pp_reply(_t('已删除图集')); } if ($action === 'save_site') { @@ -879,84 +846,72 @@ function pp_exif_summary(array $exif): string /* ---------------------------------- 数据准备 ---------------------------------- */ -function pp_albums(string $prefix, int $authorId = 0): array +function pp_albums(): array { - $db = Db::get(); - $q = $db->select('cid', 'title')->from($prefix . 'contents')->where('type = ?', 'post')->where('status = ?', 'publish'); - if ($authorId > 0) { - // 贡献者只看到自己的图集(写操作已按作者校验,读取同样隔离) - $q->where('authorId = ?', $authorId); - } - $rows = $db->fetchAll($q->order('created', Db::SORT_DESC)); - if (!$rows) { - return []; - } - - // 一次性取回所有图集的 img/device/tags/location 字段,避免 N+1 查询 - $cids = array_map(fn($r) => (int)$r['cid'], $rows); - $markers = implode(',', array_fill(0, count($cids), '?')); - $all = $db->fetchAll( - $db->select('cid', 'name', 'str_value')->from($prefix . 'fields') - ->where('cid IN (' . $markers . ')', ...$cids) - ->where("name IN ('img','device','tags','location')") - ); - $byCid = []; - foreach ($all as $f) { - $byCid[(int)$f['cid']][$f['name']] = $f['str_value']; - } - - $out = []; - foreach ($rows as $r) { - $cid = (int)$r['cid']; - $img = $byCid[$cid]['img'] ?? ''; - if ($img !== '') { - $r['img_count'] = count(array_filter(explode("\n", $img))); - $r['device'] = $byCid[$cid]['device'] ?? ''; - $r['tags'] = $byCid[$cid]['tags'] ?? ''; - $r['location'] = $byCid[$cid]['location'] ?? ''; - $out[] = $r; - } - } - return $out; + global $user; + return AdminRepository::page((int)$user->uid, pp_is_admin(), max(1, (int)($_GET['page'] ?? 1)), is_string($_GET['q'] ?? null) ? $_GET['q'] : ''); } -/** 渲染「已发布图集」卡片:面板主视图与 AJAX 局部刷新共用。 */ -function pp_render_albums_card(array $albums, $options): string +/** 仅渲染当前页的图集摘要;图片列表按需读取。 */ +function pp_render_albums_card(array $result, $options): string { + $albums = $result['items']; + $page = (int)$result['page']; + $query = (string)$result['query']; ob_start(); ?> -
-

已发布图集 拖动图片可调整顺序

+
+

图集 共 组 · 每页 组

+
暂无图集。
- -
+ +
> - - ( 张) - + + ( 张)·
- 共 张 + 共 张 - 前台查看 -
+ 查看图片 / 无脚本编辑 + + 追加图片 + + + + + + +
+ +
+ - +
+ 已发布图集只读;修改需编辑或管理员权限
- +
编辑图集信息 -
+ + - +
-
+
@@ -964,27 +919,52 @@ function pp_render_albums_card(array $albums, $options): string
- - -
展开后加载图片…
+ +
展开后加载图片…
'; } + ?>
+
- -
+ 0): ?> + +
×
+ + 0): ?>
+ +
+ @@ -996,10 +976,12 @@ function pp_render_album_thumbs(array $images): string
+
+
request->get('notice'); $noticeType = $options->request->get('noticeType', 'success'); -$albums = pp_albums($prefix, pp_is_admin() ? 0 : (int)$user->uid); +$albums = pp_albums(); $quality = (int)Plugin::opt('infinitytimeQuality', Plugin::DEFAULT_QUALITY); $thumbMax = (int)Plugin::opt('infinitytimeThumbMax', Plugin::DEFAULT_THUMB_MAX); $maxWidth = (int)Plugin::opt('infinitytimeMaxWidth', Plugin::DEFAULT_MAX_WIDTH); @@ -1027,7 +1009,19 @@ function pp_render_album_thumbs(array $images): string if (strpos($ppRel, '/') !== 0) { $ppRel = '/usr/plugins/InfinityTime'; } $ppPluginWeb = rtrim((string)$options->siteUrl, '/') . $ppRel; $ppCsrf = pp_csrf_token(); -$ppJobState = pp_read_json(pp_data_file() . '/job.json'); +$ppUploadTarget = null; +if (!empty($_GET['append'])) { + $ppUploadTarget = AdminRepository::album((int)$_GET['append'], (int)$user->uid, pp_is_admin()); + if ($ppUploadTarget) { + $counts = AdminRepository::counts([(int)$ppUploadTarget['cid']]); + $ppUploadTarget['count'] = $counts[(int)$ppUploadTarget['cid']] ?? 0; + } else { + $notice = '图集不存在或没有权限追加图片'; $noticeType = 'error'; + } +} +$ppOperationKey = bin2hex(random_bytes(16)); +try { $ppJobState = pp_is_admin() ? MaintenanceState::response(pp_read_json(pp_data_file() . '/job.json')) : []; } +catch (\Throwable $e) { $ppJobState = []; $notice = $e->getMessage(); $noticeType = 'error'; } /* ---------------------------------- 视图 ---------------------------------- */ $adminDir = dirname($_SERVER['SCRIPT_FILENAME']); @@ -1049,7 +1043,7 @@ function pp_render_album_thumbs(array $images): string

InfinityTime 图片分享