From 26de1409a687804654693d40bb7cb76a452f6a48 Mon Sep 17 00:00:00 2001 From: Thregren <37279823+Thregren@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:05:50 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BB=B4=E6=8A=A4?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E7=82=B9=E4=B8=8E=E5=8E=9F=E5=AD=90=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=90=8C=E6=AD=A5=EF=BC=8C=E8=A1=A5=E9=BD=90=E5=A4=9A?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E5=85=BC=E5=AE=B9=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 70 +++ README.md | 20 +- tests/admin-workflow.php | 1 + tests/field-sync.php | 207 +++++++++ tests/maintenance-client.cjs | 100 ++++ tests/maintenance-panel.php | 273 +++++++++++ tests/maintenance-state.php | 54 +++ tests/migration.php | 1 + tests/orphan-performance.php | 48 ++ tests/repository.php | 8 +- tests/schema-sqlite.php | 1 + tests/test.php | 1 + tests/typecho-integration.php | 427 ++++++++++++++++++ tests/upload-fallback.php | 1 + .../InfinityTime/Lib/AdminRepository.php | 8 +- .../InfinityTime/Lib/AdminWorkflow.php | 10 +- usr/plugins/InfinityTime/Lib/Database.php | 34 ++ .../InfinityTime/Lib/ImageRepository.php | 109 ++++- .../InfinityTime/Lib/MaintenanceState.php | 80 ++++ usr/plugins/InfinityTime/Plugin.php | 33 +- usr/plugins/InfinityTime/assets/admin.js | 12 +- usr/plugins/InfinityTime/panel.php | 146 +++--- usr/themes/InfinityTime/index.php | 2 +- usr/themes/InfinityTime/releases.json | 2 +- usr/themes/InfinityTime/update.md | 8 + 25 files changed, 1529 insertions(+), 127 deletions(-) create mode 100644 tests/field-sync.php create mode 100644 tests/maintenance-client.cjs create mode 100644 tests/maintenance-panel.php create mode 100644 tests/maintenance-state.php create mode 100644 tests/orphan-performance.php create mode 100644 tests/typecho-integration.php create mode 100644 usr/plugins/InfinityTime/Lib/Database.php create mode 100644 usr/plugins/InfinityTime/Lib/MaintenanceState.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7a7259..8d62b52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,11 @@ jobs: php tests/repository.php php tests/migration.php php tests/schema-sqlite.php + php tests/field-sync.php + php tests/maintenance-state.php + for mode in 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; do @@ -48,6 +53,7 @@ jobs: 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 @@ -86,3 +92,67 @@ jobs: - name: 后台上传浏览器回归 if: ${{ !cancelled() }} run: node tests/admin-upload.cjs --browser + + 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 diff --git a/README.md b/README.md index c4c3814..bee6203 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ ## 功能特性 +### v1.14.1 性能与可靠性加固 + +维护任务身份与原子检查点、聚合字段事务回滚、Mysqli 主库一致性修复、低内存孤儿引用扫描,并扩充官方 Typecho、多 PHP 和多数据库集成验证。1.14.0 的相册工作流与浏览功能全部保留。 + ### v1.14.0 相册工作流与浏览增强 - **先存草稿,再明确发布**:创建后保持草稿,逐张上传并显示「待上传 / 上传中 / 转换中 / 完成 / 失败」。只有点击发布才会公开;贡献者可管理自己的草稿,公开发布与已发布相册修改需要编辑者或管理员权限。 @@ -74,7 +78,7 @@ - **联系方式 / 联系我**:可视化选图标、增删/启停(AJAX)。 - **WebP 转换设置**:按 **通用保留原图 / 缩略图 / 普通图 / 全景图** 分组;宽度、**质量滑块**;转换工具状态;「恢复默认最佳设置」(AJAX)。 - **维护**:重建缩略图/全图、清理孤儿文件、重建尺寸字段、**清理非插件文章**;进度实时刷新,刷新页面后可继续未完成任务,并带并发锁。 -- **安全加固**:写操作带 CSRF token(含 GET 维护任务)、上传单文件大小/像素前置校验、「关于」HTML 危险标签/事件/协议清洗。 +- **安全加固**:所有写操作与维护任务仅接受带 CSRF token 的 POST、上传单文件大小/像素前置校验、「关于」HTML 危险标签/事件/协议清洗。 ## 技术栈 @@ -88,12 +92,16 @@ 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/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 @@ -109,7 +117,15 @@ node tests/gallery-loading-browser.cjs node tests/admin-upload.cjs --browser ``` -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;非事务引擎会在写入前明确拒绝,需要管理员先备份并自行迁移,本插件不会自动改动站点表引擎。 + +维护检查点有独立任务标识。未完成的同类任务继续原清单与进度,完成后再次开始会重新收集候选;迟到轮询不能覆盖新任务。磁盘写入失败会明确报错,重建字段失败计入失败数,可运行「重建尺寸字段」重试。首批已完成但响应丢失后,手动再次开始属于新任务,可能重复处理,但不会复用过期快照。 + +孤儿引用扫描仅读六个路径列,不缓存旧引用;每批删除前仍在媒体锁内核验。可运行 `php tests/orphan-performance.php` 在隔离 SQLite 中对比返回量和查询中位数,测试不使用机器相关的耗时通过阈值。 维护与上传现在通过同一上传根目录内的文件锁互斥,媒体处理串行执行,逐图队列在忙时保留状态并提示重试,不会并发启动多张转换。孤儿清理只处理至少一小时前的文件,恢复任务时重新核验数据库引用与文件身份;旧版任务快照会安全跳过,重新启动清理即可。文件锁需要底层文件系统支持可靠的 `flock`(多节点部署需共用支持锁的上传卷)。 diff --git a/tests/admin-workflow.php b/tests/admin-workflow.php index d472238..614f285 100644 --- a/tests/admin-workflow.php +++ b/tests/admin-workflow.php @@ -110,6 +110,7 @@ public static function syncPostFields($cid): void { 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'; 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/maintenance-client.cjs b/tests/maintenance-client.cjs new file mode 100644 index 0000000..bb58184 --- /dev/null +++ b/tests/maintenance-client.cjs @@ -0,0 +1,100 @@ +// 使用真实后台脚本和可控 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() { this.style = {}; this.listeners = {}; this.disabled = false; this.textContent = ''; } + addEventListener(name, listener) { this.listeners[name] = listener; } + emit(name) { this.listeners[name]?.({ target: this }); } + getAttribute(name) { return name === 'data-run' ? '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(), bar = new Element(), message = new Element(), container = new Element(); + const nodes = { '[data-run="rebuild"]': button, '#pp-bar-rebuild': bar, '#pp-msg-rebuild': message, '.container.typecho-page-main': container }; + const listeners = {}, requests = [], timers = [], replies = []; + const document = { + querySelector: selector => nodes[selector] || null, + querySelectorAll: selector => selector === '[data-run]' ? [button] : [], + 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, bar, message, container, requests, timers, + 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(); + } + 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..9a7ddd4 --- /dev/null +++ b/tests/maintenance-panel.php @@ -0,0 +1,273 @@ +(默认 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 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 (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('模拟字段同步故障'); } + } + } + 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('模拟编码故障'); } + 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 = ['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']; + 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()]); + } + $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'], '快照、实时读取与事务必须使用主库连接'); + } + } + if ($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::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, '失败计数必须在响应中可见'); + $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::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..0ce97eb --- /dev/null +++ b/tests/maintenance-state.php @@ -0,0 +1,54 @@ + '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], '旧版本完成状态不得复用旧清单'); + // 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/migration.php b/tests/migration.php index 8f46aa4..7793a4a 100644 --- a/tests/migration.php +++ b/tests/migration.php @@ -78,6 +78,7 @@ 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; 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/repository.php b/tests/repository.php index be72956..20d05cd 100644 --- a/tests/repository.php +++ b/tests/repository.php @@ -24,6 +24,8 @@ class Db { 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); } @@ -32,8 +34,11 @@ public function fetchAll($query): array { return $query->table === 'test_fields' 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'); } - return unserialize(base64_decode(substr($query, 8))); + $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); } @@ -73,6 +78,7 @@ public static function process(...$args): array { 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); diff --git a/tests/schema-sqlite.php b/tests/schema-sqlite.php index 26cfea4..f5fb51a 100644 --- a/tests/schema-sqlite.php +++ b/tests/schema-sqlite.php @@ -40,6 +40,7 @@ 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(); 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..79e6536 --- /dev/null +++ b/tests/typecho-integration.php @@ -0,0 +1,427 @@ + 'SQLite', 'SQLite' => 'SQLite', 'Pdo_Mysql' => 'Mysql', + 'Mysqli' => 'Mysql', 'Pdo_Pgsql' => 'Pgsql', 'Pgsql' => 'Pgsql']; + if (!isset($dialects[$adapter])) { throw new \InvalidArgumentException('Unsupported test adapter'); } + $dialect = $dialects[$adapter]; + $prefix = 'it_test_' . bin2hex(random_bytes(6)) . '_'; + $tmp = sys_get_temp_dir() . '/' . $prefix; + if (!mkdir($tmp, 0700)) { throw new \RuntimeException('Cannot create test directory'); } + define('__TYPECHO_ROOT_DIR__', $typecho); + define('__TYPECHO_PLUGIN_DIR__', '/usr/plugins'); + define('__TYPECHO_UPLOAD_ROOT_DIR__', $tmp . '/uploads'); + define('__TYPECHO_UPLOAD_DIR__', '/usr/uploads'); + // Same compatibility alias as Typecho's Widget\Init, with the real interface. + 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 reuses identical connection strings; distinct host spelling gives a real reader. + 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 . ': wrong exception ' . get_class($e) . ': ' . $e->getMessage()); + return; + } + check(false, $message . ': operation unexpectedly succeeded'); + } + function injectedFailure(callable $operation, string $message, string $expected = 'Integration failure'): void + { + try { $operation(); } catch (\Throwable $e) { + // Upstream native Pgsql passes false to pg_result_error_field on SQL errors (PHP 8 TypeError). + global $adapter; + $upstreamPgError = $adapter === 'Pgsql' && strpos($e->getMessage(), 'pg_result_error_field') !== false; + check(stripos($e->getMessage(), $expected) !== false || $upstreamPgError, $message . ': unexpected error ' . $e->getMessage()); + return; + } + check(false, $message . ': operation unexpectedly succeeded'); + } + 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); + } + // Real database errors, after earlier writes in the same operation have succeeded. + 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('Invalid trigger field'); } + 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; + try { + // Use the exact upstream schema, including unique keys and quoted camel-case columns. + $schema = file_get_contents($typecho . '/install/' . $dialect . '.sql'); + if ($schema === false) { throw new \RuntimeException('Missing upstream install schema'); } + $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); } + } + // Avoid activation's filesystem/tool/menu side effects, but execute production DDL. + $create = new \ReflectionMethod(Plugin::class, 'createTables'); + $create->setAccessible(true); + $create->invoke(null); + check((int)Plugin::opt('infinitytimeSchemaVersion') === 4, 'Fresh schema migration must finish'); + check(rowCount('infinitytime_images') === 0, 'Fresh schema must be empty'); + $create->invoke(null); + check(rowCount('options') === 1, 'Repeated setup must not duplicate schema options'); + check($db->selectDb(Db::READ) !== $db->selectDb(Db::WRITE), 'Test must exercise separate Typecho read/write connections'); + + $resource = Database::query('SELECT 1 AS probe'); + check(Database::query($resource) === $resource, '已执行的真实查询资源必须原样返回'); + unset($resource); + + // Query placeholders are deferred by real Typecho. Include punctuation and 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'], 'Create a persisted draft'); + check(AdminRepository::album($cid, 7, false)['title'] === $text, 'Real adapters must preserve quoted and Unicode values'); + check(AdminRepository::field($cid, 'device') === 'Camera ? 日本', 'Draft metadata persisted'); + check(AdminRepository::field($cid, AdminRepository::MARKER) === '1', 'Empty draft has album marker'); + $again = AdminWorkflow::draft(7, ['title' => 'Do not overwrite'], 'draft-operation-0001'); + check((int)$again['cid'] === $cid && $again['replayed'], 'Retry returns the existing draft'); + check(rowCount('contents') === 1, 'Retry must not create duplicate posts'); + $other = AdminWorkflow::draft(8, ['title' => 'Other author'], 'draft-operation-0001'); + check((int)$other['cid'] !== $cid, 'Operation keys are scoped to their author'); + check(AdminRepository::album($cid, 8, false) === null, 'Foreign author cannot access an album'); + check(AdminRepository::album($cid, 8, true) !== null, 'Administrator can access foreign album'); + check(AdminRepository::album($cid, 0, true) === null, 'Missing user identity is rejected'); + rejected(static function () use ($cid) { AdminWorkflow::requireAlbum($cid, 8, false); }, \DomainException::class, 'Mutation refuses foreign album'); + rejected(static function () { AdminWorkflow::draft(7, ['title' => 'bad'], 'short'); }, \InvalidArgumentException::class, 'Invalid operation key rejected'); + rejected(static function () { AdminWorkflow::draft(7, ['title' => ' '], 'draft-operation-0002'); }, \InvalidArgumentException::class, 'Blank title rejected'); + check(AdminSecurity::validMutation('POST', 'token', 'token') && !AdminSecurity::validMutation('GET', 'token', 'token') + && !AdminSecurity::validMutation('POST', '', '') && !AdminSecurity::validMutation('POST', 'token', ['token']), 'Mutation guard fails closed'); + + $ordinary = post([]); + check(AdminRepository::album($ordinary, 7, false) === null, 'Ordinary post is not an album'); + foreach (['attachment', 'page'] as $type) { + $excluded = post(['type' => $type]); + AdminRepository::setField($excluded, AdminRepository::MARKER, '1'); + check(AdminRepository::album($excluded, 7, true) === null, 'Wrong content type rejected: ' . $type); + } + $hidden = post(['status' => 'private']); + AdminRepository::setField($hidden, AdminRepository::MARKER, '1'); + check(AdminRepository::album($hidden, 7, true) === null, 'Unsupported post status rejected'); + rejected(static function () use ($cid) { AdminWorkflow::publish($cid, 7, false, true); }, \InvalidArgumentException::class, 'Empty draft cannot publish'); + check(AdminRepository::page(7, false, 1, '50%_!')['total'] === 1, 'LIKE wildcard characters searched literally'); + check(AdminRepository::page(7, false, 1, "' OR 1=1 --")['total'] === 0, 'Search cannot inject SQL'); + check(AdminRepository::page(8, false)['total'] === 1, 'Pagination respects author scope'); + + // A Query passed to Db::query(..., WRITE) is silently rerouted to READ by Typecho. + // Production helpers must compile it to a string before choosing the writer. + 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', 'Writer sees uncommitted metadata'); + check(AdminRepository::field($cid, 'device') === 'before transaction', 'Reader remains isolated from writer'); + $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 compiles and routes to writer'); + } finally { Database::query('ROLLBACK', Db::WRITE); } + check(AdminRepository::field($cid, 'device') === 'before transaction', 'Rollback restores existing field'); + + $before = rowCount('contents'); + failureTrigger(true); + try { + injectedFailure(static function () { AdminWorkflow::draft(7, ['title' => 'Rollback draft', 'device' => 'first write', 'tags' => 'fail'], 'draft-operation-fail'); }, + 'Draft field failure surfaces database error'); + } finally { failureTrigger(false); } + check(rowCount('contents') === $before, 'Draft and earlier fields rollback together'); + check(AdminRepository::draftForOperation(7, 'draft-operation-fail') === null, 'Failed draft leaves no operation marker'); + + $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, 'Image inserts return real generated primary keys'); + injectedFailure(static function () use ($cid) { photo($cid, ['upload_key' => 'photo-operation-0001']); }, + 'Same-album upload retry key is unique', $dialect === 'SQLite' ? 'UNIQUE' : 'duplicate'); + check(rowCount('infinitytime_images') === 2, 'Rejected duplicate must not add a row'); + check(photo((int)$other['cid'], ['upload_key' => 'photo-operation-0001']) > 0, 'Upload keys can repeat in different albums'); + check(photo((int)$other['cid']) > 0 && photo((int)$other['cid']) > 0, 'Multiple legacy NULL upload keys allowed'); + ImageRepository::syncPostFields($cid); + check(AdminRepository::field($cid, 'img') === "/usr/uploads/full/early.webp\n/usr/uploads/full/late.webp", 'Image paths follow sort order'); + check(json_decode(AdminRepository::field($cid, 'photo_ids'), true) === [$b, $a], 'Photo IDs aligned with paths'); + check(json_decode(AdminRepository::field($cid, 'titles'), true) === ["Early ' ?", 'Late'], 'Titles aligned with paths'); + check(json_decode(AdminRepository::field($cid, 'dims'), true) === ['800x600', '1200x600'], 'Dimensions aligned with paths'); + check(json_decode(AdminRepository::field($cid, 'panos'), true) === [0, 1], 'Panorama metadata aligned'); + $exif = json_decode(AdminRepository::field($cid, 'exif'), true); + check(!isset($exif[0]['gps']) && !isset($exif[1]['gps']), 'Public EXIF fields omit GPS'); + check(AdminRepository::counts([$cid, $cid, 0, -1]) === [$cid => 2], 'Managed image counts deduplicate IDs'); + check(count(ImageRepository::rowsForCids([$cid, (int)$other['cid']])) === 2, 'Batch image lookup groups albums'); + $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], 'Legacy line-count syntax is portable'); + check(AdminRepository::album($legacy, 7, false) !== null, 'Legacy fields identify an album'); + check(AdminRepository::field($legacy, 'img') === "/legacy/a.webp\n/legacy/b.webp", 'Sync preserves legacy-only images'); + + // Atomic replacement: fail after img/thumb have already been rewritten. + $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); }, + 'Field-sync failure surfaces database error'); + } finally { failureTrigger(false); } + check(fields($cid) === $snapshot, 'Failed field sync must retain the entire old snapshot'); + ImageRepository::syncPostFields($cid); + check(json_decode(AdminRepository::field($cid, 'titles'), true)[0] === 'Changed', 'Retry successfully updates all fields'); + check(json_decode(AdminRepository::field($cid, 'months'), true) === ['2024-05', '2024-05'], 'EXIF dates are kept aligned'); + + // Caller-owned transaction: sync must neither commit nor discard unrelated work. + $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', 'Nested sync reads uncommitted writer image rows'); + check(AdminRepository::field($cid, 'device') === 'before transaction', 'Nested sync must not commit caller writes'); + } finally { Database::query('ROLLBACK', Db::WRITE); } + check(fields($cid) === $outerSnapshot, 'Caller rollback restores successful nested field sync'); + + failureTrigger(true, 'titles'); + Database::query('BEGIN', Db::WRITE); + try { + AdminRepository::setField($cid, 'device', 'outer survives failure'); + injectedFailure(static function () use ($cid) { ImageRepository::syncPostFields($cid); }, 'Nested sync reports injected error'); + check(AdminRepository::field($cid, 'device', true) === 'outer survives failure', 'Failed nested sync preserves earlier caller writes'); + check(json_decode(AdminRepository::field($cid, 'titles', true), true)[0] === 'Changed', 'Failed nested sync restores old title within caller transaction'); + AdminRepository::setField($cid, 'device', 'outer remains usable'); + check(AdminRepository::field($cid, 'device', true) === 'outer remains usable', 'Caller can continue using transaction after failed sync'); + } finally { + Database::query('ROLLBACK', Db::WRITE); + failureTrigger(false); + } + check(fields($cid) === $outerSnapshot, 'Caller rollback after failed sync restores all fields'); + + rejected(static function () use ($cid) { AdminWorkflow::publish($cid, 7, false, false); }, \DomainException::class, 'Contributor cannot publish'); + AdminWorkflow::publish($cid, 7, false, true); + check(AdminRepository::album($cid, 7, false)['status'] === 'publish', 'Editor publishes nonempty album'); + rejected(static function () use ($cid) { AdminWorkflow::requireAlbum($cid, 7, false, false); }, \DomainException::class, 'Contributor cannot modify published album'); + check(AdminWorkflow::requireAlbum($cid, 7, false, true)['status'] === 'publish', 'Editor can modify own published album'); + + // 模拟真正滞后的副本:读库完全没有主库的图集、图片和自定义字段。 + $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, '删除确实发生在主库'); + } 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); + if ($replicaCreated) { + Database::query('DROP DATABASE ' . ($dialect === 'Mysql' ? '`' : '"') . $replicaName . ($dialect === 'Mysql' ? '`' : '"')); + } + foreach (glob($tmp . '/replica.sqlite*') ?: [] as $file) { @unlink($file); } + } + + Database::query($db->delete(ImageRepository::table())->where('cid = ?', $cid)); + ImageRepository::syncPostFields($cid, true); + check(AdminRepository::field($cid, 'img') === '' && AdminRepository::field($cid, 'photo_ids') === '', 'Explicit clear removes last-image metadata'); + check(AdminRepository::album($cid, 7, false) !== null, 'Cleared album keeps its marker'); + + // Genuine old-install migration: absent metadata/retry columns, existing rows preserved. + 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, 'Migration preserves existing rows with nullable retry keys'); + check(array_key_exists('mid_avif', $preserved) && array_key_exists('desc', $preserved), 'Migration repairs every historical missing column'); + Plugin::setOption('infinitytimeSchemaVersion', '3'); + Plugin::migrateSchema(); + check((int)Plugin::opt('infinitytimeSchemaVersion') === 4, 'Interrupted migration validates existing unique index'); + check(rowCount('infinitytime_images') === 1, 'Repeated migration never duplicates images'); + echo 'PASS: ' . $checks . ' real Typecho ' . \Typecho\Common::VERSION . ' / PHP ' . PHP_VERSION . ' / ' . $adapter . " checks\n"; + } catch (\Throwable $e) { + $failed = $e; + } finally { + ImageRepository::unlockMedia(); + // Only our random-prefixed objects are removed, including after a failing assertion. + 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: ' . $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); } + @unlink($tmp . '/uploads/.infinitytime-media.lock'); + @rmdir($tmp . '/uploads'); + @rmdir($tmp); + } + if ($failed) { + fwrite(STDERR, 'FAIL: ' . 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 index 4110c70..2c5339e 100644 --- a/usr/plugins/InfinityTime/Lib/AdminRepository.php +++ b/usr/plugins/InfinityTime/Lib/AdminRepository.php @@ -26,14 +26,14 @@ 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 ? $db->query($query, Db::WRITE) : $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 ? $db->query($query, Db::WRITE) : $query); + return $db->fetchAll($writer ? Database::query($query) : $query); } /** 所有参数均由 Typecho 适配器转义;不直接拼接用户输入。 */ @@ -138,9 +138,9 @@ public static function setField(int $cid, string $name, string $value): void { $db = Db::get(); $table = $db->getPrefix() . 'fields'; - $db->query($db->delete($table)->where('cid = ?', $cid)->where('name = ?', $name)); + Database::query($db->delete($table)->where('cid = ?', $cid)->where('name = ?', $name)); if ($value !== '') { - $db->query($db->insert($table)->rows(['cid' => $cid, 'name' => $name, 'type' => 'str', 'str_value' => $value])); + Database::query($db->insert($table)->rows(['cid' => $cid, 'name' => $name, 'type' => 'str', 'str_value' => $value])); } } diff --git a/usr/plugins/InfinityTime/Lib/AdminWorkflow.php b/usr/plugins/InfinityTime/Lib/AdminWorkflow.php index 7a87b10..8a346da 100644 --- a/usr/plugins/InfinityTime/Lib/AdminWorkflow.php +++ b/usr/plugins/InfinityTime/Lib/AdminWorkflow.php @@ -17,9 +17,9 @@ public static function draft(int $uid, array $data, string $key): array if ($title === '') { throw new \InvalidArgumentException('请填写图集标题'); } $db = Db::get(); $now = time(); - $db->query('BEGIN', Db::WRITE); + Database::query('BEGIN'); try { - $cid = (int)$db->query($db->insert($db->getPrefix() . 'contents')->rows([ + $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', @@ -31,9 +31,9 @@ public static function draft(int $uid, array $data, string $key): array foreach (['device' => 'device', 'tags' => 'tags', 'location' => 'address'] as $name => $input) { AdminRepository::setField($cid, $name, trim((string)($data[$input] ?? ''))); } - $db->query('COMMIT', Db::WRITE); + Database::query('COMMIT'); } catch (\Throwable $e) { - $db->query('ROLLBACK', Db::WRITE); + Database::query('ROLLBACK'); throw $e; } return ['cid' => $cid, 'title' => $title, 'status' => 'draft', 'replayed' => false]; @@ -136,6 +136,6 @@ public static function publish(int $cid, int $uid, bool $admin, bool $canPublish if (($counts[$cid] ?? 0) < 1) { throw new \InvalidArgumentException('请先上传至少一张图片再发布'); } ImageRepository::syncPostFields($cid); $db = Db::get(); - $db->query($db->update($db->getPrefix() . 'contents')->rows(['status' => 'publish', 'modified' => time()])->where('cid = ?', $cid)); + 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..d52becc --- /dev/null +++ b/usr/plugins/InfinityTime/Lib/Database.php @@ -0,0 +1,34 @@ +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; + } +} diff --git a/usr/plugins/InfinityTime/Lib/ImageRepository.php b/usr/plugins/InfinityTime/Lib/ImageRepository.php index da69080..f4574ba 100644 --- a/usr/plugins/InfinityTime/Lib/ImageRepository.php +++ b/usr/plugins/InfinityTime/Lib/ImageRepository.php @@ -244,7 +244,7 @@ public static function insertRow(int $cid, array $meta, int $sort = 0): int 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'], @@ -274,7 +274,7 @@ private static function fetchRows($db, $query): array { if (is_resource(self::$mediaLock)) { // Typecho 会按 Query 类型重新选择连接池;先编译绑定参数,再显式选择主库。 - $query = $db->query($query->prepare((string)$query), \Typecho\Db::WRITE); + $query = Database::query($query->prepare((string)$query)); } return $db->fetchAll($query); } @@ -337,7 +337,7 @@ 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, @@ -357,15 +357,97 @@ public static function updateVariants(int $rowId, array $result): void foreach (['width', 'height', 'size'] as $field) { if (isset($result[$field])) { $values[$field] = (int)$result[$field]; } } - $db->query($db->update(self::table())->rows($values)->where('id = ?', $rowId)); + Database::query($db->update(self::table())->rows($values)->where('id = ?', $rowId)); } - /** 按图片行的顺序同步路径与元数据,避免排序/删除后文章字段错位。 */ + /** 主库上的原子字段替换;保存点不会提交或回滚调用方的其他写入。 */ public static function syncPostFields(int $cid, bool $clearEmpty = false): void + { + self::lockMedia(); + $db = \Typecho\Db::get(); + $adapter = strtolower($db->getAdapterName()); + $handle = $db->selectDb(\Typecho\Db::WRITE); + $write = static function ($query) { + // 所有同步语句编译后固定走主库;不需要 INSERT 的生成主键。 + return Database::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 用户表。 + $query = $db->select(); + $sql = $query->prepare('SHOW TABLE STATUS WHERE Name = ' . $query->quoteValue($db->getPrefix() . 'fields')); + $table = $db->fetchRow($write($sql)); + if (strcasecmp((string)($table['Engine'] ?? ''), 'InnoDB') !== 0) { + throw new \RuntimeException('字段同步需要 fields 表使用 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) { + throw new \RuntimeException('当前数据库适配器不支持安全的字段同步事务'); + } + // SQLite SAVEPOINT 可独立启动事务;嵌套 RELEASE 不会提交外层 BEGIN/SAVEPOINT。 + $started = false; + try { + $write('SAVEPOINT ' . $savepoint); + $started = true; + self::writePostFields($cid, $clearEmpty, $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) { + // 保留最初的错误,同时明确记录连接丢失等导致无法确认回滚的情况。 + Plugin::log('field sync rollback failed: ' . $rollbackError->getMessage()); + } + throw $e; + } + } + + /** 按图片行的顺序同步路径与元数据,避免排序/删除后文章字段错位。 */ + 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) { $row['exif'] = json_decode($row['exif'] ?? '{}', true); } + unset($row); // 历史文章可能仅有自定义字段。无图片行时不覆盖,删除最后一张图则显式清空。 if (!$rows && !$clearEmpty) { return; @@ -408,15 +490,15 @@ public static function syncPostFields(int $cid, bool $clearEmpty = false): void $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, 'photo_ids' => $photoIds, 'months' => $months]; foreach (['addresses', 'titles', 'descs', 'panos', 'dims', 'variants', 'exif', 'photo_ids', 'months'] as $f) { - $db->query($db->delete($prefix . 'fields')->where('cid = ?', $cid)->where('name = ?', $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; @@ -427,9 +509,9 @@ 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), ])); } } @@ -454,7 +536,7 @@ 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)); } /** 仅处理真实上传目录内的普通文件;旧快照、软链接与新文件一律跳过。 */ @@ -492,7 +574,8 @@ private static function referencedPaths(): array $real = realpath($path); if ($real !== false) { $referenced[$real] = true; } }; - foreach (self::fetchRows($db, $db->select()->from(self::table())) as $row) { + // 只读引用路径;扫描孤儿文件不需要搬运可能很大的 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); } diff --git a/usr/plugins/InfinityTime/Lib/MaintenanceState.php b/usr/plugins/InfinityTime/Lib/MaintenanceState.php new file mode 100644 index 0000000..db355f5 --- /dev/null +++ b/usr/plugins/InfinityTime/Lib/MaintenanceState.php @@ -0,0 +1,80 @@ + $job, 'job_id' => bin2hex(random_bytes(16)), 'total' => count($list), + 'done' => 0, 'current' => '', 'failed' => 0, 'finished' => false]; + self::write(self::listFile($dir, $state), $list); + self::write($file, $state); + return [$state, $list]; + } + + 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 \RuntimeException('无效的维护任务状态'); + } + 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/Plugin.php b/usr/plugins/InfinityTime/Plugin.php index daf8905..49e6bdb 100644 --- a/usr/plugins/InfinityTime/Plugin.php +++ b/usr/plugins/InfinityTime/Plugin.php @@ -4,7 +4,7 @@ * * @package InfinityTime * @author InfinityTime - * @version 1.14.0 + * @version 1.14.1 * @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.14.0'; + public const VERSION = '1.14.1'; public const MENU_NAME = 'InfinityTime'; // 统一默认(“恢复默认/写入”)配置,避免各处写死不同数值 public const DEFAULT_QUALITY = 76; // 缩略图质量 @@ -385,7 +386,7 @@ private static function createTables(): void if ($isMysql) { $sql .= " engine=InnoDB DEFAULT CHARSET=utf8mb4"; } - $db->query($sql, Db::WRITE); + Database::query($sql); // 旧表升级:按 schema 版本补齐缺失列 self::migrateSchema(); @@ -393,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`)", Db::WRITE); + 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})", Db::WRITE); + Database::query("CREATE INDEX idx_cid ON {$q}{$table}{$q} ({$q}cid{$q})"); } } catch (\Throwable $e) { // 已存在或不受支持时忽略 @@ -423,18 +424,18 @@ public static function migrateSchema(): void // 使用限定列名,避免 SQLite 将不存在的双引号列名当作字符串。 $probe = "SELECT {$q}{$table}{$q}.{$q}{$col}{$q} FROM {$q}{$table}{$q} WHERE 1 = 0"; try { - $db->query($probe, Db::WRITE); + Database::query($probe); continue; } catch (\Throwable $e) { // 缺列时尝试补齐;其他读取错误也不能冒充成功。 } try { - $db->query("ALTER TABLE {$q}{$table}{$q} ADD COLUMN {$q}{$col}{$q} {$definition}", Db::WRITE); + Database::query("ALTER TABLE {$q}{$table}{$q} ADD COLUMN {$q}{$col}{$q} {$definition}"); } catch (\Throwable $e) { // 另一进程可能已加列;下面实际探测成功才允许继续。 } try { - $db->query($probe, Db::WRITE); + Database::query($probe); } catch (\Throwable $e) { throw new \RuntimeException('InfinityTime 数据库升级失败,请检查数据库权限后重试(列:' . $col . ')', 0, $e); } @@ -452,26 +453,26 @@ private static function ensureUploadIndex($db, string $table): void $q = $mysql ? '`' : '"'; $index = 'it_upload_' . substr(sha1($table), 0, 16); try { - $db->query("CREATE UNIQUE INDEX {$q}{$index}{$q} ON {$q}{$table}{$q} ({$q}cid{$q}, {$q}upload_key{$q})", Db::WRITE); + 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($db->query("SHOW INDEX FROM {$q}{$table}{$q} WHERE Key_name = '" . $index . "'", Db::WRITE)); + $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($db->query("SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND indexname = '" . $index . "'", Db::WRITE)); + $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($db->query("PRAGMA index_list(" . $q . $table . $q . ")", Db::WRITE)) as $row) { + 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($db->query("PRAGMA index_info(" . $q . $index . $q . ")", Db::WRITE)); + $columns = $db->fetchAll(Database::query("PRAGMA index_info(" . $q . $index . $q . ")")); $valid = array_column($columns, 'name') === ['cid', 'upload_key']; } } @@ -504,11 +505,11 @@ public static function setOption(string $name, $value): void $prefix = $db->getPrefix(); $val = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : (string)$value; $query = $db->select('name')->from($prefix . 'options')->where('name = ?', $name)->limit(1); - $exists = $db->fetchRow($db->query($query->prepare((string)$query), Db::WRITE)); + $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; diff --git a/usr/plugins/InfinityTime/assets/admin.js b/usr/plugins/InfinityTime/assets/admin.js index 954fb52..39d6f52 100644 --- a/usr/plugins/InfinityTime/assets/admin.js +++ b/usr/plugins/InfinityTime/assets/admin.js @@ -564,7 +564,7 @@ /* ---------- 维护任务 ---------- */ var runningJobs = {}; - function runJob(job) { + function runJob(job, resume) { if (runningJobs[job]) return; runningJobs[job] = true; var bar = $('#pp-bar-' + job); @@ -573,11 +573,15 @@ if (btn) btn.disabled = true; if (bar) bar.style.width = '0%'; // 每次开始先归零,避免上一次的 100% 残留 if (msg) msg.textContent = '准备中…'; + var mode = resume ? 'resume' : 'start'; + var jobId = resume && JOB ? (JOB.job_id || '') : ''; function tick() { - post('maintenance', { job: job }) + post('maintenance', { job: job, mode: mode, job_id: jobId }) .then(function (d) { if (!d || d.ok === false) throw new Error((d && d.msg) || '出错,请重试'); if (d.msg && d.finished) { if (msg) msg.textContent = d.msg; if (btn) btn.disabled = false; runningJobs[job] = false; return; } + jobId = d.job_id || jobId; + mode = 'poll'; var pct = d.total ? Math.round(d.done * 100 / d.total) : 100; if (bar) bar.style.width = pct + '%'; if (d.total > 0) { @@ -587,7 +591,7 @@ if (msg) msg.textContent = noJob[job] || '没有需要处理的项目'; } if (!d.finished) { setTimeout(tick, 300); return; } - if (msg) msg.textContent += d.failed > 0 ? (' ✓ 完成(' + d.failed + ' 张失败,请查看日志)') : ' ✓ 完成'; + if (msg) msg.textContent += d.failed > 0 ? (' ✓ 完成(' + d.failed + ' 项失败,请查看日志)') : ' ✓ 完成'; if (btn) btn.disabled = false; runningJobs[job] = false; }) @@ -757,7 +761,7 @@ 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/panel.php b/usr/plugins/InfinityTime/panel.php index 10985d3..3e36a78 100644 --- a/usr/plugins/InfinityTime/panel.php +++ b/usr/plugins/InfinityTime/panel.php @@ -16,6 +16,9 @@ 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(); @@ -193,9 +196,9 @@ 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)); + Database::query($db->delete($prefix . 'fields')->where('cid = ?', $cid)->where('name = ?', $name)); if ($value !== '') { - $db->query($db->insert($prefix . 'fields')->rows([ + Database::query($db->insert($prefix . 'fields')->rows([ 'cid' => $cid, 'name' => $name, 'type' => 'str', 'str_value' => $value, ])); } @@ -212,15 +215,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 下因删除文件而空出的目录(自底向上)。 */ @@ -303,33 +303,24 @@ function pp_exif_summary(array $exif): string $__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 ($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, '', $state); } + pp_write_json($__lockFile, ['uid' => $__uid, 'job' => $job, 'time' => time()]); $idx = (int)($state['done'] ?? 0); $failed = (int)($state['failed'] ?? 0); $batch = 3; @@ -348,8 +339,13 @@ function pp_exif_summary(array $exif): string if ((microtime(true) - $started) > $budget) { break; } - $item = $list[$idx]; - $src = ImageRepository::toAbs($item[1]); + $snapshot = $list[$idx]; + $rowId = is_array($snapshot) ? (int)$snapshot[0] : (int)$snapshot; + // 快照只决定候选身份;路径与归属在每批主库重读,避免删除/改绑后重建旧记录。 + $live = AdminRepository::readRow($db->select()->from(ImageRepository::table())->where('id = ?', $rowId)->limit(1), true); + if (!$live || empty($live['original'])) { $idx++; continue; } + $item = [$rowId, $live['original'], $live['full'], $live['thumb'], (int)($live['cid'] ?? 0)]; + $src = ImageRepository::toAbs((string)$item[1]); if (is_file($src)) { $mw = $maxWidth; $fq = $fullQuality; @@ -378,27 +374,23 @@ function pp_exif_summary(array $exif): string $idx++; } foreach (array_keys($syncedCids) as $__c) { - try { ImageRepository::syncPostFields((int)$__c); } catch (\Throwable $e) {} + try { ImageRepository::syncPostFields((int)$__c); } catch (\Throwable $e) { + Plugin::log('rebuild sync: cid=' . $__c . ' ' . $e->getMessage()); + $failed++; + $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 = $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)) { - $list = ImageRepository::orphanCandidates(); - 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, '', $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)); @@ -408,33 +400,25 @@ function pp_exif_summary(array $exif): string } $state['done'] = $idx; $state['finished'] = $idx >= count($list); - if ($state['finished']) { - @unlink($listFile); - $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 = $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, '', $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 超时 @@ -461,12 +445,8 @@ function pp_exif_summary(array $exif): string $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 = $state; } elseif ($job === 'albums_html') { // 局部刷新图集列表:只返回卡片 HTML,避免为刷新列表重新渲染整个后台页面 header('Content-Type: text/html; charset=utf-8'); @@ -491,6 +471,11 @@ function pp_exif_summary(array $exif): string exit; } + } catch (\Throwable $e) { + Plugin::log('maintenance: ' . $e->getMessage()); + pp_reply_json(false, $e->getMessage(), ['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']) ? ',已完成' : ';再次提交可继续')); @@ -568,7 +553,7 @@ function pp_exif_summary(array $exif): string if ($cid > 0) { $title = trim((string)($_POST['title'] ?? '')); if ($title !== '') { - $db->query($db->update($prefix . 'contents')->rows(['title' => $title])->where('cid = ?', $cid)); + Database::query($db->update($prefix . 'contents')->rows(['title' => $title])->where('cid = ?', $cid)); } pp_set_field($cid, 'device', trim((string)($_POST['device'] ?? ''))); pp_set_field($cid, 'tags', trim((string)($_POST['tags'] ?? ''))); @@ -614,7 +599,7 @@ function pp_exif_summary(array $exif): string if ($rid <= 0) { continue; } - $db->query($db->update(ImageRepository::table())->rows(['sort' => $order])->where('id = ?', $rid)->where('cid = ?', $cid)); + Database::query($db->update(ImageRepository::table())->rows(['sort' => $order])->where('id = ?', $rid)->where('cid = ?', $cid)); $order++; } if ($cid > 0) { @@ -631,7 +616,7 @@ function pp_exif_summary(array $exif): string $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)); + Database::query($db->delete(ImageRepository::table())->where('id = ?', $rowId)); ImageRepository::syncPostFields((int)$row['cid'], true); } else { pp_deny('没有权限删除该图片'); @@ -644,18 +629,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 IN (?, ?)', 'img', AdminRepository::MARKER)) 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']; @@ -664,15 +649,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); @@ -706,8 +691,8 @@ function pp_exif_summary(array $exif): string 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)); + Database::query($db->delete($prefix . 'contents')->where('cid = ?', $cid)); + Database::query($db->delete($prefix . 'fields')->where('cid = ?', $cid)); } if ($ajax) { header('Content-Type: application/json; charset=utf-8'); @@ -963,7 +948,8 @@ function pp_render_album_thumbs(array $images, bool $canEdit = true): string } } $ppOperationKey = bin2hex(random_bytes(16)); -$ppJobState = pp_read_json(pp_data_file() . '/job.json'); +try { $ppJobState = pp_read_json(pp_data_file() . '/job.json'); } +catch (\Throwable $e) { $ppJobState = []; $notice = $e->getMessage(); $noticeType = 'error'; } /* ---------------------------------- 视图 ---------------------------------- */ $adminDir = dirname($_SERVER['SCRIPT_FILENAME']); diff --git a/usr/themes/InfinityTime/index.php b/usr/themes/InfinityTime/index.php index 2e2e32a..82554f0 100755 --- a/usr/themes/InfinityTime/index.php +++ b/usr/themes/InfinityTime/index.php @@ -3,7 +3,7 @@ * 一款简约的相册主题 * @package 无限时光 * @author InfinityTime - * @version 1.14.0 + * @version 1.14.1 * @link https://github.com/InfinityTime/InfinityTime */ ?> diff --git a/usr/themes/InfinityTime/releases.json b/usr/themes/InfinityTime/releases.json index 64630e0..7875a65 100644 --- a/usr/themes/InfinityTime/releases.json +++ b/usr/themes/InfinityTime/releases.json @@ -1,4 +1,4 @@ { - "tag_name": "1.14.0" + "tag_name": "1.14.1" } diff --git a/usr/themes/InfinityTime/update.md b/usr/themes/InfinityTime/update.md index 0362677..e2a5b82 100644 --- a/usr/themes/InfinityTime/update.md +++ b/usr/themes/InfinityTime/update.md @@ -1,5 +1,13 @@ # 更新日志 +## 1.14.1 + +- 修复连续重建复用过期清单:新任务重新扫描,未完成任务保留断点,任务标识拒绝迟到轮询;状态落盘失败明确提示。 +- 图集聚合字段通过主库保存点原子同步,故障保留旧字段并保持外层事务;重建时同步错误计入失败。MySQL 非 InnoDB 字段表安全拒绝。 +- 统一插件主库查询入口,修复 Typecho Mysqli 读写连接与插入标识边界;非插件文章清理从主库确认身份并正确路由事务。 +- 孤儿引用扫描只读取六个路径列,显著降低含大 EXIF 图库的读取数据量,同时保留每批引用与文件身份复核。 +- 增加 PHP 7.4 / 8.2 / 8.4、Typecho 1.2.1 / 1.3.0、SQLite / MySQL / PostgreSQL 的真实数据库集成矩阵和维护故障注入回归。 + ## 1.14.0 - 后台草稿优先、逐图串行上传及持久化重试标识,支持成功项恢复和仓库相册追加。 From 8dc3cf9fa82e137ecb6bddc484cf8ebf78af812f Mon Sep 17 00:00:00 2001 From: Thregren <37279823+Thregren@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:09:56 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E5=85=BC=E5=AE=B9PHP7.4=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=AF=AD=E6=B3=95=EF=BC=8C=E5=A2=9E=E5=8A=A0=E9=9D=9E=E4=BA=8B?= =?UTF-8?q?=E5=8A=A1=E8=A1=A8=E4=B8=8E=E4=B8=BB=E4=BB=8E=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E6=95=85=E9=9A=9C=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/gallery-discovery.php | 54 ++++---- tests/repository.php | 3 +- tests/typecho-integration.php | 235 ++++++++++++++++++++-------------- 3 files changed, 170 insertions(+), 122 deletions(-) diff --git a/tests/gallery-discovery.php b/tests/gallery-discovery.php index ee991e4..5bbc2cb 100644 --- a/tests/gallery-discovery.php +++ b/tests/gallery-discovery.php @@ -1,12 +1,13 @@ 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; } @@ -23,24 +24,23 @@ public function getPrefix(): string { return 'test_'; } public function select(...$columns): Query { return new Query($columns); } public function fetchAll(Query $query): array { $this->queries[] = $query; - $rows = match ($query->table) { - 'test_contents' => $this->albums, 'test_infinitytime_images' => $this->images, - 'test_fields' => $this->fields, 'test_relationships' => $this->metas, - default => throw new \RuntimeException('未预期的数据表:' . $query->table), - }; + $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 { - return match ($sql) { - 'type = ?' => $r['type'] === $values[0], - 'status = ?' => $r['status'] === $values[0], - '(password IS NULL OR password = ?)' => ($r['password'] ?? '') === '', - 'created <= ?' => $r['created'] <= $values[0], - 'cid <= ?' => $r['cid'] <= $values[0], - 'cid = ?' => $r['cid'] === $values[0], - 'id = ?' => $r['id'] === $values[0], - default => str_contains($sql, 'cid IN') ? in_array($r['cid'], $values, true) - : (str_contains($sql, 'name IN') ? in_array($r['name'], $values, true) : true), - }; + 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']); } @@ -72,7 +72,11 @@ public function __call(string $name, array $args): void { } class GalleryFixture { public GalleryFixtureOptions $options; - public function __construct(public string $archiveType = '', public string $archiveTitle = '', public string $archiveSlug = '') { $this->options = new GalleryFixtureOptions(); } + 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; } @@ -178,8 +182,8 @@ public function render(): void { include __DIR__ . '/../usr/themes/InfinityTime/ $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(!str_contains($html, ' + ${['jquery.min', 'jquery.poptrox.min', 'browser.min', 'breakpoints.min', 'lightbox', 'main'].map(name => ``).join('')} `; const image = ''; (async () => { - const browser = await chromium.launch({ headless: true, ...(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); + 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)); @@ -99,7 +110,13 @@ const image = ' 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'); @@ -152,7 +169,7 @@ const image = ' await open('new'); await ready('new'); await close(); assert.deepEqual(errors, [], 'no uncaught script errors'); await context.close(); - console.log(`Gallery browser regressions passed at ${viewport.width}px`); + 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-loading-browser.cjs b/tests/gallery-loading-browser.cjs index 92aaa97..1aecdd7 100644 --- a/tests/gallery-loading-browser.cjs +++ b/tests/gallery-loading-browser.cjs @@ -1,9 +1,11 @@ -// 使用本地 Chromium,全量拦截请求,不访问线上图册。 -// 运行:CHROMIUM_PATH=/usr/bin/chromium node tests/gallery-loading-browser.cjs +// 全量拦截请求,不访问线上图册。PLAYWRIGHT_BROWSER 可选 chromium/firefox/webkit。 +// 本地 Chromium:CHROMIUM_PATH=/usr/bin/chromium node tests/gallery-loading-browser.cjs const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { chromium } = require('playwright'); +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`; @@ -25,7 +27,7 @@ const initial = card('a', 1, [url('a'), url('b'), url('pano'), url('slow')], ['p }) + card('same-album', 1, [url('other-month')], ['p-5']) + card('broken', 2, [url('broken')], ['p-6']); const pixel = ''; (async () => { - const browser = await chromium.launch({ headless: true, ...(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); + 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 }); @@ -52,7 +54,7 @@ const pixel = ' const page = await context.newPage(); const errors = [], requests = []; page.on('pageerror', e => errors.push(e.message)); - let broken = true, page2Fails = true, includeHistory = false; + let broken = true, page2Fails = true, includeHistory = false, fallbackWebpFails = true; const delayed = new Map(); await page.route('**/*', async route => { const pathname = new URL(route.request().url()).pathname; @@ -71,6 +73,10 @@ const pixel = ' } if (pathname === '/page4') return route.fulfill({ contentType: 'text/html', body: pageHtml(card('fresh', 5, [url('fresh')], ['p-9']), '') }); 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)); @@ -163,6 +169,26 @@ const pixel = ' await page.evaluate(() => InfinityWaterfall.reset({ nextUrl: '/empty' })); assert.equal(await page.evaluate(() => InfinityWaterfall.loadMore()), true, '合法的搜索末尾空批次能正常结束'); assert.equal(await page.locator('.pp-load-retry').isVisible(), false); + // 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; @@ -188,7 +214,7 @@ const pixel = ' assert.equal(new URL(page.url()).searchParams.get('album'), '1'); assert.deepEqual(errors, []); await context.close(); - console.log(`图册加载、降级和重试回归测试通过:${viewport.width}px`); + console.log(`图册加载、降级和重试回归测试通过:${browserName} ${viewport.width}px`); } } finally { await browser.close(); } })().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/gallery.cjs b/tests/gallery.cjs index df6bb9c..63cffc9 100644 --- a/tests/gallery.cjs +++ b/tests/gallery.cjs @@ -20,6 +20,7 @@ class Element { 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; } @@ -28,7 +29,7 @@ class Element { 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' ? e.tagName === 'IMG' + : 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)]); } @@ -50,7 +51,7 @@ const context = { albumForSrc: () => ({ album: { images: ['/a.webp', '/b.webp', '/c.webp'] }, idx: 0 }) }; vm.createContext(context); -vm.runInContext(['removeResponsivePicture', 'applyResponsive', 'inAlbumNav'].map(helper).join('\n'), 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'); @@ -126,6 +127,167 @@ context.stops = []; key('Tab'); assert.equal(focus, overlay, 'empty dialog is a 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. { diff --git a/tests/image-delete.php b/tests/image-delete.php new file mode 100644 index 0000000..5a0756e --- /dev/null +++ b/tests/image-delete.php @@ -0,0 +1,274 @@ +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), + ]; + } + 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_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), '后续孤儿清理仍保护历史引用'); + 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/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/typecho-integration.php b/tests/typecho-integration.php index 05492ab..67bf3fd 100644 --- a/tests/typecho-integration.php +++ b/tests/typecho-integration.php @@ -153,6 +153,7 @@ function failureTrigger(bool $enabled, string $field = 'tags'): void } $failed = null; + $fixtureFiles = []; try { // 使用官方建表 SQL,包含真实唯一约束及需要引号的驼峰列名。 $schema = file_get_contents($typecho . '/install/' . $dialect . '.sql'); @@ -408,6 +409,95 @@ function failureTrigger(bool $enabled, string $field = 'tags'): void foreach (glob($tmp . '/replica.sqlite*') ?: [] as $file) { @unlink($file); } } + // 真实删除事务:数据库字段失败、外层回滚都不能提前移除实体文件。 + $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), '最后一个引用删除后才清理共用变体文件'); + Database::query($db->delete(ImageRepository::table())->where('cid = ?', $cid)); ImageRepository::syncPostFields($cid, true); check(AdminRepository::field($cid, 'img') === '' && AdminRepository::field($cid, 'photo_ids') === '', '显式清空删除最后一张图的元数据'); @@ -459,6 +549,8 @@ function failureTrigger(bool $enabled, string $field = 'tags'): void } $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); diff --git a/usr/plugins/InfinityTime/Lib/ImageRepository.php b/usr/plugins/InfinityTime/Lib/ImageRepository.php index f4574ba..59d24d0 100644 --- a/usr/plugins/InfinityTime/Lib/ImageRepository.php +++ b/usr/plugins/InfinityTime/Lib/ImageRepository.php @@ -362,6 +362,14 @@ public static function updateVariants(int $rowId, array $result): void /** 主库上的原子字段替换;保存点不会提交或回滚调用方的其他写入。 */ 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 { self::lockMedia(); $db = \Typecho\Db::get(); @@ -376,11 +384,15 @@ public static function syncPostFields(int $cid, bool $clearEmpty = false): void $mysql = strpos($adapter, 'mysql') !== false; if ($mysql) { // MyISAM 等表忽略回滚,不能宣称字段替换是原子的;不自动 ALTER 用户表。 - $query = $db->select(); - $sql = $query->prepare('SHOW TABLE STATUS WHERE Name = ' . $query->quoteValue($db->getPrefix() . 'fields')); - $table = $db->fetchRow($write($sql)); - if (strcasecmp((string)($table['Engine'] ?? ''), 'InnoDB') !== 0) { - throw new \RuntimeException('字段同步需要 fields 表使用 InnoDB 事务引擎,请先备份并由管理员迁移表引擎'); + $tables = [$db->getPrefix() . 'fields']; + if ($mutatesImages) { $tables[] = self::table(); } + 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。 @@ -411,15 +423,24 @@ public static function syncPostFields(int $cid, bool $clearEmpty = false): void throw new \RuntimeException('无法安全检测数据库事务状态'); } if (!$active) { $write('BEGIN'); $owned = true; } - } elseif (strpos($adapter, 'sqlite') === false) { + } 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('当前数据库适配器不支持安全的字段同步事务'); } - // SQLite SAVEPOINT 可独立启动事务;嵌套 RELEASE 不会提交外层 BEGIN/SAVEPOINT。 + // 嵌套 RELEASE 不会提交外层 BEGIN/SAVEPOINT;只有 owned 才能删除实体文件。 $started = false; try { $write('SAVEPOINT ' . $savepoint); $started = true; - self::writePostFields($cid, $clearEmpty, $write); + $operation($write); $write('RELEASE SAVEPOINT ' . $savepoint); if ($owned) { $write('COMMIT'); } } catch (\Throwable $e) { @@ -435,6 +456,51 @@ public static function syncPostFields(int $cid, bool $clearEmpty = false): void } throw $e; } + return $owned; + } + + /** + * 删除图片行并同步文章字段。调用方先验证 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; } + 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; } + } + $write($db->delete(self::table())->where('id = ?', $rowId)->where('cid = ?', $cid)); + self::writePostFields($cid, true, $write); + $deleted = true; + }, true); + if ($deleted && $committed && $candidates) { + // 锁持续覆盖提交后的引用重查;任何异常都不能把已提交的删除伪报为失败。 + 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()); + } + } + return $deleted; } /** 按图片行的顺序同步路径与元数据,避免排序/删除后文章字段错位。 */ @@ -539,8 +605,8 @@ public static function removeFor(int $cid): void Database::query($db->delete(self::table())->where('cid = ?', $cid)); } - /** 仅处理真实上传目录内的普通文件;旧快照、软链接与新文件一律跳过。 */ - private static function orphanFingerprint(string $path): ?array + /** 仅接受白名单真实目录内的普通文件;孤儿扫描额外跳过宽限期内的新文件。 */ + private static function orphanFingerprint(string $path, bool $oldOnly = true): ?array { clearstatcache(true, $path); if (is_link($path) || !is_file($path)) { return null; } @@ -555,7 +621,7 @@ private static function orphanFingerprint(string $path): ?array if (!$inside) { return null; } $stat = @stat($path); // 保守宽限期:给升级前的上传进程及失败后的人工恢复留出时间。 - if (!$stat || $stat['mtime'] > time() - 3600) { return null; } + 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']]; } @@ -567,12 +633,28 @@ private static function referencedPaths(): array $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 (preg_match('#^(?:https?:)?//#i', $value)) { $value = (string)parse_url($value, PHP_URL_PATH); } if ($value === '') { return; } - $path = self::toAbs($value); - $referenced[$path] = true; - $real = realpath($path); - if ($real !== false) { $referenced[$real] = true; } + // 历史字段可含缓存参数、片段和 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; } + } + } }; // 只读引用路径;扫描孤儿文件不需要搬运可能很大的 EXIF/标题等元数据。 foreach (self::fetchRows($db, $db->select('original', 'full', 'thumb', 'mid', 'avif', 'mid_avif')->from(self::table())) as $row) { diff --git a/usr/plugins/InfinityTime/Lib/MediaProcessor.php b/usr/plugins/InfinityTime/Lib/MediaProcessor.php index 94ca2fc..e547fab 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(手机/平板用) diff --git a/usr/plugins/InfinityTime/assets/admin.js b/usr/plugins/InfinityTime/assets/admin.js index 39d6f52..7d6fa0e 100644 --- a/usr/plugins/InfinityTime/assets/admin.js +++ b/usr/plugins/InfinityTime/assets/admin.js @@ -109,13 +109,14 @@ 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 form.querySelector('[name="' + name + '"]'); } + 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(); @@ -149,7 +150,7 @@ var dt = new DataTransfer(); 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 discard() { Upload.sel.forEach(function (item) { if (item.url) URL.revokeObjectURL(item.url); }); @@ -177,50 +178,58 @@ 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; }); - var el = $('#pp-upload-summary'); - if (el) el.textContent = Upload.sel.length ? '本次 ' + Upload.sel.length + ' 张(' + humanSize(total) + '):已保存 ' + counts.done + ',待上传 ' + counts.queued + ',失败 ' + counts.failed : ''; + 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 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(); }); - wrap.appendChild(card); - }); + 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) { @@ -261,10 +270,10 @@ } function uploadOne(item) { return new Promise(function (resolve) { - if (!item.file) { item.state = 'failed'; item.error = '请重新选择此文件,浏览器刷新后不会保留文件内容'; persist(); render(); resolve(); return; } + 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(); render(); + 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 || ''); @@ -276,22 +285,28 @@ 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(); render(); resolve(); + activeProgress = 0; persist(); renderItem(item); resolve(); } xhr.open('POST', ENDPOINT, true); xhr.withCredentials = true; // 超时后的保存结果不确定:重试时保留原幂等键。 xhr.timeout = 180000; xhr.upload.onprogress = function (ev) { - if (settled || !ev.lengthComputable) return; - activeProgress = Math.min(0.95, ev.loaded / ev.total * 0.95); summary(); - if (ev.loaded >= ev.total && item.state === 'uploading') { item.state = 'converting'; persist(); render(); } + 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 = 'converting'; persist(); render(); } }; + 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) || '上传请求失败,请重试' }; @@ -316,6 +331,8 @@ 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()); diff --git a/usr/plugins/InfinityTime/panel.php b/usr/plugins/InfinityTime/panel.php index 3e36a78..9a9bca5 100644 --- a/usr/plugins/InfinityTime/panel.php +++ b/usr/plugins/InfinityTime/panel.php @@ -615,9 +615,17 @@ function pp_exif_summary(array $exif): string if ($rowId > 0) { $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']); - Database::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('没有权限删除该图片'); } diff --git a/usr/themes/InfinityTime/assets/js/init.js b/usr/themes/InfinityTime/assets/js/init.js index 6ffb1c6..6a371c1 100644 --- a/usr/themes/InfinityTime/assets/js/init.js +++ b/usr/themes/InfinityTime/assets/js/init.js @@ -53,9 +53,24 @@ var article = img.closest('.thumb'); var sources = Array.from((img.closest('picture') || document.createElement('picture')).querySelectorAll('source')) .map(function(source) { return { node: source, srcset: source.getAttribute('srcset') }; }); - var swapped = false, retry; + var swapped = false, avifFallback = false, retry; function failed() { if (swapped) return; + // 浏览器支持 AVIF 但文件损坏/缺失时,先尝试既有 WebP 候选。 + var avifSources = sources.filter(function(source) { + var candidates = source.node.getAttribute('srcset'); + if (source.node.getAttribute('type') !== 'image/avif' || !candidates) return false; + if (!img.currentSrc) return true; + return candidates.split(',').some(function(candidate) { + try { return new URL(candidate.trim().split(/\s+/)[0], document.baseURI).href === img.currentSrc; } + catch (e) { return false; } + }); + }); + if (!avifFallback && avifSources.length) { + avifFallback = true; + avifSources.forEach(function(source) { source.node.removeAttribute('srcset'); }); + return; + } swapped = true; img.removeAttribute('srcset'); sources.forEach(function(source) { source.node.removeAttribute('srcset'); }); @@ -67,7 +82,7 @@ retry.textContent = '缩略图加载失败 · 重试'; retry.addEventListener('click', function(event) { event.preventDefault(); event.stopPropagation(); - swapped = false; + swapped = false; avifFallback = false; retry.disabled = true; retry.textContent = '正在重试…'; sources.forEach(function(source) { if (source.srcset) source.node.setAttribute('srcset', source.srcset); }); diff --git a/usr/themes/InfinityTime/assets/js/lightbox.js b/usr/themes/InfinityTime/assets/js/lightbox.js index 2e05a9c..712c3a2 100644 --- a/usr/themes/InfinityTime/assets/js/lightbox.js +++ b/usr/themes/InfinityTime/assets/js/lightbox.js @@ -537,12 +537,19 @@ return; } // 跟随容器尺寸(poptrox 弹窗有放大动画,叠加 ResizeObserver 保证 Pannellum 画布尺寸正确) - const ro = new ResizeObserver(function () { - if (viewer && typeof viewer.setSize === 'function') { - try { viewer.setSize(pic.clientWidth || 0, pic.clientHeight || 0); } catch (e) {} - } - }); - try { ro.observe(pic); } catch (e) {} + // 此 API 是增强项:缺失时仍须保存 viewer 状态,以便轮询复用 + // 和关闭时销毁,不能在已隐藏原图之后中断挂载并反复创建画布。 + let ro = null; + if (typeof ResizeObserver === 'function') { + try { + ro = new ResizeObserver(function () { + if (viewer && typeof viewer.setSize === 'function') { + try { viewer.setSize(pic.clientWidth || 0, pic.clientHeight || 0); } catch (e) {} + } + }); + ro.observe(pic); + } catch (e) { if (ro) { try { ro.disconnect(); } catch (ignored) {} } ro = null; } + } setTimeout(function () { if (viewer && typeof viewer.setSize === 'function') { try { viewer.setSize(pic.clientWidth || 0, pic.clientHeight || 0); } catch (e) {} diff --git a/usr/themes/InfinityTime/assets/js/main.js b/usr/themes/InfinityTime/assets/js/main.js index 47a9d7b..77aa60f 100755 --- a/usr/themes/InfinityTime/assets/js/main.js +++ b/usr/themes/InfinityTime/assets/js/main.js @@ -548,6 +548,21 @@ document.addEventListener('DOMContentLoaded', function() { picture.remove(); } } + function fallbackAvif(img, src) { + var picture = img.parentNode; + if (img.__ppAvifFallbackSource === src || !picture || picture.tagName !== 'PICTURE' || + !picture.classList.contains('pp-picture') || !picture.querySelector('source[type="image/avif"]')) return false; + var source = picture.querySelector('source[type="image/avif"]'); + if (img.currentSrc && !(source.getAttribute('srcset') || '').split(',').some(function(candidate) { + try { return new URL(candidate.trim().split(/\s+/)[0], document.baseURI).href === img.currentSrc; } + catch (e) { return false; } + })) return false; // AVIF 未被选中:不能吞掉真正的 WebP 错误。 + // 不会在 AVIF 请求或解码失败时自动尝试 WebP。 + // 保留同一主图和 WebP srcset,让原有 load/尺寸回调完成这次加载。 + img.__ppAvifFallbackSource = src; + removeResponsivePicture(img); + return true; + } function applyResponsive(popup, img, requestedSrc) { if (!popup || !img) return; var src = requestedSrc || img.getAttribute('src') || ''; @@ -571,7 +586,7 @@ document.addEventListener('DOMContentLoaded', function() { return; } var webp = v.webp || []; - var avif = v.avif || []; + var avif = img.__ppAvifFallbackSource === src ? [] : (v.avif || []); var w = parseInt(v.w, 10) || img.naturalWidth || 0; var midW = Math.min(1600, w); var sizes = '(max-width: 900px) 100vw, min(1400px, calc(100vw - 100px))'; @@ -661,7 +676,7 @@ document.addEventListener('DOMContentLoaded', function() { function later(fn, delay) { timers.push(setTimeout(fn, delay)); } function cleanup() { img.removeEventListener('load', whenDecoded); - img.removeEventListener('error', failed); + img.removeEventListener('error', failed, true); if (observer) observer.disconnect(); timers.forEach(clearTimeout); if (popup.__imageCleanup === cleanup) popup.__imageCleanup = null; @@ -706,13 +721,19 @@ document.addEventListener('DOMContentLoaded', function() { if (img.decode) img.decode().then(gate, function() { if (img.naturalWidth > 0) gate(); else failed(); }); else gate(); } - function failed() { + function failed(event) { if (!isCurrent()) return; + if (fallbackAvif(img, full)) { + // 先于 Poptrox 的普通 error 处理器运行,避免它移除 load + // 回调并把一次可恢复的格式失败变成终态加载错误。 + if (event) event.stopImmediatePropagation(); + return; + } showImageError(popup, img); if (observer) observer.disconnect(); } img.addEventListener('load', whenDecoded); - img.addEventListener('error', failed); + img.addEventListener('error', failed, true); if (img.complete && img.naturalWidth > 0) whenDecoded(); // 错误可能早于 MutationObserver 绑定监听,因此补查一次。 else if (img.complete) later(function() { if (isCurrent() && img.complete && !img.naturalWidth) failed(); }, 0); diff --git a/usr/themes/InfinityTime/update.md b/usr/themes/InfinityTime/update.md index e2a5b82..9818253 100644 --- a/usr/themes/InfinityTime/update.md +++ b/usr/themes/InfinityTime/update.md @@ -2,6 +2,12 @@ ## 1.14.1 +- 图片删除先在同一事务提交记录与聚合字段,再安全清理未被其他记录/历史 URL 引用的文件;失败保留恢复机会。 +- 上传队列状态仅更新当前卡片,成功确认后释放本地 File / ObjectURL,避免大量重复 DOM 创建;失败项继续保留重试。 +- AVIF 缺失或解码失败先降级到 WebP;ResizeObserver 不可用时全景不再重复挂载。 +- 修复 GD 位图别名导致的内存重叠、极窄图片缩放为零;主 WebP 输出暂存校验后发布,编码失败保留旧图,明确拒绝编码器单边硬限制而不暗中降低全景分辨率。 +- 真实浏览器验证扩展到 Chromium / Firefox / WebKit,包含桌面和窄屏、坏格式回退、资源回收与维护状态机。 + - 修复连续重建复用过期清单:新任务重新扫描,未完成任务保留断点,任务标识拒绝迟到轮询;状态落盘失败明确提示。 - 图集聚合字段通过主库保存点原子同步,故障保留旧字段并保持外层事务;重建时同步错误计入失败。MySQL 非 InnoDB 字段表安全拒绝。 - 统一插件主库查询入口,修复 Typecho Mysqli 读写连接与插入标识边界;非插件文章清理从主库确认身份并正确路由事务。 From 53a4d9a9d72f5b52deb098688b2a55e6f009a169 Mon Sep 17 00:00:00 2001 From: Thregren <37279823+Thregren@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:30:53 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BF=9D=E6=8A=A4=E6=95=B4=E5=9B=BE?= =?UTF-8?q?=E9=9B=86=E4=BA=8B=E5=8A=A1=E5=88=A0=E9=99=A4=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=E5=AE=9A1.14.1=E7=9A=84=E5=8F=AF=E4=BF=A1CI?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/release-guard.cjs | 142 ++++++++++++++++++ .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 74 +++++++++ README.md | 8 +- tests/image-delete.php | 71 +++++++++ tests/release-guards.cjs | 90 +++++++++++ tests/typecho-integration.php | 72 +++++++++ .../InfinityTime/Lib/ImageRepository.php | 80 +++++++--- usr/plugins/InfinityTime/panel.php | 21 +-- usr/themes/InfinityTime/update.md | 2 +- 10 files changed, 528 insertions(+), 33 deletions(-) create mode 100644 .github/scripts/release-guard.cjs create mode 100644 tests/release-guards.cjs diff --git a/.github/scripts/release-guard.cjs b/.github/scripts/release-guard.cjs new file mode 100644 index 0000000..ec466fa --- /dev/null +++ b/.github/scripts/release-guard.cjs @@ -0,0 +1,142 @@ +'use strict'; +// 仅用于本次已授权的 v1.14.1;失败或部分发布必须人工核查,绝不覆盖资产。 +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.14.1'; +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.14.1'); + insist(plugin.match(/const VERSION = '([\d.]+)'/)?.[1] === VERSION, '插件版本不是 1.14.1'); + insist(JSON.parse(release).tag_name === VERSION, '版本清单不是 1.14.1'); +} +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; +} +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; + insist(response.ok, `GitHub API ${method} ${endpoint}: ${response.status}`); + 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, 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 0674be8..7207189 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,7 @@ jobs: 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/gallery-loading.cjs node tests/gallery-navigation.cjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d1b3d4..2360d68 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,69 @@ jobs: dist/infinitytime-theme.zip dist/infinitytime-plugin.zip body_path: dist/RELEASE_NOTES.md + + # 一次性授权范围:只允许目标仓库 main 的完整 CI 发布 1.14.1。 + release-1-14-1: + 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.14.1 + 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, '目标仓库不匹配'); + const get = async path => { + const response = await fetch(`https://api.github.com/repos/${repo}${path}`, { signal: AbortSignal.timeout(30000), headers: { ...(path.startsWith('/actions/') ? {} : { Authorization: `Bearer ${process.env.GH_TOKEN}` }), Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } }); + assert(response.ok, `GitHub API ${response.status}`); + 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 099b9d0..7b71bf2 100644 --- a/README.md +++ b/README.md @@ -125,11 +125,11 @@ CI 同时检查 PHP / JS 语法、版本一致性和更新记录,并在 PHP 7. 真实数据库测试仅在一次性测试库运行,例如:`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;非事务引擎会在写入前明确拒绝,需要管理员先备份并自行迁移,本插件不会自动改动站点表引擎。 +字段聚合同步使用主库保存点:失败保留完整旧字段,不提交或回滚调用方的其他事务。MySQL 的 `fields` 表必须使用 InnoDB(单图删除还检查图片表,整图集删除同时检查文章表);非事务引擎会在写入前明确拒绝,需要管理员先备份并自行迁移,本插件不会自动改动站点表引擎。 维护检查点有独立任务标识。未完成的同类任务继续原清单与进度,完成后再次开始会重新收集候选;迟到轮询不能覆盖新任务。磁盘写入失败会明确报错,重建字段失败计入失败数,可运行「重建尺寸字段」重试。首批已完成但响应丢失后,手动再次开始属于新任务,可能重复处理,但不会复用过期快照。 -单图删除将图片行和聚合字段放在同一事务中,确认自行提交后才清理无引用实体;调用方外层事务尚未结束时保留文件,由提交后的安全孤儿清理回收。数据库与文件系统没有跨系统原子提交:提交后崩溃、连接结果未知或清理失败可能留下孤儿,优先避免丢失可恢复文件。历史 URL 的查询参数、编码和点目录会保守纳入引用保护。 +单图删除将图片行和聚合字段放在同一事务中;整图集删除将图片行、图集字段及文章记录放在同一事务中,确认自行提交后才清理无引用实体;调用方外层事务尚未结束时保留文件,由提交后的安全孤儿清理回收。数据库与文件系统没有跨系统原子提交:提交后崩溃、连接结果未知或清理失败可能留下孤儿,优先避免丢失可恢复文件。历史 URL 的查询参数、编码和点目录会保守纳入引用保护。 上传中只更新当前照片卡片,服务器确认成功且返回图片标识后才释放本地文件资源,失败项保留重试。200 张隔离 DOM/XHR 样本的创建节点数从 1,124,203 降至 8,403,全部成功后的 ObjectURL 从 200 降至 0;这仅是测试样本的 DOM/资源数据,完整队列统计与持久化仍有遍历,不代表全部算法线性或线上耗时承诺。 @@ -232,3 +232,7 @@ git tag v1.7.0 && git push origin v1.7.0 > 若你基于本主题二次分发,请同时保留上述第三方项目的许可声明。 + +### 本次固定版本发布 + +1.14.1 使用额外的受控发布入口:只有本仓库 main 的完整 CI 成功且 SHA 仍为当前主线,才可创建 v1.14.1 与两份 ZIP。该入口固定版本,不能自动发布未来版本;已有完整发布只核验,不覆盖。匿名 CI 元数据查询、主线变化、版本不符或部分发布均会停止;任务失败后需核对 tag、draft 及资产状态。原手动 tag 发布流程保持。 diff --git a/tests/image-delete.php b/tests/image-delete.php index 5a0756e..e2fd4fa 100644 --- a/tests/image-delete.php +++ b/tests/image-delete.php @@ -111,6 +111,7 @@ function snapshot(): array { 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 { @@ -134,6 +135,7 @@ function failDelete(int $id, int $cid, string $message): void { } 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); @@ -258,6 +260,75 @@ function failDelete(int $id, int $cid, string $message): void { $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'], '删除、同步和引用检查固定主库'); diff --git a/tests/release-guards.cjs b/tests/release-guards.cjs new file mode 100644 index 0000000..af73994 --- /dev/null +++ b/tests/release-guards.cjs @@ -0,0 +1,90 @@ +'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.14.1', () => guardVersions('@version 1.14.1', "const VERSION = '1.14.1'", '{"tag_name":"1.14.1"}')); +for (const index of [0, 1, 2]) test(`拒绝版本字段 ${index} 不一致或未来版本`, () => { + const values = ['@version 1.14.1', "const VERSION = '1.14.1'", '{"tag_name":"1.14.1"}']; + values[index] = values[index].replace('1.14.1', '1.14.2'); + assert.throws(() => guardVersions(...values)); +}); +const digests = Object.fromEntries(ASSETS.map(name => [name, 'f'.repeat(64)])); +function complete() { return { tag_name: 'v1.14.1', 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.14.2' }], ['缺资产', 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 }); } +}); diff --git a/tests/typecho-integration.php b/tests/typecho-integration.php index 67bf3fd..caa4d6f 100644 --- a/tests/typecho-integration.php +++ b/tests/typecho-integration.php @@ -498,6 +498,78 @@ function failureTrigger(bool $enabled, string $field = 'tags'): void 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') === '', '显式清空删除最后一张图的元数据'); diff --git a/usr/plugins/InfinityTime/Lib/ImageRepository.php b/usr/plugins/InfinityTime/Lib/ImageRepository.php index 59d24d0..1c73a6c 100644 --- a/usr/plugins/InfinityTime/Lib/ImageRepository.php +++ b/usr/plugins/InfinityTime/Lib/ImageRepository.php @@ -369,7 +369,7 @@ public static function syncPostFields(int $cid, bool $clearEmpty = false): void } /** 共用字段同步事务协议;返回 true 仅表示本方法已提交自己开启的事务。 */ - private static function fieldTransaction(callable $operation, bool $mutatesImages = false): bool + private static function fieldTransaction(callable $operation, bool $mutatesImages = false, bool $mutatesContents = false): bool { self::lockMedia(); $db = \Typecho\Db::get(); @@ -386,6 +386,7 @@ private static function fieldTransaction(callable $operation, bool $mutatesImage // MyISAM 等表忽略回滚,不能宣称字段替换是原子的;不自动 ALTER 用户表。 $tables = [$db->getPrefix() . 'fields']; if ($mutatesImages) { $tables[] = self::table(); } + if ($mutatesContents) { $tables[] = $db->getPrefix() . 'contents'; } foreach ($tables as $name) { $query = $db->select(); $sql = $query->prepare('SHOW TABLE STATUS WHERE Name = ' . $query->quoteValue($name)); @@ -474,33 +475,70 @@ public static function deleteImage(int $rowId, int $cid): bool $row = $db->fetchRow($write($db->select()->from(self::table()) ->where('id = ?', $rowId)->where('cid = ?', $cid)->limit(1))); if (!$row) { return; } - 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; } - } + $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 && $candidates) { - // 锁持续覆盖提交后的引用重查;任何异常都不能把已提交的删除伪报为失败。 - 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'); } - } + 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()); } + } catch (\Throwable $e) { + Plugin::log('image delete cleanup deferred: ' . $e->getMessage()); } - return $deleted; } /** 按图片行的顺序同步路径与元数据,避免排序/删除后文章字段错位。 */ diff --git a/usr/plugins/InfinityTime/panel.php b/usr/plugins/InfinityTime/panel.php index 9a9bca5..588ef93 100644 --- a/usr/plugins/InfinityTime/panel.php +++ b/usr/plugins/InfinityTime/panel.php @@ -698,16 +698,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); - Database::query($db->delete($prefix . 'contents')->where('cid = ?', $cid)); - Database::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') { diff --git a/usr/themes/InfinityTime/update.md b/usr/themes/InfinityTime/update.md index 9818253..f7aa5f0 100644 --- a/usr/themes/InfinityTime/update.md +++ b/usr/themes/InfinityTime/update.md @@ -2,7 +2,7 @@ ## 1.14.1 -- 图片删除先在同一事务提交记录与聚合字段,再安全清理未被其他记录/历史 URL 引用的文件;失败保留恢复机会。 +- 单图及整图集删除先在同一事务提交记录与聚合字段,再安全清理未被其他记录/历史 URL 引用的文件;失败保留恢复机会。 - 上传队列状态仅更新当前卡片,成功确认后释放本地 File / ObjectURL,避免大量重复 DOM 创建;失败项继续保留重试。 - AVIF 缺失或解码失败先降级到 WebP;ResizeObserver 不可用时全景不再重复挂载。 - 修复 GD 位图别名导致的内存重叠、极窄图片缩放为零;主 WebP 输出暂存校验后发布,编码失败保留旧图,明确拒绝编码器单边硬限制而不暗中降低全景分辨率。