From a4b3db4e5d841816f1e1d863f73f1b907b210b0f Mon Sep 17 00:00:00 2001 From: Thregren Date: Mon, 14 Sep 2026 15:49:05 +0800 Subject: [PATCH 01/24] docs: record v1.13.4 release and server cleanup --- ...\205\344\274\230\345\214\226\346\270\205\345\215\225.md" | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git "a/\345\276\205\344\274\230\345\214\226\346\270\205\345\215\225.md" "b/\345\276\205\344\274\230\345\214\226\346\270\205\345\215\225.md" index e094afc..8fc22c6 100644 --- "a/\345\276\205\344\274\230\345\214\226\346\270\205\345\215\225.md" +++ "b/\345\276\205\344\274\230\345\214\226\346\270\205\345\215\225.md" @@ -2,7 +2,7 @@ > 这是针对当前 `InfinityTime`(无限时光)主题 + 插件的后续优化点清单。已做的基础清理(移除死 JS/CSS/标记、未引用资源、`detectTools` 缓存、图集列表合并查询)已在代码中完成;以下为**尚未处理、按优先级排序**的事项。 -## 2026-09-14 代码清理与健壮性修复(v1.13.4,待发布) +## 2026-09-14 代码清理与健壮性修复(v1.13.4) - HTML 白名单先清洗后代再展开非白名单容器;无 DOM 扩展时降级为转义后的文字,避免保留事件属性。 - 链接协议校验覆盖实体编码的制表符、换行和回车;关于及联系方式在前台输出时再次过滤历史值。联系方式遇到错误 JSON 类型时安全跳过。 @@ -12,7 +12,9 @@ - 图集字段同步包含图片和缩略图地址;删除单图后同步,删除最后一张时清空;无仓库记录的历史图集默认保持原字段。 - 回归测试覆盖嵌套 HTML、浏览器重新解析、无 DOM 降级、混合图片重建、文件引用清理、排序/删除字段同步、上传预览及无 DataTransfer 提交,并纳入 CI。 -验证范围:PHP/JS 语法、纯逻辑测试、使用 DB/编码器替身的仓库测试、模拟 DOM 的后台上传测试。本轮尚未运行线上后台端到端操作,也未发布或同步服务器。 +验证范围:PHP/JS 语法、纯逻辑测试、使用 DB/编码器替身的仓库测试、模拟 DOM 的后台上传测试。已发布 GitHub Release `v1.13.4`(提交 `5b482e1`),并同步到服务器 `photo.thregren.world`:8 个功能文件校验和一致,首页 200,主题资源 200。**尚未在线上后台实际点击验证**上传、排序、重建与删除流程。 + +同步服务器时顺带清理:删除主题目录残留的 `index.php.bak.*`(可被当作静态文件下载)、Web 根目录下的插件/主题 zip 安装包与 `.user.ini.bak`,删除 8 个 nginx vhost 旧备份(保留生效配置并 `nginx -t` 通过),数据库快照移到 Web 根目录外的 `/www/backup/infinitytime/`。 ### 后续仍需处理 From ff03290887e8c451bf74472b5ec5e89b4676855e Mon Sep 17 00:00:00 2001 From: Thregren <37279823+Thregren@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:12:59 +0800 Subject: [PATCH 02/24] fix: harden media maintenance and accessible gallery lifecycle --- .github/workflows/ci.yml | 17 ++ README.md | 13 ++ tests/gallery-browser.cjs | 148 ++++++++++++ tests/gallery.cjs | 219 ++++++++++++++++++ tests/migration.php | 90 +++++++ tests/repository.php | 64 ++++- .../InfinityTime/Lib/ImageRepository.php | 156 ++++++++++--- usr/plugins/InfinityTime/Plugin.php | 36 +-- usr/plugins/InfinityTime/panel.php | 72 +++--- usr/themes/InfinityTime/assets/css/main.css | 33 +++ .../assets/js/jquery.poptrox.min.js | 4 +- usr/themes/InfinityTime/assets/js/lightbox.js | 13 +- usr/themes/InfinityTime/assets/js/main.js | 165 ++++++++++++- usr/themes/InfinityTime/index.php | 10 +- usr/themes/InfinityTime/update.md | 9 + 15 files changed, 941 insertions(+), 108 deletions(-) create mode 100644 tests/gallery-browser.cjs create mode 100644 tests/gallery.cjs create mode 100644 tests/migration.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46ed88b..eafa288 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,9 +36,11 @@ jobs: php tests/test.php php tests/sanitizer-fallback.php php tests/repository.php + php tests/migration.php php tests/upload-fallback.php php tests/theme-output.php node tests/admin-upload.cjs + node tests/gallery.cjs - name: Version consistency run: | @@ -53,3 +55,18 @@ jobs: run: | THEME_V=$(sed -n 's/.*@version \([0-9][0-9.]*\).*/\1/p' usr/themes/InfinityTime/index.php | head -1) grep -q "^## $THEME_V$" usr/themes/InfinityTime/update.md + + browser: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install isolated browser test dependencies + run: | + npm install --no-save --package-lock=false playwright@1.62.1 + npx playwright install --with-deps chromium + - name: Gallery browser regressions + run: node tests/gallery-browser.cjs diff --git a/README.md b/README.md index e25b529..f1be6be 100644 --- a/README.md +++ b/README.md @@ -69,13 +69,25 @@ php tests/test.php php tests/sanitizer-fallback.php php tests/repository.php +php tests/migration.php php tests/upload-fallback.php php tests/theme-output.php node tests/admin-upload.cjs +node tests/gallery.cjs +``` + +浏览器回归(Playwright 1.62.1 + Chromium;页面与资源请求全部由测试拦截,不访问线上站点): + +```sh +npm install --no-save --package-lock=false playwright@1.62.1 +npx playwright install chromium +node tests/gallery-browser.cjs ``` CI 同时检查 PHP / JS 语法、版本一致性和更新记录。仓库测试使用隔离临时文件及 DB/编码器替身;后台脚本测试模拟 DOM,覆盖预览、拖拽、移除和无 `DataTransfer` 时的提交顺序。这些检查不能替代真实 Typecho 后台及图片编码器的端到端验证。 +维护与上传现在通过同一上传根目录内的文件锁互斥,忙时提示稍后重试。孤儿清理只处理至少一小时前的文件,恢复任务时重新核验数据库引用与文件身份;旧版任务快照会安全跳过,重新启动清理即可。文件锁需要底层文件系统支持可靠的 `flock`(多节点部署需共用支持锁的上传卷)。 + ### 后端 - PHP ≥ 7.4(推荐 8.x)+ Typecho 1.3;`php-gd`(WebP/缩放)、`php-exif`、可选 `php-imagick` / ImageMagick / `heif-convert`。 - 存储:独立表 `typecho_infinitytime_images`(兼容 MySQL / SQLite / PostgreSQL),图集复用 Typecho `contents` + `fields`。 @@ -168,3 +180,4 @@ git tag v1.7.0 && git push origin v1.7.0 - 本项目未引入任何新的第三方运行时库;灯箱 / EXIF / 瀑布流的动效基于 jQuery 内置缓动与 CSS 缓动实现,不依赖 GSAP、Three.js 等额外库。 > 若你基于本主题二次分发,请同时保留上述第三方项目的许可声明。 + diff --git a/tests/gallery-browser.cjs b/tests/gallery-browser.cjs new file mode 100644 index 0000000..e20118e --- /dev/null +++ b/tests/gallery-browser.cjs @@ -0,0 +1,148 @@ +// Run with Playwright installed: node tests/gallery-browser.cjs +// All page/assets/image requests are intercepted; no live site is contacted. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium } = require('playwright'); +const theme = path.resolve(__dirname, '../usr/themes/InfinityTime'); +const origin = 'https://gallery.test'; +const url = name => origin + '/' + name + '.webp'; +const attr = value => JSON.stringify(value).replace(/&/g, '&').replace(/'/g, '''); +function card(id, images, titles, variants = [], panos = []) { + return `
Album ${id}

Album ${id}

`; +} +const fixture = ` + +
+${card('a', [url('a'), url('plain'), url('pano')], ['Sunrise', 'Plain landscape', 'Panorama'], [{ w: 2400, webp: [url('a')], avif: [origin + '/a.avif'] }], [0, 0, 1])} +${card('b', [url('b'), url('b2')], ['', 'Blue sky'])} +
+ +${['jquery.min', 'jquery.poptrox.min', 'browser.min', 'breakpoints.min', 'lightbox', 'main'].map(name => ``).join('')} +`; +const image = ''; +(async () => { + const browser = await chromium.launch({ headless: true, ...(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 }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', err => errors.push(err.message)); + const releaseSlow = []; + await page.route('**/*', async route => { + const pathname = new URL(route.request().url()).pathname; + if (pathname === '/') return route.fulfill({ contentType: 'text/html', body: fixture }); + if (pathname.startsWith('/assets/')) { + const filename = path.join(theme, pathname.slice(1)); + if (fs.existsSync(filename)) return route.fulfill({ path: filename }); + return route.fulfill({ status: 404, body: '' }); + } + if (pathname === '/slow.webp') await new Promise(resolve => { releaseSlow.push(resolve); }); + return route.fulfill({ contentType: 'image/svg+xml', body: image }); + }); + await page.goto(origin); + await page.waitForFunction(() => typeof window.__rebindPoptrox === 'function'); + async function ready(name) { + await page.waitForFunction(expected => { + const p = document.querySelector('.poptrox-popup'), img = p && p.querySelector('.pic img'); + return img && img.getAttribute('src') === expected && img.complete && img.naturalWidth > 0 && !p.classList.contains('loading'); + }, url(name)); + await page.waitForTimeout(500); // Poptrox's 420ms fade completes after loading. + } + async function close() { + await page.keyboard.press('Escape'); + await page.waitForFunction(() => getComputedStyle(document.querySelector('.poptrox-overlay')).display === 'none'); + } + async function open(id, key = 'Enter') { + await page.locator('#' + id).focus(); + await page.keyboard.press(key); + await page.waitForFunction(() => document.querySelector('.poptrox-overlay').getAttribute('aria-hidden') === 'false'); + } + const handlerCount = () => page.evaluate(() => { + const events = jQuery._data(window, 'events') || {}; + return Object.values(events).flat().filter(h => /^poptrox\d+$/.test(h.namespace || '')).length; + }); + const initialHandlers = await handlerCount(); + assert.equal(initialHandlers, 3, 'one instance has resize, orientation and keyboard handlers'); + assert.equal(await page.locator('#a').getAttribute('role'), 'button'); + assert.equal(await page.locator('#a').getAttribute('tabindex'), '0'); + await open('a'); await ready('a'); + assert.equal(await page.locator('.poptrox-overlay').getAttribute('role'), 'dialog'); + assert.equal(await page.locator('.poptrox-overlay').getAttribute('aria-modal'), 'true'); + assert.ok(await page.locator('#wrapper').evaluate(el => el.inert)); + assert.match(await page.locator('.pic img').getAttribute('alt'), /Sunrise/); + assert.ok(await page.locator('.pic img').evaluate(el => el.currentSrc.endsWith('/a.avif'))); + for (let i = 0; i < 10; i++) { + await page.keyboard.press(i % 2 ? 'Shift+Tab' : 'Tab'); + assert.ok(await page.evaluate(() => document.querySelector('.poptrox-overlay').contains(document.activeElement)), 'focus remains in modal'); + } + await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight'); await ready('plain'); + assert.equal(await page.locator('.pic picture').count(), 0, 'AVIF to unvarianted image removes picture'); + assert.ok(await page.locator('.pic img').evaluate(el => el.currentSrc.endsWith('/plain.webp'))); + assert.match(await page.locator('.pic img').getAttribute('alt'), /Plain landscape/); + await page.keyboard.press('ArrowLeft'); await ready('a'); + // Jump directly from AVIF to panorama on the reused image, exercising that fallback too. + await page.locator('.pic img').evaluate((el, src) => el.setAttribute('src', src), url('pano')); + await ready('pano'); + assert.equal(await page.locator('.pic picture').count(), 0, 'AVIF to panorama removes picture'); + await page.keyboard.press('ArrowRight'); await ready('b'); + assert.match(await page.locator('.pic img').getAttribute('alt'), /Album b/); + await page.locator('.pp-mnav-next').focus(); await page.keyboard.press('Space'); await ready('b2'); + assert.match(await page.locator('.pic img').getAttribute('alt'), /Blue sky/, 'empty first title preserves per-image alignment'); + if (viewport.width < 900) { + const handle = page.locator('.exif-dock-handle'); + await handle.click(); + assert.equal(await handle.getAttribute('aria-expanded'), 'true'); + assert.equal(await page.locator('.poptrox-overlay').getAttribute('aria-hidden'), 'false', 'drawer click does not close modal'); + await handle.focus(); await page.keyboard.press('Space'); + assert.equal(await handle.getAttribute('aria-expanded'), 'false', 'drawer keyboard activation remains local'); + assert.equal(await page.locator('.pic img').getAttribute('src'), url('b2'), 'Space does not trigger Poptrox previous'); + } + await close(); + assert.equal(await page.evaluate(() => document.activeElement.id), 'a', 'restores original gallery opener'); + assert.equal(await page.locator('#wrapper').getAttribute('inert'), null); + assert.equal(await page.locator('#preexisting-hidden').getAttribute('inert'), '', 'preserves preexisting inert state'); + await open('a', 'Space'); await ready('a'); + // Closing during the custom image transition cannot resurrect the old image. + await page.keyboard.press('ArrowRight'); await close(); + await open('b'); await ready('b'); + assert.equal(await page.locator('.pic img').getAttribute('src'), url('b')); + await close(); + // Cancel the vendor opening fade before its queued switch callback runs. + await open('a'); await close(); + await page.waitForTimeout(700); + assert.equal(await page.locator('.poptrox-popup').isVisible(), false); + assert.equal(await page.locator('.pic img').count(), 0); + for (let i = 0; i < 4; i++) await page.evaluate(() => window.__rebindPoptrox()); + assert.equal(await handlerCount(), initialHandlers, 'rebind does not accumulate window handlers'); + assert.equal(await page.locator('.pp-mobile-nav').count(), 1, 'shared controls survive without duplication'); + await open('a'); await ready('a'); + await page.evaluate(html => { + document.querySelector('#main').insertAdjacentHTML('beforeend', html); + window.__rebindPoptrox(); + }, card('new', [url('new')], ['New card'])); + assert.equal(await page.locator('#new').getAttribute('role'), null, 'open dialog defers rebind'); + await close(); + await page.waitForFunction(() => document.querySelector('#new').getAttribute('role') === 'button'); + await open('new'); await ready('new'); await close(); + assert.equal(await handlerCount(), initialHandlers); + // Late load completion after Close must not restart Poptrox or show controls. + await page.evaluate(html => { document.querySelector('#main').insertAdjacentHTML('beforeend', html); window.__rebindPoptrox(); }, card('slow', [url('slow')], ['Slow card'])); + await open('slow'); + await page.waitForFunction(expected => { const p = document.querySelector('.poptrox-popup'), img = p.querySelector('.pic img'); return p.classList.contains('loading') && img && img.getAttribute('src') === expected && !img.complete; }, url('slow')); + await close(); + releaseSlow.forEach(resolve => resolve()); + await page.waitForTimeout(900); + assert.equal(await page.locator('.poptrox-popup').isVisible(), false); + assert.equal(await page.locator('.pic img').count(), 0); + await open('new'); await ready('new'); await close(); + assert.deepEqual(errors, [], 'no uncaught script errors'); + await context.close(); + console.log(`Gallery browser regressions passed at ${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 new file mode 100644 index 0000000..0bea410 --- /dev/null +++ b/tests/gallery.cjs @@ -0,0 +1,219 @@ +// Dependency-free functional regressions for the real gallery helpers and handlers. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const source = fs.readFileSync(require.resolve('../usr/themes/InfinityTime/assets/js/main.js'), 'utf8'); +function helper(name) { + const start = source.indexOf(' function ' + name + '('); + assert.ok(start >= 0, name + ' exists'); + return source.slice(start, source.indexOf('\n }', start) + 6); +} +class Element { + constructor(tag, cls = '') { + this.tagName = tag.toUpperCase(); this.children = []; this.attrs = {}; this.style = {}; + this.classes = new Set(cls.split(' ').filter(Boolean)); this.isConnected = true; + this.classList = { contains: v => this.classes.has(v), add: v => this.classes.add(v), remove: v => this.classes.delete(v) }; + } + set className(v) { this.classes = new Set(v.split(' ')); } + get className() { return [...this.classes].join(' '); } + get parentNode() { return this.parent; } + appendChild(el) { el.remove(); el.parent = this; this.children.push(el); return el; } + insertBefore(el, before) { el.remove(); el.parent = this; this.children.splice(this.children.indexOf(before), 0, el); } + remove() { if (this.parent) { this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null; } } + setAttribute(k, v) { this.attrs[k] = String(v); } + getAttribute(k) { return this.attrs[k] ?? null; } + hasAttribute(k) { return k in this.attrs; } + removeAttribute(k) { delete this.attrs[k]; } + contains(el) { return this === el || this.children.some(c => c.contains(el)); } + querySelectorAll(selector) { + const matches = e => selector === 'picture.pp-picture' ? e.tagName === 'PICTURE' && e.classList.contains('pp-picture') + : selector === 'source[type="image/avif"]' ? e.tagName === 'SOURCE' && e.type === 'image/avif' + : selector === '.pic img' ? e.tagName === 'IMG' + : selector.startsWith('.') ? e.classList.contains(selector.slice(1)) : false; + return this.children.flatMap(child => [...(matches(child) ? [child] : []), ...child.querySelectorAll(selector)]); + } + querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } +} +const popup = new Element('div', 'poptrox-popup'); +const pic = popup.appendChild(new Element('div', 'pic')); +const img = pic.appendChild(new Element('img')); +const timers = []; +const context = { + document: { createElement: tag => new Element(tag) }, + variantMap: { + '/a.webp': { w: 2400, webp: ['/a.webp', '/a-small.webp'], avif: ['/a.avif', '/a-small.avif'] }, + '/b.webp': { w: 2200, webp: ['/b.webp'], avif: ['/b.avif'] }, + '/webp-only.webp': { w: 2000, webp: ['/webp-only.webp'] } + }, + isPanoUrl: src => src === '/pano.webp', normUrl: src => src.split('?')[0], + setTimeout: fn => timers.push(fn), window: {}, isPopupActive: true, + albumForSrc: () => ({ album: { images: ['/a.webp', '/b.webp', '/c.webp'] }, idx: 0 }) +}; +vm.createContext(context); +vm.runInContext(['removeResponsivePicture', 'applyResponsive', 'inAlbumNav'].map(helper).join('\n'), context); +function show(src) { img.setAttribute('src', src); context.applyResponsive(popup, img); } +show('/a.webp'); +assert.equal(img.parentNode.tagName, 'PICTURE'); +assert.match(img.parentNode.querySelector('source[type="image/avif"]').getAttribute('srcset'), /a\.avif/); +show('/legacy.webp'); +assert.equal(img.parentNode, pic, 'no-variant fallback unwraps the reused image'); +assert.equal(pic.querySelectorAll('picture.pp-picture').length, 0); +assert.equal(img.getAttribute('srcset'), null); +assert.equal(img.getAttribute('sizes'), null); +show('/a.webp'); show('/pano.webp'); +assert.equal(img.parentNode, pic, 'panorama fallback removes the old AVIF source'); +assert.equal(img.getAttribute('srcset'), null); +show('/a.webp'); show('/b.webp'); +assert.equal(pic.querySelectorAll('picture.pp-picture').length, 1); +assert.equal(img.parentNode.querySelector('source[type="image/avif"]').getAttribute('srcset'), '/b.avif 2200w'); +show('/webp-only.webp'); +assert.equal(img.parentNode, pic, 'WebP-only variants unwrap old AVIF picture'); +assert.equal(img.getAttribute('srcset'), '/webp-only.webp 2000w'); +show('/a.webp'); +img.remove(); pic.appendChild(img); show('/legacy.webp'); +assert.equal(pic.querySelectorAll('picture.pp-picture').length, 0, 'detached-image wrappers are removed'); + +show('/a.webp'); +assert.equal(context.inAlbumNav(popup, 1), true); +assert.equal(context.inAlbumNav(popup, 1), true, 'rapid clicks are consumed, not delegated across albums'); +assert.equal(timers.length, 2, 'a repeated click schedules no extra transition'); +timers[1](); +assert.equal(img.getAttribute('src'), '/b.webp'); +timers[0](); +context.inAlbumNav(popup, 1); +context.isPopupActive = false; +timers[3](); +assert.equal(img.getAttribute('src'), '/b.webp', 'closing cancels deferred image switch'); +context.isPopupActive = true; +popup.__switchSeq++; +timers[3](); +assert.equal(img.getAttribute('src'), '/b.webp', 'reopening cannot revive the previous session callback'); +popup.__switching = false; +popup.classList.add('loading'); +assert.equal(context.inAlbumNav(popup, 1), true, 'loading consumes navigation'); + +// Run the actual delegated keyboard handler, without exporting production internals. +const keyStart = source.indexOf(" document.addEventListener('keydown', function(e) {"); +const keyEnd = source.indexOf(' }, true);', keyStart) + ' }, true);'.length; +let handler, clicks = [], focus; +const opener = { click: () => clicks.push('open') }; +const button = { closest: () => null }; +const overlay = { querySelector: () => popup, focus: () => { focus = overlay; } }; +popup.querySelector = selector => ({ click: () => clicks.push(selector) }); +Object.assign(context, { + document: { addEventListener: (_, fn) => { handler = fn; }, querySelector: () => overlay, activeElement: null }, + dialogTabStops: () => context.stops || [], + $: () => ({ trigger: name => clicks.push(name) }) +}); +vm.runInContext(source.slice(keyStart, keyEnd), context); +function key(value, target = button, shiftKey = false) { + const event = { key: value, target, shiftKey, preventDefault() { this.prevented = true; }, stopImmediatePropagation() { this.stopped = true; } }; + handler(event); return event; +} +context.isPopupActive = false; +assert.ok(key('Enter', { closest: () => opener }).prevented); +assert.ok(key(' ', { closest: () => opener }).prevented); +assert.deepEqual(clicks, ['open', 'open']); +context.isPopupActive = true; +key('ArrowRight'); key('ArrowLeft'); +assert.deepEqual(clicks.slice(-2), ['.nav-next', '.nav-previous'], 'arrows use the same within-album click path'); +const first = { focus: () => { focus = first; } }, last = { focus: () => { focus = last; } }; +context.stops = [first, last]; context.document.activeElement = last; +key('Tab'); assert.equal(focus, first, 'Tab wraps inside the dialog'); +context.document.activeElement = first; +key('Tab', button, true); assert.equal(focus, last, 'Shift+Tab wraps inside the dialog'); +context.stops = []; key('Tab'); assert.equal(focus, overlay, 'empty dialog is a safe focus fallback'); +key('Escape'); assert.equal(clicks.at(-1), 'poptrox_close'); +console.log('Gallery responsive fallback, navigation cancellation, and keyboard regressions passed'); + +// Execute the actual vendor integration with queued-animation/event doubles. +// This covers the close-before-open / late-load races and per-instance cleanup. +{ + const animations = []; + const windowNode = new Element('window'), bodyNode = new Element('body'); + const root = bodyNode.appendChild(new Element('main')); + const anchor = root.appendChild(new Element('a')); + anchor.setAttribute('href', '/first.webp'); anchor.appendChild(new Element('img')); + function descendants(node) { return node.children.flatMap(c => [c, ...descendants(c)]); } + function matches(node, selector) { return selector && (selector[0] === '.' ? node.classes.has(selector.slice(1)) : node.tagName === selector.toUpperCase()); } + class Collection extends Array { + constructor(items = []) { super(); this.push(...items); } + css(key, value) { if (typeof key === 'object') this.forEach(n => Object.assign(n.style, key)); else if (value !== undefined) this.forEach(n => n.style[key] = value); return this; } + attr(key, value) { if (value === undefined) return this[0]?.getAttribute(key); this.forEach(n => n.setAttribute(key, value)); return this; } + removeAttr(key) { this.forEach(n => n.removeAttribute(key)); return this; } + prop(key, value) { if (value === undefined) return this[0]?.[key]; this.forEach(n => n[key] = value); return this; } + data(key, value) { this.forEach(n => n.data ||= {}); if (value === undefined) return this[0]?.data[key]; this.forEach(n => n.data[key] = value); return this; } + find(selector) { return new Collection(this.flatMap(n => descendants(n).filter(c => matches(c, selector)))); } + children() { return new Collection(this.flatMap(n => n.children)); } + add(other) { return new Collection([...this, ...other]); } + each(fn) { this.forEach((n, i) => fn.call(n, i)); return this; } + append(other) { const nodes = $(other); this.forEach(n => nodes.forEach(c => n.appendChild(c))); return this; } + appendTo(parent) { $(parent).append(this); return this; } + prepend() { return this; } + html() { return this; } + width() { return arguments.length ? this : 640; } + height() { return arguments.length ? this : 400; } + outerWidth() { return 640; } + outerHeight() { return 400; } + show() { this.forEach(n => n.visible = true); return this; } + hide() { this.forEach(n => n.visible = false); return this; } + is() { return !!this[0]?.visible; } + addClass(name) { this.forEach(n => n.classes.add(name)); return this; } + removeClass(name) { this.forEach(n => n.classes.delete(name)); return this; } + detach() { this.forEach(n => n.remove()); return this; } + remove() { return this.detach(); } + on(names, callback) { if (typeof callback !== 'function') return this; this.forEach(n => { n.events ||= []; names.split(' ').forEach(name => n.events.push({ name, callback })); }); return this; } + off(name) { this.forEach(n => n.events = (n.events || []).filter(e => name[0] === '.' ? !e.name.endsWith(name) : e.name.split('.')[0] !== name)); return this; } + trigger(name, args = []) { this.forEach(n => (n.events || []).filter(e => e.name.split('.')[0] === name).forEach(e => e.callback.call(n, { preventDefault() {}, stopPropagation() {} }, ...args))); return this; } + fadeTo(speed, opacity, callback) { this.show(); this.forEach(n => animations.push({ node: n, callback })); return this; } + fadeIn(speed, callback) { return this.fadeTo(speed, 1, callback); } + fadeOut(speed, callback) { this.forEach(n => animations.push({ node: n, callback: () => { n.visible = false; if (callback) callback(); } })); return this; } + animate(properties, speed, easing, callback) { return this.fadeTo(speed, 1, callback); } + stop() { this.forEach(n => { for (let i = animations.length - 1; i >= 0; i--) if (animations[i].node === n) animations.splice(i, 1); }); return this; } + } + // Array methods must produce plain arrays, not call our collection constructor. + Object.defineProperty(Collection, Symbol.species, { value: Array }); + function $(value) { + if (value instanceof Collection) return value; + if (value === undefined || value === null) return new Collection(); + if (value === 'body') return new Collection([bodyNode]); + if (typeof value !== 'string') return new Collection([value]); + const tag = value.match(/^<(\w+)/)?.[1] || 'div'; + const cls = value.match(/class="([^"]+)"/)?.[1] || ''; + const node = new Element(tag, cls); + if (cls === 'poptrox-popup') { + for (const c of ['pic', 'closer', 'nav-previous', 'nav-next']) node.appendChild(new Element('div', c)); + } + return new Collection([node]); + } + $.fn = Collection.prototype; $.extend = Object.assign; + const vendorWindow = windowNode; vendorWindow.location = { protocol: 'https:' }; + vm.runInNewContext(fs.readFileSync(require.resolve('../usr/themes/InfinityTime/assets/js/jquery.poptrox.min.js'), 'utf8'), { + jQuery: $, window: vendorWindow, document: { createElement: tag => new Element(tag) } + }); + let opens = 0, closes = 0; + $(root).poptrox({ usePopupDefaultStyling: false, usePopupLoader: false, usePopupCaption: false, usePopupNav: true, usePopupForceClose: true, + onPopupOpen() { opens++; }, onPopupClose() { closes++; } }); + const dialog = $(bodyNode).find('.poptrox-popup'), picture = dialog.find('.pic'); + assert.equal(windowNode.events.length, 3); + function flush() { let n = 0; while (animations.length) { assert.ok(n++ < 100, 'animation queue terminates'); animations.shift().callback?.(); } } + $(anchor).trigger('click'); + dialog.trigger('poptrox_close'); + flush(); + assert.equal(picture.children().length, 0, 'closing opening fade cancels its deferred switch'); + assert.equal(dialog.is(':visible'), false); + $(anchor).trigger('click'); + // Only complete overlay opening, leaving the image load pending. + animations.shift().callback(); + const pendingImage = picture.children(); + assert.equal(pendingImage.length, 1); + assert.equal(pendingImage[0].events.filter(e => e.name === 'load').length, 1); + dialog.trigger('poptrox_close'); + assert.equal(pendingImage[0].events.filter(e => e.name === 'load').length, 0, 'closing detaches the pending image load handler'); + pendingImage.trigger('load'); flush(); + assert.equal(dialog.is(':visible'), false, 'late load cannot show a closed popup'); + root._poptroxDestroy(); + assert.equal(windowNode.events.length, 0, 'destroy removes this instance window handlers'); + assert.equal(opens, 2); assert.equal(closes, 2); + console.log('Poptrox opening/load cancellation and event teardown regressions passed'); +} diff --git a/tests/migration.php b/tests/migration.php new file mode 100644 index 0000000..8e6d18a --- /dev/null +++ b/tests/migration.php @@ -0,0 +1,90 @@ +values = $values; return $this; } + } + class Db { + public static self $instance; + public array $columns = []; + public array $alters = []; + public ?string $failColumn = null; + public bool $race = false; + public string $adapter = 'SQLite'; + public static function get(): self { return self::$instance; } + public function getPrefix(): string { return 'test_'; } + public function getAdapterName(): string { return $this->adapter; } + public function select(...$args): Query { return new Query(); } + public function insert($table): Query { return new Query(); } + public function update($table): Query { return new Query(); } + public function fetchRow($query) { return false; } + public function query($query): void { + if (!is_string($query)) { return; } + if (preg_match('/^SELECT ["`]test_infinitytime_images["`]\.["`]([a-z_]+)["`]/', $query, $match)) { + if (!in_array($match[1], $this->columns, true)) { throw new \RuntimeException('missing column'); } + return; + } + if (preg_match('/ADD COLUMN ["`]([a-z_]+)["`]/', $query, $match)) { + $this->alters[] = $match[1]; + if ($match[1] === $this->failColumn) { throw new \RuntimeException('permission denied'); } + $this->columns[] = $match[1]; + if ($this->race) { throw new \RuntimeException('duplicate column from concurrent migration'); } + return; + } + throw new \RuntimeException('Unexpected SQL: ' . $query); + } + } +} +namespace Utils { + class Helper { + public static object $options; + public static function options(): object { return self::$options; } + } +} +namespace { + interface Typecho_Plugin_Interface {} + require __DIR__ . '/../usr/plugins/InfinityTime/Plugin.php'; + require __DIR__ . '/../usr/plugins/InfinityTime/Lib/ImageRepository.php'; + use TypechoPlugin\InfinityTime\Plugin; + use TypechoPlugin\InfinityTime\Lib\ImageRepository; + function checkMigration(bool $condition, string $message): void { + if (!$condition) { throw new RuntimeException($message); } + } + $db = \Typecho\Db::$instance = new \Typecho\Db(); + \Utils\Helper::$options = (object)['infinitytimeSchemaVersion' => 0]; + $db->columns = ['title']; + $db->failColumn = 'avif'; + $failed = false; + try { ImageRepository::ensureSchema(); } catch (RuntimeException $e) { $failed = true; } + checkMigration($failed, 'DDL failure must surface'); + checkMigration(\Utils\Helper::$options->infinitytimeSchemaVersion === 0, 'Failure must not advance schema version'); + $db->failColumn = null; + ImageRepository::ensureSchema(); + checkMigration((int)\Utils\Helper::$options->infinitytimeSchemaVersion === 3, 'Same-process retry completes'); + checkMigration(count(array_filter($db->alters, static function ($c) { return $c === 'desc'; })) === 1, 'Retry skips already-created columns'); + checkMigration(!in_array('title', $db->alters, true), 'Existing columns need no ALTER'); + $count = count($db->alters); + Plugin::migrateSchema(); + checkMigration(count($db->alters) === $count, 'Current version is idempotent'); + $db->columns = ['title', 'desc']; + \Utils\Helper::$options->infinitytimeSchemaVersion = 2; + Plugin::migrateSchema(); + checkMigration(in_array('mid', $db->columns, true) && in_array('avif', $db->columns, true) && (int)\Utils\Helper::$options->infinitytimeSchemaVersion === 3, 'Repair old falsely successful v2 migration'); + foreach (['SQLite', 'Mysql', 'Pgsql'] as $adapter) { + $db->adapter = $adapter; + $db->columns = []; + $db->race = true; + \Utils\Helper::$options->infinitytimeSchemaVersion = 0; + Plugin::migrateSchema(); + checkMigration(count($db->columns) === 5 && (int)\Utils\Helper::$options->infinitytimeSchemaVersion === 3, 'Concurrent duplicate is accepted only after verification: ' . $adapter); + } + echo "Migration failure / retry / verification passed\n"; +} diff --git a/tests/repository.php b/tests/repository.php index 458e8ab..0d0dc1a 100644 --- a/tests/repository.php +++ b/tests/repository.php @@ -18,14 +18,22 @@ class Db { public static self $instance; public array $images = []; public array $fields = []; + public array $legacyFields = []; public static function get(): self { return self::$instance; } public function getPrefix(): string { return 'test_'; } public function select(...$args): Query { return new Query('select'); } public function insert($table): Query { return (new Query('insert'))->from($table); } public function delete($table): Query { return (new Query('delete'))->from($table); } public function update($table): Query { return (new Query('update'))->from($table); } - public function fetchAll($query): array { return $this->images; } + public function fetchAll($query): array { return $query->table === 'test_fields' ? $this->legacyFields : $this->images; } public function query($query): void { + if ($query->table === 'test_infinitytime_images' && $query->kind === 'update') { + foreach ($this->images as &$image) { + if (($image['id'] ?? null) === $query->conditions['id = ?'][0]) { $image = array_merge($image, $query->values); } + } + unset($image); + return; + } if ($query->table !== 'test_fields') { return; } if ($query->kind === 'delete') { unset($this->fields[$query->conditions['name = ?'][0]]); } if ($query->kind === 'insert') { $this->fields[$query->values['name']] = $query->values['str_value']; } @@ -46,7 +54,11 @@ public static function log($message): void {} namespace TypechoPlugin\InfinityTime\Lib { class MediaProcessor { public static array $calls = []; - public static function process(...$args): array { self::$calls[] = $args; return []; } + public static function process(...$args): array { + self::$calls[] = $args; + return ['width' => $args[5], 'height' => 80, 'size' => 321, + 'mid' => null, 'avif' => null, 'mid_avif' => null]; + } } } @@ -80,6 +92,8 @@ function expect(bool $condition, string $name): void { expect($result === ['rebuilt' => 2, 'failed' => 0], '混合图片重建成功'); expect(array_slice(MediaProcessor::$calls[0], 5) === [180, 94], '全景独立压缩设置'); expect(array_slice(MediaProcessor::$calls[1], 5) === [120, 51], '后续普通图不继承全景设置'); + expect($db->images[0]['width'] === 180 && $db->images[1]['width'] === 120, '重建持久化实际输出宽度'); + expect($db->images[1]['height'] === 80 && $db->images[1]['size'] === 321, '重建持久化高度和字节数'); $db->images[0]['mid'] = '/uploads/full/pano@1600.webp'; $db->images[0]['avif'] = '/uploads/full/pano.avif'; $db->images[0]['mid_avif'] = '/uploads/full/pano@1600.avif'; @@ -89,18 +103,60 @@ function expect(bool $condition, string $name): void { } } file_put_contents($root . '/uploads/full/orphan.webp', 'fixture'); + touch($root . '/uploads/full/orphan.webp', time() - 7200); expect(Repository::cleanupOrphans()['removed_files'] === 1, '只清理一个孤儿文件'); foreach (['mid', 'avif', 'mid_avif'] as $field) { expect(is_file(Repository::toAbs($db->images[0][$field])), '保留引用 ' . $field); } + // 快照生成后补入引用,恢复旧任务不得删掉该图。 + $late = $root . '/uploads/full/late.webp'; + file_put_contents($late, 'late'); touch($late, time() - 7200); + $snapshot = Repository::orphanCandidates(); + $db->images[] = ['full' => '/uploads/full/late.webp']; + expect(Repository::removeOrphanCandidates($snapshot) === 0 && is_file($late), '删除前重查新增数据库引用'); + $changed = $root . '/uploads/full/changed.webp'; + file_put_contents($changed, 'old'); touch($changed, time() - 7200); + $snapshot = Repository::orphanCandidates(); + file_put_contents($changed, 'replacement with different size'); touch($changed, time() - 7200); + expect(Repository::removeOrphanCandidates($snapshot) === 0 && is_file($changed), '文件身份变化后跳过旧候选'); + $fresh = $root . '/uploads/full/fresh.webp'; file_put_contents($fresh, 'new upload'); + expect(!in_array($fresh, array_column(Repository::orphanCandidates(), 'path'), true), '新上传文件享有宽限期'); + expect(Repository::removeOrphanCandidates([$changed]) === 0, '拒绝旧版纯路径删除快照'); + $outside = $root . '/outside.webp'; file_put_contents($outside, 'outside'); touch($outside, time() - 7200); + symlink($outside, $root . '/uploads/full/link.webp'); + expect(!in_array($root . '/uploads/full/link.webp', array_column(Repository::orphanCandidates(), 'path'), true), '不收集指向外部的软链接'); + expect(Repository::removeOrphanCandidates([['path' => $outside]]) === 0 && is_file($outside), '拒绝上传目录外的路径'); + $legacy = $root . '/uploads/full/legacy.webp'; + $legacyAvif = $root . '/uploads/full/legacy.avif'; + foreach ([$legacy, $legacyAvif] as $path) { file_put_contents($path, 'legacy'); touch($path, time() - 7200); } + $db->legacyFields = [ + ['name' => 'img', 'str_value' => "https://example.com/uploads/full/legacy.webp\n/uploads/full/other.webp"], + ['name' => 'variants', 'str_value' => '[{"avif":["/uploads/full/legacy.avif"]}]'], + ]; + $legacyCandidates = array_column(Repository::orphanCandidates(), 'path'); + expect(!in_array($legacy, $legacyCandidates, true) && !in_array($legacyAvif, $legacyCandidates, true), '保留仅由历史文章字段引用的全图和变体'); + // 模拟其他进程持锁:失败时绝不执行清理。 + Repository::unlockMedia(); + $other = fopen($root . '/uploads/.infinitytime-media.lock', 'c'); + flock($other, LOCK_EX | LOCK_NB); + $blocked = false; + try { Repository::cleanupOrphans(); } catch (RuntimeException $e) { $blocked = true; } + expect($blocked && is_file($changed), '上传/维护锁竞争时安全失败'); + flock($other, LOCK_UN); fclose($other); + Repository::lockMedia(); + Repository::lockMedia(); // 同一请求可复用锁,覆盖 ingest 到 insertRow 的间隙。 + $db->images = [ - ['full' => '/b.webp', 'thumb' => '/b-thumb.webp', 'title' => 'B', 'exif' => '{"gps":"private","iso":100}'], + ['full' => '/b.webp', 'thumb' => '/b-thumb.webp', 'title' => 'B', 'width' => 2400, 'height' => 1600, 'mid' => '/b@1600.webp', 'avif' => '/b.avif', 'exif' => '{"gps":"private","iso":100}'], ['full' => '/a.webp', 'thumb' => '/a-thumb.webp', 'title' => 'A', 'exif' => '{}'], ]; Repository::syncPostFields(1); expect($db->fields['img'] === "/b.webp\n/a.webp", '图片路径与仓库排序一致'); expect($db->fields['thumb'] === "/b-thumb.webp\n/a-thumb.webp", '缩略图同序'); + expect(json_decode($db->fields['dims'], true) === ['2400x1600', ''], '首次同步包含尺寸字段'); + $variants = json_decode($db->fields['variants'], true); + expect($variants[0]['w'] === 2400 && $variants[0]['webp'] === ['/b.webp', '/b@1600.webp'] && $variants[0]['avif'] === ['/b.avif'], '首次同步包含响应式变体'); expect(json_decode($db->fields['titles']) === ['B', 'A'], '标题同序'); expect(strpos($db->fields['exif'], 'private') === false, 'GPS 不写入公开字段'); $db->images = [$db->images[1]]; @@ -113,9 +169,11 @@ function expect(bool $condition, string $name): void { expect($db->fields['img'] === '' && $db->fields['thumb'] === '' && !isset($db->fields['titles']), '删除最后一张清空地址及元数据'); echo "Repository: $checks checks passed\n"; } finally { + Repository::unlockMedia(); // 仅移除本次随机创建的 fixture 目录。 $entries = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); foreach ($entries as $entry) { $entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); } rmdir($root); } } + diff --git a/usr/plugins/InfinityTime/Lib/ImageRepository.php b/usr/plugins/InfinityTime/Lib/ImageRepository.php index 8c30764..ad1c57a 100644 --- a/usr/plugins/InfinityTime/Lib/ImageRepository.php +++ b/usr/plugins/InfinityTime/Lib/ImageRepository.php @@ -26,6 +26,32 @@ public static function table(): string return \Typecho\Db::get()->getPrefix() . 'infinitytime_images'; } + /** 请求级互斥锁覆盖“文件落盘 → 入库”,进程退出自动释放;不删除锁文件。 */ + private static $mediaLock = null; + public static function lockMedia(): void + { + if (is_resource(self::$mediaLock)) { + return; + } + self::ensureDir(self::uploadRoot()); + $handle = @fopen(self::uploadRoot() . '/.infinitytime-media.lock', 'c'); + if (!$handle || !flock($handle, LOCK_EX | LOCK_NB)) { + if (is_resource($handle)) { fclose($handle); } + throw new \RuntimeException('图片上传或维护正在进行,请稍后重试'); + } + self::$mediaLock = $handle; + register_shutdown_function([self::class, 'unlockMedia']); + } + + public static function unlockMedia(): void + { + if (is_resource(self::$mediaLock)) { + flock(self::$mediaLock, LOCK_UN); + fclose(self::$mediaLock); + } + self::$mediaLock = null; + } + /** 是否 360 全景:等距圆柱即宽高比约 2:1(1.98~2.02),排除 XPAN 等更宽画幅。 */ public static function isPano(int $w, int $h): bool { @@ -119,6 +145,7 @@ public static function ingest(array $file, array $opts = []): ?array } try { + self::lockMedia(); $hash = hash_file('sha256', $src); $y = date('Y'); @@ -213,6 +240,7 @@ public static function defaultDirs(): array */ 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([ @@ -281,12 +309,13 @@ public static function ensureSchema(): void if (self::$schemaEnsured) { return; } - self::$schemaEnsured = true; // 按 schema 版本补齐缺失列(幂等;版本一致时 Plugin::migrateSchema 直接返回,不跑 DDL) try { Plugin::migrateSchema(); + self::$schemaEnsured = true; } catch (\Throwable $e) { Plugin::log('migrateSchema failed: ' . $e->getMessage()); + throw $e; } } @@ -301,15 +330,20 @@ public static function setImageMeta(int $rowId, string $title, string $desc, str ])->where('id = ?', $rowId)); } - /** 重建后更新某张图的响应式变体路径(mid / avif / mid_avif)。 */ + /** 重建后同步实际输出尺寸、大小与响应式变体路径。 */ public static function updateVariants(int $rowId, array $result): void { + self::lockMedia(); $db = \Typecho\Db::get(); - $db->query($db->update(self::table())->rows([ + $values = [ 'mid' => !empty($result['mid']) ? self::toWeb((string)$result['mid']) : null, 'avif' => !empty($result['avif']) ? self::toWeb((string)$result['avif']) : null, 'mid_avif' => !empty($result['mid_avif']) ? self::toWeb((string)$result['mid_avif']) : null, - ])->where('id = ?', $rowId)); + ]; + foreach (['width', 'height', 'size'] as $field) { + if (isset($result[$field])) { $values[$field] = (int)$result[$field]; } + } + $db->query($db->update(self::table())->rows($values)->where('id = ?', $rowId)); } /** 按图片行的顺序同步路径与元数据,避免排序/删除后文章字段错位。 */ @@ -394,38 +428,104 @@ public static function removeFor(int $cid): void $db->query($db->delete(self::table())->where('cid = ?', $cid)); } - /** 清理:删除所有不再关联任何 cid 的孤儿文件与记录(可选保留空目录)。 */ - public static function cleanupOrphans(): array + /** 仅处理真实上传目录内的普通文件;旧快照、软链接与新文件一律跳过。 */ + private static function orphanFingerprint(string $path): ?array + { + clearstatcache(true, $path); + if (is_link($path) || !is_file($path)) { return null; } + $real = realpath($path); + if ($real === false || $real !== $path) { return null; } + $inside = false; + foreach (self::defaultDirs() as $webDir) { + $dir = self::toAbs($webDir); + if (is_link($dir) || realpath($dir) !== $dir) { continue; } + if (strpos($real, $dir . '/') === 0) { $inside = true; break; } + } + if (!$inside) { return null; } + $stat = @stat($path); + // 保守宽限期:给升级前的上传进程及失败后的人工恢复留出时间。 + if (!$stat || $stat['mtime'] > time() - 3600) { return null; } + return ['path' => $path, 'dev' => $stat['dev'], 'ino' => $stat['ino'], + 'size' => $stat['size'], 'mtime' => $stat['mtime'], 'ctime' => $stat['ctime']]; + } + + private static function referencedPaths(): array { $db = \Typecho\Db::get(); - $rows = $db->fetchAll($db->select()->from(self::table())); - // 收集所有仍被引用的绝对路径 $referenced = []; - foreach ($rows as $r) { - foreach (['original', 'full', 'thumb', 'mid', 'avif', 'mid_avif'] as $f) { - if (!empty($r[$f])) { - $referenced[self::toAbs($r[$f])] = true; - } + $add = static function ($value) use (&$referenced): void { + if (!is_string($value) || trim($value) === '') { return; } + $value = trim($value); + if (preg_match('#^https?://#i', $value)) { $value = (string)parse_url($value, PHP_URL_PATH); } + if ($value === '') { return; } + $path = self::toAbs($value); + $referenced[$path] = true; + $real = realpath($path); + if ($real !== false) { $referenced[$real] = true; } + }; + foreach ($db->fetchAll($db->select()->from(self::table())) as $row) { + foreach (['original', 'full', 'thumb', 'mid', 'avif', 'mid_avif'] as $field) { + $add($row[$field] ?? null); } } - $removed = 0; - foreach (self::defaultDirs() as $type => $webDir) { - $absDir = self::toAbs($webDir); - if (!is_dir($absDir)) { - continue; - } - $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($absDir, \FilesystemIterator::SKIP_DOTS)); - foreach ($it as $file) { - if ($file->isFile() && !isset($referenced[$file->getPathname()])) { - if (@unlink($file->getPathname())) { - $removed++; + // 历史图集可能只有文章字段,没有仓库行,也必须保留其公开图片。 + $fields = $db->fetchAll($db->select('name', 'str_value')->from($db->getPrefix() . 'fields') + ->where('name IN (?, ?, ?)', 'img', 'thumb', 'variants')); + foreach ($fields as $field) { + if (($field['name'] ?? '') === 'variants') { + $variants = json_decode((string)($field['str_value'] ?? ''), true); + if (!is_array($variants)) { continue; } + foreach ($variants as $variant) { + if (!is_array($variant)) { continue; } + foreach (['webp', 'avif'] as $format) { + if (!is_array($variant[$format] ?? null)) { continue; } + foreach ($variant[$format] as $path) { $add($path); } } } + } else { + foreach (preg_split('/\\r?\\n/', (string)($field['str_value'] ?? '')) as $path) { $add($path); } + } + } + return $referenced; + } + + /** 快照仅是候选,删除前必须在同一锁内重新校验引用和文件身份。 */ + public static function orphanCandidates(): array + { + self::lockMedia(); + $referenced = self::referencedPaths(); + $list = []; + foreach (self::defaultDirs() as $webDir) { + $dir = self::toAbs($webDir); + if (!is_dir($dir) || is_link($dir)) { continue; } + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)); + foreach ($it as $file) { + $path = $file->getPathname(); + $candidate = self::orphanFingerprint($path); + if ($candidate !== null && !isset($referenced[$path])) { $list[] = $candidate; } } - // 清理空目录 - self::pruneEmpty($absDir); } - return ['removed_files' => $removed]; + return $list; + } + + public static function removeOrphanCandidates(array $candidates): int + { + self::lockMedia(); + $referenced = self::referencedPaths(); + $removed = 0; + foreach ($candidates as $candidate) { + // 升级前缓存的纯路径列表也不得直接用于删除。 + if (!is_array($candidate) || !isset($candidate['path'])) { continue; } + $path = (string)$candidate['path']; + if (isset($referenced[$path]) || self::orphanFingerprint($path) !== $candidate) { continue; } + if (@unlink($path)) { $removed++; } + } + return $removed; + } + + public static function cleanupOrphans(): array + { + return ['removed_files' => self::removeOrphanCandidates(self::orphanCandidates())]; } /** @@ -436,6 +536,7 @@ public static function cleanupOrphans(): array */ public static function rebuild(array $opts = []): array { + self::lockMedia(); $opts = array_merge([ 'quality' => (int)Plugin::opt('infinitytimeQuality', Plugin::DEFAULT_QUALITY), 'thumb_max' => (int)Plugin::opt('infinitytimeThumbMax', Plugin::DEFAULT_THUMB_MAX), @@ -537,3 +638,4 @@ private static function pruneEmpty(string $dir): void } } } + diff --git a/usr/plugins/InfinityTime/Plugin.php b/usr/plugins/InfinityTime/Plugin.php index ca396cc..b96196c 100644 --- a/usr/plugins/InfinityTime/Plugin.php +++ b/usr/plugins/InfinityTime/Plugin.php @@ -268,9 +268,13 @@ public static function uploadHandle(array $file) // 记录为“未归档”图片(cid=0),清理时不会误删 try { - ImageRepository::insertRow(0, $meta, 0); + if (ImageRepository::insertRow(0, $meta, 0) <= 0) { + throw new \RuntimeException('图片记录写入失败'); + } } catch (\Throwable $e) { self::log('insertRow(cid=0): ' . $e->getMessage()); + // 不向 Typecho 返回没有数据库引用、将来可能被孤儿清理的产物。 + return self::defaultUploadHandle($file); } return [ @@ -397,7 +401,7 @@ private static function createTables(): void } /** 当前 schema 版本。新增列时 +1,并在 migrateSchema 里补对应 ALTER。 */ - private const SCHEMA_VERSION = 2; + private const SCHEMA_VERSION = 3; /** 按版本补齐旧表缺失的列(幂等;每进程/每次激活最多跑一次 DDL)。 */ public static function migrateSchema(): void @@ -410,21 +414,26 @@ public static function migrateSchema(): void if ($current >= self::SCHEMA_VERSION) { return; } - $cols = []; - if ($current < 1) { - $cols[] = 'title'; - $cols[] = 'desc'; - } - if ($current < 2) { - $cols[] = 'mid'; - $cols[] = 'avif'; - $cols[] = 'mid_avif'; - } + // v3 一次性验证全部旧列,修复曾被 v2 错误标记成功的安装。 + $cols = ['title', 'desc', 'mid', 'avif', 'mid_avif']; foreach ($cols as $col) { + // 使用限定列名,避免 SQLite 将不存在的双引号列名当作字符串。 + $probe = "SELECT {$q}{$table}{$q}.{$q}{$col}{$q} FROM {$q}{$table}{$q} WHERE 1 = 0"; + try { + $db->query($probe); + continue; + } catch (\Throwable $e) { + // 缺列时尝试补齐;其他读取错误也不能冒充成功。 + } try { $db->query("ALTER TABLE {$q}{$table}{$q} ADD COLUMN {$q}{$col}{$q} text"); } catch (\Throwable $e) { - // 列已存在则忽略 + // 另一进程可能已加列;下面实际探测成功才允许继续。 + } + try { + $db->query($probe); + } catch (\Throwable $e) { + throw new \RuntimeException('InfinityTime 数据库升级失败,请检查数据库权限后重试(列:' . $col . ')', 0, $e); } } self::setOption('infinitytimeSchemaVersion', (string)self::SCHEMA_VERSION); @@ -473,3 +482,4 @@ public static function log(string $msg): void // 全局上传钩子注册(放在文件顶层,插件加载时执行) \Typecho\Plugin::factory('Widget_Upload')->uploadHandle = 'TypechoPlugin\\InfinityTime\\Plugin::uploadHandle'; + diff --git a/usr/plugins/InfinityTime/panel.php b/usr/plugins/InfinityTime/panel.php index ff00be8..81083a3 100644 --- a/usr/plugins/InfinityTime/panel.php +++ b/usr/plugins/InfinityTime/panel.php @@ -288,6 +288,12 @@ function pp_exif_summary(array $exif): string echo json_encode(['finished' => true, 'total' => 0, 'done' => 0, 'current' => '', 'failed' => 0, 'msg' => '安全校验失败,请刷新后台页面后重试'], JSON_UNESCAPED_UNICODE); exit; } + // 原子锁串行化维护状态,并与上传的落盘/入库临界区互斥。 + try { ImageRepository::lockMedia(); } catch (\Throwable $e) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['finished' => true, 'total' => 0, 'done' => 0, 'current' => '', 'failed' => 0, 'msg' => $e->getMessage()], JSON_UNESCAPED_UNICODE); + exit; + } // 简单并发锁:同一时间只允许一个维护任务(不同管理员 90 秒内不能抢跑) $__uid = (int)($user->uid ?? 0); $__lockFile = pp_data_file() . '/job.lock'; @@ -382,28 +388,7 @@ function pp_exif_summary(array $exif): string $jobFile = pp_data_file() . '/job.json'; $state = pp_read_json($jobFile); if (($state['job'] ?? '') !== 'cleanup' || !file_exists($listFile)) { - $rows = $db->fetchAll($db->select()->from(ImageRepository::table())); - $ref = []; - foreach ($rows as $r) { - foreach (['original', 'full', 'mid', 'avif', 'mid_avif', 'thumb'] as $k) { - if (!empty($r[$k])) { - $ref[ImageRepository::toAbs($r[$k])] = true; - } - } - } - $list = []; - foreach (ImageRepository::defaultDirs() as $type => $webDir) { - $abs = ImageRepository::toAbs($webDir); - if (!is_dir($abs)) { - continue; - } - $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($abs, \FilesystemIterator::SKIP_DOTS)); - foreach ($it as $file) { - if ($file->isFile() && !isset($ref[$file->getPathname()])) { - $list[] = $file->getPathname(); - } - } - } + $list = ImageRepository::orphanCandidates(); pp_write_json($listFile, $list); pp_write_json($jobFile, ['job' => 'cleanup', 'total' => count($list), 'done' => 0, 'current' => '']); } @@ -411,15 +396,15 @@ function pp_exif_summary(array $exif): string $state = pp_read_json($jobFile); $idx = (int)($state['done'] ?? 0); $batch = 50; + ImageRepository::removeOrphanCandidates(array_slice($list, $idx, $batch)); for ($i = 0; $i < $batch && $idx < count($list); $i++) { - @unlink($list[$idx]); - $state['current'] = basename($list[$idx]); + $state['current'] = basename(is_array($list[$idx]) ? (string)($list[$idx]['path'] ?? '') : (string)$list[$idx]); $idx++; } $state['done'] = $idx; $state['finished'] = $idx >= count($list); if ($state['finished']) { - pp_prune_empty_dirs(); + @unlink($listFile); $state = ['job' => 'cleanup', 'total' => 0, 'done' => 0, 'current' => '', 'finished' => true]; } pp_write_json($jobFile, $state); @@ -538,7 +523,7 @@ function pp_exif_summary(array $exif): string 'allowComment' => '0', 'allowPing' => '0', 'allowFeed' => '0', 'template' => '', 'password' => '', ])); - $imgs = []; $thumbs = []; $exifs = []; $addrs = []; $titles = []; $descs = []; $panos = []; + $imgs = []; $firstExif = null; $index = 0; $fail = 0; $quality = (int)Plugin::opt('infinitytimeQuality', Plugin::DEFAULT_QUALITY); $thumbMax = (int)Plugin::opt('infinitytimeThumbMax', Plugin::DEFAULT_THUMB_MAX); @@ -588,16 +573,20 @@ function pp_exif_summary(array $exif): string $fail++; continue; } - $rowId = ImageRepository::insertRow($cid, $meta, $index); $imgTitle = isset($postTitles[$i]) ? trim((string)$postTitles[$i]) : ''; $imgDesc = isset($postDescs[$i]) ? trim((string)$postDescs[$i]) : ''; - if ($imgTitle !== '' || $imgDesc !== '') { - ImageRepository::setImageMeta($rowId, $imgTitle, $imgDesc, (string)$address); + $meta['title'] = $imgTitle; + $meta['desc'] = $imgDesc; + try { + $rowId = ImageRepository::insertRow($cid, $meta, $index); + if ($rowId <= 0) { throw new \RuntimeException('图片记录写入失败'); } + } catch (\Throwable $e) { + Plugin::log('create_album insertRow: ' . $e->getMessage()); + ImageRepository::$lastError = '图片记录写入失败,请稍后重试'; + $fail++; + continue; } - $imgs[] = $meta['full']; $thumbs[] = $meta['thumb']; $exifs[] = $meta['exif']; $addrs[] = $address; - $titles[] = $imgTitle; $descs[] = $imgDesc; - $pw = (int)($meta['width'] ?? 0); $ph = (int)($meta['height'] ?? 0); - $panos[] = ImageRepository::isPano($pw, $ph) ? 1 : 0; + $imgs[] = $meta['full']; if ($firstExif === null) { $firstExif = $meta['exif']; } @@ -619,19 +608,9 @@ function pp_exif_summary(array $exif): string pp_reply($msg, 'error'); } - foreach ([ - 'img' => implode("\n", $imgs), - 'thumb' => implode("\n", $thumbs), - 'exif' => json_encode($exifs, JSON_UNESCAPED_UNICODE), - 'addresses' => json_encode($addrs, JSON_UNESCAPED_UNICODE), - 'titles' => json_encode($titles, JSON_UNESCAPED_UNICODE), - 'descs' => json_encode($descs, JSON_UNESCAPED_UNICODE), - 'panos' => json_encode($panos, JSON_UNESCAPED_UNICODE), - 'device' => $device !== '' ? $device : trim((string)($firstExif['make'] ?? '') . ' ' . (string)($firstExif['model'] ?? '')), - 'location' => $address, - ] as $name => $val) { - pp_set_field($cid, $name, $val); - } + ImageRepository::syncPostFields($cid); + pp_set_field($cid, 'device', $device !== '' ? $device : trim((string)($firstExif['make'] ?? '') . ' ' . (string)($firstExif['model'] ?? ''))); + pp_set_field($cid, 'location', $address); if ($tags !== '') { pp_set_field($cid, 'tags', $tags); } @@ -1264,3 +1243,4 @@ function pp_render_album_thumbs(array $images): string + diff --git a/usr/themes/InfinityTime/assets/css/main.css b/usr/themes/InfinityTime/assets/css/main.css index 0ade1c9..0c3af39 100755 --- a/usr/themes/InfinityTime/assets/css/main.css +++ b/usr/themes/InfinityTime/assets/css/main.css @@ -2753,6 +2753,8 @@ span.tag-list a:hover { /* 灯箱弹窗:EXIF 停靠侧栏(主图外侧,不遮挡) */ .poptrox-exif-dock { + text-align: left; + visibility: hidden; position: fixed; width: 218px; max-height: 82vh; @@ -2771,6 +2773,7 @@ span.tag-list a:hover { transform .45s cubic-bezier(0.45, 0, 0.55, 1); } .poptrox-exif-dock.show { + visibility: visible; opacity: 1; transform: translateY(0); pointer-events: auto; @@ -2951,6 +2954,7 @@ span.tag-list a:hover { /* 底部切图按钮(全端统一):灯箱打开时淡入,EXIF 抽屉展开时淡出隐藏。 桌面端也用它替代 poptrox 的左右箭头与右上角关闭叉。 */ .pp-mobile-nav { + visibility: hidden; position: fixed; left: 0; right: 0; @@ -2967,12 +2971,14 @@ span.tag-list a:hover { } /* 显示态:灯箱打开且抽屉收起 */ body.exif-dock-open .pp-mobile-nav { + visibility: visible; opacity: 1; transform: translateY(0); pointer-events: auto; } /* 抽屉展开(仅移动端会出现):同速向上移动淡出 */ body.pp-dock-expanded .pp-mobile-nav { + visibility: hidden; opacity: 0; transform: translateY(-24px); pointer-events: none; @@ -3001,6 +3007,31 @@ body.pp-dock-expanded .pp-mobile-nav { .poptrox-popup .nav-previous, .poptrox-popup .nav-next { display: none !important; } +/* Keep closing available even while loading or while the EXIF drawer is open. */ +.pp-lightbox-close { + position: fixed; + top: 16px; + right: 16px; + z-index: 20014; + width: 44px; + height: 44px; + padding: 0; + border: 1px solid rgba(255, 255, 255, .4); + border-radius: 50%; + background: #191c1e; + color: #fff; + font-size: 28px; + line-height: 1; + cursor: pointer; +} +#main .thumb > a.image:focus-visible, +.poptrox-overlay button:focus-visible, +.poptrox-overlay [tabindex]:focus-visible { + outline: 3px solid #fff !important; + outline-offset: 3px; + box-shadow: 0 0 0 6px #111; +} + @media (max-width: 900px) { /* 移动端等 EXIF 抽屉收起到底后再淡入,避免与抽屉动画打架 */ body.exif-dock-open .pp-mobile-nav { transition-delay: .45s; } @@ -3010,6 +3041,7 @@ body.pp-dock-expanded .pp-mobile-nav { @media (min-width: 901px) { /* 桌面端等定位完成(pp-nav-placed)再淡入,避免先出现在底部中央再跳到 EXIF 列 */ body.exif-dock-open .pp-mobile-nav:not(.pp-nav-placed) { + visibility: hidden; opacity: 0; pointer-events: none; } @@ -3200,3 +3232,4 @@ body.is-preload #header { #header nav>ul>li a:hover { border-radius: 999px; } + diff --git a/usr/themes/InfinityTime/assets/js/jquery.poptrox.min.js b/usr/themes/InfinityTime/assets/js/jquery.poptrox.min.js index afdecff..0d407d2 100755 --- a/usr/themes/InfinityTime/assets/js/jquery.poptrox.min.js +++ b/usr/themes/InfinityTime/assets/js/jquery.poptrox.min.js @@ -1,2 +1,4 @@ +/* InfinityTime: instance-scoped events / rebind cleanup; cancel pending animations and loads on close. */ /* jquery.poptrox.js v2.5.2-dev | (c) @ajlkn | github.com/ajlkn/jquery.poptrox | MIT licensed */ -!function(e){e.fn.poptrox_disableSelection=function(){return e(this).css("user-select","none").css("-khtml-user-select","none").css("-moz-user-select","none").css("-o-user-select","none").css("-webkit-user-select","none")},e.fn.poptrox=function(o){if(0==this.length)return e(this);if(this.length>1){for(var t=0;t'),l=e(window),u=[],d=0,h=!1,g=new Array,f=function(){p=l.width(),i=l.height()+s.windowHeightPad;var e=Math.abs(x.width()-x.outerWidth()),o=Math.abs(x.height()-x.outerHeight()),t=p-2*s.windowMargin-e,r=i-2*s.windowMargin-o;x.css("min-width",s.popupWidth).css("min-height",s.popupHeight),v.children().css("max-width",t).css("max-height",r)};s.usePopupLoader||(s.popupLoaderSelector=null),s.usePopupCloser||(s.popupCloserSelector=null),s.usePopupCaption||(s.popupCaptionSelector=null),s.usePopupNav||(s.popupNavPreviousSelector=null,s.popupNavNextSelector=null);var x;x=e(s.popupSelector?s.popupSelector:'
'+(s.popupLoaderSelector?'
'+s.popupLoaderText+"
":"")+'
'+(s.popupCaptionSelector?'
':"")+(s.popupCloserSelector?''+s.popupCloserText+"":"")+(s.popupNavPreviousSelector?'':"")+(s.popupNavNextSelector?'':"")+"
");var v=x.find(".pic"),w=e(),b=x.find(s.popupLoaderSelector),m=x.find(s.popupCaptionSelector),C=x.find(s.popupCloserSelector),y=x.find(s.popupNavNextSelector),S=x.find(s.popupNavPreviousSelector),P=y.add(S);if(s.usePopupDefaultStyling&&(x.css("background",s.popupBackgroundColor).css("color",s.popupTextColor).css("padding",s.popupPadding+"px"),m.length>0&&(x.css("padding-bottom",s.popupCaptionHeight+"px"),m.css("position","absolute").css("left","0").css("bottom","0").css("width","100%").css("text-align","center").css("height",s.popupCaptionHeight+"px").css("line-height",s.popupCaptionHeight+"px"),s.popupCaptionTextSize&&m.css("font-size",popupCaptionTextSize)),C.length>0&&C.html(s.popupCloserText).css("font-size",s.popupCloserTextSize).css("background",s.popupCloserBackgroundColor).css("color",s.popupCloserTextColor).css("display","block").css("width","40px").css("height","40px").css("line-height","40px").css("text-align","center").css("position","absolute").css("text-decoration","none").css("outline","0").css("top","0").css("right","-40px"),b.length>0&&b.html("").css("position","relative").css("font-size",s.popupLoaderTextSize).on("startSpinning",function(o){var t=e("
"+s.popupLoaderText+"
");t.css("height",Math.floor(s.popupHeight/2)+"px").css("overflow","hidden").css("line-height",Math.floor(s.popupHeight/2)+"px").css("text-align","center").css("margin-top",Math.floor((x.height()-t.height()+(m.length>0?m.height():0))/2)).css("color",s.popupTextColor?s.popupTextColor:"").on("xfin",function(){t.fadeTo(300,.5,function(){t.trigger("xfout")})}).on("xfout",function(){t.fadeTo(300,.05,function(){t.trigger("xfin")})}).trigger("xfin"),b.append(t)}).on("stopSpinning",function(e){var o=b.find("div");o.remove()}),2==P.length)){P.css("font-size","75px").css("text-align","center").css("color","#fff").css("text-shadow","none").css("height","100%").css("position","absolute").css("top","0").css("opacity","0.35").css("cursor","pointer").css("box-shadow","inset 0px 0px 10px 0px rgba(0,0,0,0)").poptrox_disableSelection();var k,T;s.usePopupEasyClose?(k="100px",T="100px"):(k="75%",T="25%"),y.css("right","0").css("width",k).html('
>
'),S.css("left","0").css("width",T).html('
<
')}return l.on("resize orientationchange",function(){f()}),m.on("update",function(e,o){o&&0!=o.length||(o=s.popupBlankCaptionText),m.html(o)}),C.css("cursor","pointer").on("click",function(e){return e.preventDefault(),e.stopPropagation(),x.trigger("poptrox_close"),!0}),y.on("click",function(e){e.stopPropagation(),e.preventDefault(),x.trigger("poptrox_next")}),S.on("click",function(e){e.stopPropagation(),e.preventDefault(),x.trigger("poptrox_previous")}),a.css("position","fixed").css("left",0).css("top",0).css("z-index",s.baseZIndex).css("width","100%").css("height","100%").css("text-align","center").css("cursor","pointer").appendTo(s.parent).prepend('
').append('
').hide().on("touchmove",function(e){return!1}).on("click",function(e){e.preventDefault(),e.stopPropagation(),x.trigger("poptrox_close")}),x.css("display","inline-block").css("vertical-align","middle").css("position","relative").css("z-index",1).css("cursor","auto").appendTo(a).hide().on("poptrox_next",function(){var e=d+1;e>=u.length&&(e=0),x.trigger("poptrox_switch",[e])}).on("poptrox_previous",function(){var e=d-1;e<0&&(e=u.length-1),x.trigger("poptrox_switch",[e])}).on("poptrox_reset",function(){f(),x.data("width",s.popupWidth).data("height",s.popupHeight),b.hide().trigger("stopSpinning"),m.hide(),C.hide(),P.hide(),v.hide(),w.attr("src","").detach()}).on("poptrox_open",function(e,o){return!!h||(h=!0,s.useBodyOverflow&&n.css("overflow","hidden"),s.onPopupOpen&&s.onPopupOpen(),x.addClass("loading"),void a.fadeTo(s.fadeSpeed,1,function(){x.trigger("poptrox_switch",[o,!0])}))}).on("poptrox_switch",function(o,t,p){var i;if(!p&&h)return!0;if(h=!0,x.addClass("loading").css("width",x.data("width")).css("height",x.data("height")),m.hide(),w.attr("src")&&w.attr("src",""),w.detach(),i=u[t],w=i.object,w.off("load"),v.css("text-indent","-9999px").show().append(w),"ajax"==i.type?e.get(i.src,function(e){w.html(e),w.trigger("load")}):w.attr("src",i.src),"image"!=i.type){var r,n;r=i.width,n=i.height,"%"==r.slice(-1)&&(r=parseInt(r.substring(0,r.length-1))/100*l.width()),"%"==n.slice(-1)&&(n=parseInt(n.substring(0,n.length-1))/100*l.height()),w.css("position","relative").css("outline","0").css("z-index",s.baseZIndex+100).width(r).height(n)}b.trigger("startSpinning").fadeIn(300),x.show(),s.popupIsFixed?(x.removeClass("loading").width(s.popupWidth).height(s.popupHeight),w.on("load",function(){w.off("load"),b.hide().trigger("stopSpinning"),m.trigger("update",[i.captionText]).fadeIn(s.fadeSpeed),C.fadeIn(s.fadeSpeed),v.css("text-indent",0).hide().fadeIn(s.fadeSpeed,function(){h=!1}),d=t,P.fadeIn(s.fadeSpeed)})):w.on("load",function(){f(),w.off("load"),b.hide().trigger("stopSpinning");var e=w.width(),o=w.height(),p=function(){m.trigger("update",[i.captionText]).fadeIn(s.fadeSpeed),C.fadeIn(s.fadeSpeed),v.css("text-indent",0).hide().fadeIn(s.fadeSpeed,function(){h=!1}),d=t,P.fadeIn(s.fadeSpeed),x.removeClass("loading").data("width",e).data("height",o).css("width","auto").css("height","auto")};x.animate({width:e,height:o},s.popupSpeed,"swing",p)}),"image"!=i.type&&w.trigger("load")}).on("poptrox_close",function(){return!(!h||s.usePopupForceClose)||(h=!0,x.hide().trigger("poptrox_reset"),s.onPopupClose&&s.onPopupClose(),void a.fadeOut(s.fadeSpeed,function(){s.useBodyOverflow&&n.css("overflow","auto"),h=!1}))}).trigger("poptrox_reset"),s.usePopupEasyClose?(m.on("click","a",function(e){e.stopPropagation()}),x.css("cursor","pointer").on("click",function(e){e.stopPropagation(),e.preventDefault(),x.trigger("poptrox_close")})):x.on("click",function(e){e.stopPropagation()}),l.keydown(function(e){if(x.is(":visible"))switch(e.keyCode){case 37:case 32:if(s.usePopupNav)return x.trigger("poptrox_previous"),!1;break;case 39:if(s.usePopupNav)return x.trigger("poptrox_next"),!1;break;case 27:return x.trigger("poptrox_close"),!1}}),r.find(s.selector).each(function(o){var t,p,i=e(this),r=i.find("img"),n=i.data("poptrox");if("ignore"!=n&&i.attr("href")){if(t={src:i.attr("href"),captionText:r.attr("title"),width:null,height:null,type:null,object:null,options:null},s.caption){if("function"==typeof s.caption)c=s.caption(i);else if("selector"in s.caption){var a;a=i.find(s.caption.selector),"attribute"in s.caption?c=a.attr(s.caption.attribute):(c=a.html(),s.caption.remove===!0&&a.remove())}}else c=r.attr("title");if(t.captionText=c,n){var l=n.split(",");0 in l&&(t.type=l[0]),1 in l&&(p=l[1].match(/([0-9%]+)x([0-9%]+)/),p&&3==p.length&&(t.width=p[1],t.height=p[2])),2 in l&&(t.options=l[2])}if(!t.type)switch(p=t.src.match(/\/\/([a-z0-9\.]+)\/.*/),(!p||p.length<2)&&(p=[!1]),p[1]){case"api.soundcloud.com":t.type="soundcloud";break;case"youtu.be":t.type="youtube";break;case"vimeo.com":t.type="vimeo";break;case"wistia.net":t.type="wistia";break;case"bcove.me":t.type="bcove";break;default:t.type="image"}switch(p=t.src.match(/\/\/[a-z0-9\.]+\/(.*)/),t.type){case"iframe":t.object=e(''),t.object.on("click",function(e){e.stopPropagation()}).css("cursor","auto"),t.width&&t.height||(t.width="600",t.height="400");break;case"ajax":t.object=e('
'),t.object.on("click",function(e){e.stopPropagation()}).css("cursor","auto").css("overflow","auto"),t.width&&t.height||(t.width="600",t.height="400");break;case"soundcloud":t.object=e(''),t.src="//w.soundcloud.com/player/?url="+escape(t.src)+(t.options?"&"+t.options:""),t.width="600",t.height="166";break;case"youtube":t.object=e(''),t.src="//www.youtube.com/embed/"+p[1]+(t.options?"?"+t.options:""),t.width&&t.height||(t.width="800",t.height="480");break;case"vimeo":t.object=e(''),t.src="//player.vimeo.com/video/"+p[1]+(t.options?"?"+t.options:""),t.width&&t.height||(t.width="800",t.height="480");break;case"wistia":t.object=e(''),t.src="//fast.wistia.net/"+p[1]+(t.options?"?"+t.options:""),t.width&&t.height||(t.width="800",t.height="480");break;case"bcove":t.object=e(''),t.src="//bcove.me/"+p[1]+(t.options?"?"+t.options:""),t.width&&t.height||(t.width="640",t.height="360");break;default:if(t.object=e(''),s.preload){var p=document.createElement("img");p.src=t.src,g.push(p)}t.width=i.attr("width"),t.height=i.attr("height")}"file:"==window.location.protocol&&t.src.match(/^\/\//)&&(t.src="http:"+t.src),u.push(t),r.removeAttr("title"),i.removeAttr("href").css("cursor","pointer").css("outline",0).on("click",function(e){e.preventDefault(),e.stopPropagation(),x.trigger("poptrox_open",[o])})}}),r.prop("_poptrox",s),r}}(jQuery); +!function(e){var instanceId=0;e.fn.poptrox_disableSelection=function(){return e(this).css("user-select","none").css("-khtml-user-select","none").css("-moz-user-select","none").css("-o-user-select","none").css("-webkit-user-select","none")},e.fn.poptrox=function(o){if(0==this.length)return e(this);if(this.length>1){for(var t=0;t'),l=e(window),u=[],d=0,h=!1,g=new Array,f=function(){p=l.width(),i=l.height()+s.windowHeightPad;var e=Math.abs(x.width()-x.outerWidth()),o=Math.abs(x.height()-x.outerHeight()),t=p-2*s.windowMargin-e,r=i-2*s.windowMargin-o;x.css("min-width",s.popupWidth).css("min-height",s.popupHeight),v.children().css("max-width",t).css("max-height",r)};s.usePopupLoader||(s.popupLoaderSelector=null),s.usePopupCloser||(s.popupCloserSelector=null),s.usePopupCaption||(s.popupCaptionSelector=null),s.usePopupNav||(s.popupNavPreviousSelector=null,s.popupNavNextSelector=null);var x;x=e(s.popupSelector?s.popupSelector:'
'+(s.popupLoaderSelector?'
'+s.popupLoaderText+"
":"")+'
'+(s.popupCaptionSelector?'
':"")+(s.popupCloserSelector?''+s.popupCloserText+"":"")+(s.popupNavPreviousSelector?'':"")+(s.popupNavNextSelector?'':"")+"
");var v=x.find(".pic"),w=e(),b=x.find(s.popupLoaderSelector),m=x.find(s.popupCaptionSelector),C=x.find(s.popupCloserSelector),y=x.find(s.popupNavNextSelector),S=x.find(s.popupNavPreviousSelector),P=y.add(S);if(s.usePopupDefaultStyling&&(x.css("background",s.popupBackgroundColor).css("color",s.popupTextColor).css("padding",s.popupPadding+"px"),m.length>0&&(x.css("padding-bottom",s.popupCaptionHeight+"px"),m.css("position","absolute").css("left","0").css("bottom","0").css("width","100%").css("text-align","center").css("height",s.popupCaptionHeight+"px").css("line-height",s.popupCaptionHeight+"px"),s.popupCaptionTextSize&&m.css("font-size",popupCaptionTextSize)),C.length>0&&C.html(s.popupCloserText).css("font-size",s.popupCloserTextSize).css("background",s.popupCloserBackgroundColor).css("color",s.popupCloserTextColor).css("display","block").css("width","40px").css("height","40px").css("line-height","40px").css("text-align","center").css("position","absolute").css("text-decoration","none").css("outline","0").css("top","0").css("right","-40px"),b.length>0&&b.html("").css("position","relative").css("font-size",s.popupLoaderTextSize).on("startSpinning",function(o){var t=e("
"+s.popupLoaderText+"
");t.css("height",Math.floor(s.popupHeight/2)+"px").css("overflow","hidden").css("line-height",Math.floor(s.popupHeight/2)+"px").css("text-align","center").css("margin-top",Math.floor((x.height()-t.height()+(m.length>0?m.height():0))/2)).css("color",s.popupTextColor?s.popupTextColor:"").on("xfin",function(){t.fadeTo(300,.5,function(){t.trigger("xfout")})}).on("xfout",function(){t.fadeTo(300,.05,function(){t.trigger("xfin")})}).trigger("xfin"),b.append(t)}).on("stopSpinning",function(e){var o=b.find("div");o.remove()}),2==P.length)){P.css("font-size","75px").css("text-align","center").css("color","#fff").css("text-shadow","none").css("height","100%").css("position","absolute").css("top","0").css("opacity","0.35").css("cursor","pointer").css("box-shadow","inset 0px 0px 10px 0px rgba(0,0,0,0)").poptrox_disableSelection();var k,T;s.usePopupEasyClose?(k="100px",T="100px"):(k="75%",T="25%"),y.css("right","0").css("width",k).html('
>
'),S.css("left","0").css("width",T).html('
<
')}return l.on("resize"+eventNamespace+" orientationchange"+eventNamespace,function(){f()}),m.on("update",function(e,o){o&&0!=o.length||(o=s.popupBlankCaptionText),m.html(o)}),C.css("cursor","pointer").on("click",function(e){return e.preventDefault(),e.stopPropagation(),x.trigger("poptrox_close"),!0}),y.on("click",function(e){e.stopPropagation(),e.preventDefault(),x.trigger("poptrox_next")}),S.on("click",function(e){e.stopPropagation(),e.preventDefault(),x.trigger("poptrox_previous")}),a.css("position","fixed").css("left",0).css("top",0).css("z-index",s.baseZIndex).css("width","100%").css("height","100%").css("text-align","center").css("cursor","pointer").appendTo(s.parent).prepend('
').append('
').hide().on("touchmove",function(e){return!1}).on("click",function(e){e.preventDefault(),e.stopPropagation(),x.trigger("poptrox_close")}),x.css("display","inline-block").css("vertical-align","middle").css("position","relative").css("z-index",1).css("cursor","auto").appendTo(a).hide().on("poptrox_next",function(){var e=d+1;e>=u.length&&(e=0),x.trigger("poptrox_switch",[e])}).on("poptrox_previous",function(){var e=d-1;e<0&&(e=u.length-1),x.trigger("poptrox_switch",[e])}).on("poptrox_reset",function(){f(),x.data("width",s.popupWidth).data("height",s.popupHeight),b.hide().trigger("stopSpinning"),m.hide(),C.hide(),P.hide(),v.hide(),w.off("load").prop("onload",null).prop("onerror",null).attr("src","").detach()}).on("poptrox_open",function(e,o){return!!h||(h=!0,s.useBodyOverflow&&n.css("overflow","hidden"),s.onPopupOpen&&s.onPopupOpen(),x.addClass("loading"),void a.fadeTo(s.fadeSpeed,1,function(){x.trigger("poptrox_switch",[o,!0])}))}).on("poptrox_switch",function(o,t,p){var i;if(!p&&h)return!0;if(h=!0,x.addClass("loading").css("width",x.data("width")).css("height",x.data("height")),m.hide(),w.attr("src")&&w.attr("src",""),w.detach(),i=u[t],w=i.object,w.off("load"),v.css("text-indent","-9999px").show().append(w),"ajax"==i.type?e.get(i.src,function(e){w.html(e),w.trigger("load")}):w.attr("src",i.src),"image"!=i.type){var r,n;r=i.width,n=i.height,"%"==r.slice(-1)&&(r=parseInt(r.substring(0,r.length-1))/100*l.width()),"%"==n.slice(-1)&&(n=parseInt(n.substring(0,n.length-1))/100*l.height()),w.css("position","relative").css("outline","0").css("z-index",s.baseZIndex+100).width(r).height(n)}b.trigger("startSpinning").fadeIn(300),x.show(),s.popupIsFixed?(x.removeClass("loading").width(s.popupWidth).height(s.popupHeight),w.on("load",function(){w.off("load"),b.hide().trigger("stopSpinning"),m.trigger("update",[i.captionText]).fadeIn(s.fadeSpeed),C.fadeIn(s.fadeSpeed),v.css("text-indent",0).hide().fadeIn(s.fadeSpeed,function(){h=!1}),d=t,P.fadeIn(s.fadeSpeed)})):w.on("load",function(){f(),w.off("load"),b.hide().trigger("stopSpinning");var e=w.width(),o=w.height(),p=function(){m.trigger("update",[i.captionText]).fadeIn(s.fadeSpeed),C.fadeIn(s.fadeSpeed),v.css("text-indent",0).hide().fadeIn(s.fadeSpeed,function(){h=!1}),d=t,P.fadeIn(s.fadeSpeed),x.removeClass("loading").data("width",e).data("height",o).css("width","auto").css("height","auto")};x.animate({width:e,height:o},s.popupSpeed,"swing",p)}),"image"!=i.type&&w.trigger("load")}).on("poptrox_close",function(){return!(!h||s.usePopupForceClose)||(h=!0,a.stop(!0,!1),x.stop(!0,!1),v.stop(!0,!1),b.stop(!0,!1),m.stop(!0,!1),C.stop(!0,!1),P.stop(!0,!1),x.hide().trigger("poptrox_reset"),s.onPopupClose&&s.onPopupClose(),void a.fadeOut(s.fadeSpeed,function(){s.useBodyOverflow&&n.css("overflow","auto"),h=!1}))}).trigger("poptrox_reset"),s.usePopupEasyClose?(m.on("click","a",function(e){e.stopPropagation()}),x.css("cursor","pointer").on("click",function(e){e.stopPropagation(),e.preventDefault(),x.trigger("poptrox_close")})):x.on("click",function(e){e.stopPropagation()}),l.on("keydown"+eventNamespace,function(e){if(x.is(":visible"))switch(e.keyCode){case 37:case 32:if(s.usePopupNav)return x.trigger("poptrox_previous"),!1;break;case 39:if(s.usePopupNav)return x.trigger("poptrox_next"),!1;break;case 27:return x.trigger("poptrox_close"),!1}}),r.find(s.selector).each(function(o){var t,p,i=e(this),r=i.find("img"),n=i.data("poptrox");if("ignore"!=n&&i.attr("href")){if(t={src:i.attr("href"),captionText:r.attr("title"),width:null,height:null,type:null,object:null,options:null},s.caption){if("function"==typeof s.caption)c=s.caption(i);else if("selector"in s.caption){var a;a=i.find(s.caption.selector),"attribute"in s.caption?c=a.attr(s.caption.attribute):(c=a.html(),s.caption.remove===!0&&a.remove())}}else c=r.attr("title");if(t.captionText=c,n){var l=n.split(",");0 in l&&(t.type=l[0]),1 in l&&(p=l[1].match(/([0-9%]+)x([0-9%]+)/),p&&3==p.length&&(t.width=p[1],t.height=p[2])),2 in l&&(t.options=l[2])}if(!t.type)switch(p=t.src.match(/\/\/([a-z0-9\.]+)\/.*/),(!p||p.length<2)&&(p=[!1]),p[1]){case"api.soundcloud.com":t.type="soundcloud";break;case"youtu.be":t.type="youtube";break;case"vimeo.com":t.type="vimeo";break;case"wistia.net":t.type="wistia";break;case"bcove.me":t.type="bcove";break;default:t.type="image"}switch(p=t.src.match(/\/\/[a-z0-9\.]+\/(.*)/),t.type){case"iframe":t.object=e(''),t.object.on("click",function(e){e.stopPropagation()}).css("cursor","auto"),t.width&&t.height||(t.width="600",t.height="400");break;case"ajax":t.object=e('
'),t.object.on("click",function(e){e.stopPropagation()}).css("cursor","auto").css("overflow","auto"),t.width&&t.height||(t.width="600",t.height="400");break;case"soundcloud":t.object=e(''),t.src="//w.soundcloud.com/player/?url="+escape(t.src)+(t.options?"&"+t.options:""),t.width="600",t.height="166";break;case"youtube":t.object=e(''),t.src="//www.youtube.com/embed/"+p[1]+(t.options?"?"+t.options:""),t.width&&t.height||(t.width="800",t.height="480");break;case"vimeo":t.object=e(''),t.src="//player.vimeo.com/video/"+p[1]+(t.options?"?"+t.options:""),t.width&&t.height||(t.width="800",t.height="480");break;case"wistia":t.object=e(''),t.src="//fast.wistia.net/"+p[1]+(t.options?"?"+t.options:""),t.width&&t.height||(t.width="800",t.height="480");break;case"bcove":t.object=e(''),t.src="//bcove.me/"+p[1]+(t.options?"?"+t.options:""),t.width&&t.height||(t.width="640",t.height="360");break;default:if(t.object=e(''),s.preload){var p=document.createElement("img");p.src=t.src,g.push(p)}t.width=i.attr("width"),t.height=i.attr("height")}"file:"==window.location.protocol&&t.src.match(/^\/\//)&&(t.src="http:"+t.src),u.push(t),r.removeAttr("title"),i.removeAttr("href").css("cursor","pointer").css("outline",0).on("click",function(e){e.preventDefault(),e.stopPropagation(),x.trigger("poptrox_open",[o])})}}),r.prop("_poptrox",s).prop("_poptroxDestroy",function(){l.off(eventNamespace)}),r}}(jQuery); + diff --git a/usr/themes/InfinityTime/assets/js/lightbox.js b/usr/themes/InfinityTime/assets/js/lightbox.js index 46cd7b0..20775cb 100644 --- a/usr/themes/InfinityTime/assets/js/lightbox.js +++ b/usr/themes/InfinityTime/assets/js/lightbox.js @@ -37,7 +37,7 @@ exifDock = document.createElement('div'); exifDock.className = 'poptrox-exif-dock'; exifDock.innerHTML = - '
' + '' @@ -52,18 +52,19 @@ var setDockExpanded = function (v) { exifDock.classList.toggle('expanded', !!v); document.body.classList.toggle('pp-dock-expanded', !!v); + exifDock.querySelector('.exif-dock-handle').setAttribute('aria-expanded', v ? 'true' : 'false'); }; var toggleDock = function (e) { var h = e && e.target && e.target.closest ? e.target.closest('.exif-dock-handle') : null; if (h) setDockExpanded(!exifDock.classList.contains('expanded')); }; - exifDock.addEventListener('click', toggleDock); + exifDock.addEventListener('click', function(e) { e.stopPropagation(); toggleDock(e); }); exifDock.addEventListener('keydown', function (e) { if (e.key !== 'Enter' && e.key !== ' ') return; var h = e.target && e.target.closest ? e.target.closest('.exif-dock-handle') : null; if (h) { e.preventDefault(); setDockExpanded(!exifDock.classList.contains('expanded')); } }); - document.body.appendChild(exifDock); + (document.querySelector('.poptrox-overlay') || document.body).appendChild(exifDock); } return exifDock; } @@ -712,7 +713,10 @@ if (document.body.style.overflow !== 'hidden') document.body.style.overflow = 'hidden'; } else { if (document.body.style.overflow !== '') document.body.style.overflow = ''; - if (exifDock) { exifDock.classList.remove('show'); exifDock.classList.remove('expanded'); } + if (exifDock) { + exifDock.classList.remove('show'); exifDock.classList.remove('expanded'); + exifDock.querySelector('.exif-dock-handle').setAttribute('aria-expanded', 'false'); + } document.body.classList.remove('pp-dock-expanded'); ppDestroyPano(); } @@ -762,3 +766,4 @@ window.ensureExifObserver = ensureExifObserver; window.syncDockExif = syncDockExif; }); + diff --git a/usr/themes/InfinityTime/assets/js/main.js b/usr/themes/InfinityTime/assets/js/main.js index ed27e09..b39a75b 100755 --- a/usr/themes/InfinityTime/assets/js/main.js +++ b/usr/themes/InfinityTime/assets/js/main.js @@ -204,6 +204,117 @@ document.addEventListener('DOMContentLoaded', function() { let isPopupActive = false; const $main = $('#main'); const $body = $('body'); + var galleryOpener = null; + var backgroundState = []; + var poptroxRebindPending = false; + + // Poptrox removes href and owns its popup markup. Restore keyboard semantics + // here, including controls rendered outside .poptrox-popup by lightbox.js. + function prepareGalleryDialog() { + $main.find('.thumb > a.image').attr({ role: 'button', tabindex: '0', 'aria-haspopup': 'dialog' }); + var overlay = document.querySelector('.poptrox-overlay'); + if (!overlay) return; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', '图片浏览'); + overlay.setAttribute('aria-hidden', isPopupActive ? 'false' : 'true'); + if (isPopupActive) overlay.removeAttribute('inert'); + else overlay.setAttribute('inert', ''); + overlay.setAttribute('tabindex', '-1'); + document.querySelectorAll('.poptrox-exif-dock, .pp-mobile-nav, .pp-mobile-caption').forEach(function(el) { + if (el.parentNode !== overlay) overlay.appendChild(el); + }); + if (!overlay.querySelector('.pp-lightbox-close')) { + var close = document.createElement('button'); + close.type = 'button'; + close.className = 'pp-lightbox-close'; + close.setAttribute('aria-label', '关闭图片浏览'); + close.textContent = '×'; + close.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + $(overlay.querySelector('.poptrox-popup')).trigger('poptrox_close'); + }); + overlay.appendChild(close); + } + } + function openGalleryDialog() { + prepareGalleryDialog(); + var overlay = document.querySelector('.poptrox-overlay'); + if (!galleryOpener) galleryOpener = document.activeElement; + backgroundState = []; + Array.from(document.body.children).forEach(function(el) { + if (el === overlay || /^(SCRIPT|STYLE|LINK)$/.test(el.tagName)) return; + backgroundState.push({ el: el, inert: el.hasAttribute('inert'), hidden: el.getAttribute('aria-hidden') }); + el.setAttribute('inert', ''); + el.setAttribute('aria-hidden', 'true'); + }); + // onPopupOpen runs just before Poptrox reveals the overlay. + setTimeout(function() { + if (isPopupActive && overlay && overlay.isConnected) overlay.querySelector('.pp-lightbox-close').focus(); + }, 0); + } + function closeGalleryDialog() { + backgroundState.forEach(function(state) { + if (!state.inert) state.el.removeAttribute('inert'); + if (state.hidden === null) state.el.removeAttribute('aria-hidden'); + else state.el.setAttribute('aria-hidden', state.hidden); + }); + backgroundState = []; + if (galleryOpener && galleryOpener.isConnected) galleryOpener.focus({ preventScroll: true }); + galleryOpener = null; + var overlay = document.querySelector('.poptrox-overlay'); + if (overlay) { overlay.setAttribute('aria-hidden', 'true'); overlay.setAttribute('inert', ''); } + } + function dialogTabStops(overlay) { + return Array.from(overlay.querySelectorAll('button, a[href], input, select, textarea, [tabindex]')).filter(function(el) { + if (el.tabIndex < 0 || el.disabled || el.closest('[inert]') || !el.getClientRects().length) return false; + var style = getComputedStyle(el); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); + } + document.addEventListener('focusin', function(e) { + var overlay = document.querySelector('.poptrox-overlay'); + if (isPopupActive && overlay && !overlay.contains(e.target)) overlay.querySelector('.pp-lightbox-close').focus(); + }); + document.addEventListener('keydown', function(e) { + var opener = e.target && e.target.closest ? e.target.closest('#main .thumb > a.image') : null; + if (!isPopupActive) { + if (opener && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + e.stopImmediatePropagation(); + opener.click(); + } + return; + } + var overlay = document.querySelector('.poptrox-overlay'); + var popup = overlay && overlay.querySelector('.poptrox-popup'); + if (!popup) return; + if (e.key === 'Tab') { + var stops = dialogTabStops(overlay); + var index = stops.indexOf(document.activeElement); + var next = e.shiftKey ? (index <= 0 ? stops.length - 1 : index - 1) : (index + 1) % stops.length; + e.preventDefault(); + e.stopImmediatePropagation(); + (stops[next] || overlay).focus(); + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopImmediatePropagation(); + $(popup).trigger('poptrox_close'); + } else if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.target.closest('input, textarea, select, [contenteditable="true"]')) { + // Use the same path as the visible buttons, so arrows visit album + // images rather than jumping directly to the next gallery card. + e.preventDefault(); + e.stopImmediatePropagation(); + var nav = popup.querySelector(e.key === 'ArrowRight' ? '.nav-next' : '.nav-previous'); + if (nav) nav.click(); + } + }, true); + // Poptrox treats Space as "previous" at window level. Let real controls + // handle it normally without also navigating the gallery. + document.addEventListener('keydown', function(e) { + if (isPopupActive && e.key === ' ') e.stopPropagation(); + }); // 监听弹窗状态(配置复用:无限瀑布流翻页后需重绑一次) const PP_CONFIG = { @@ -212,6 +323,7 @@ document.addEventListener('DOMContentLoaded', function() { fadeSpeed: 420, onPopupClose: function() { isPopupActive = false; + closeGalleryDialog(); captionFadeOut(); $body.removeClass('modal-active'); $('html, body').css({ @@ -223,15 +335,23 @@ document.addEventListener('DOMContentLoaded', function() { // 清理本项目挂在弹窗上的临时状态,避免关闭后再开残留锁/全景态 document.querySelectorAll('.poptrox-popup').forEach(function(p) { clearLqip(p); + p.__lqipSeq = (p.__lqipSeq || 0) + 1; p.classList.remove('pp-pano-mode'); delete p.__switching; + p.__switchSeq = (p.__switchSeq || 0) + 1; delete p.__panoActive; delete p.__ppIndex; delete p.__ppArticle; }); + // Newly loaded cards may have arrived while the modal was open. + // Rebuild only after its closing animation no longer owns the overlay. + if (poptroxRebindPending) setTimeout(function() { + if (poptroxRebindPending) window.__rebindPoptrox(); + }, PP_CONFIG.fadeSpeed + 20); }, onPopupOpen: function() { isPopupActive = true; + openGalleryDialog(); $body.addClass('modal-active'); // 移动端用原始宽高预置弹窗尺寸,避免首次打开时先闪一个 150×150 的小方块 // (桌面端有 EXIF 侧栏让位逻辑,尺寸交给 poptrox 自己算,避免预设偏宽再回缩) @@ -278,22 +398,30 @@ document.addEventListener('DOMContentLoaded', function() { return p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2; }; $main.poptrox(PP_CONFIG); + prepareGalleryDialog(); // 无限瀑布流翻页后,把新卡片也绑定到灯箱。 // poptrox 只绑定“还有 href”的锚点,而早已绑定过的锚点 href 已被 poptrox 移除, // 所以重绑前要把 href 从 data-images[0] 补回来;否则重绑会跳过旧卡片(导致“所有灯箱打不开”)。 window.__rebindPoptrox = function() { - if ($('.poptrox-overlay').is(':visible')) return; // 灯箱打开时不重建 + if (isPopupActive || $('.poptrox-overlay').is(':visible')) { poptroxRebindPending = true; return; } + poptroxRebindPending = false; + if ($main[0]._poptroxDestroy) $main[0]._poptroxDestroy(); $main.find('.thumb > a.image').each(function() { var a = this; if (!a.getAttribute('href') && a.dataset.images) { try { var imgs = ppParseArr(a.dataset.images); if (imgs[0]) a.setAttribute('href', imgs[0]); } catch (e) {} } }); + // Preserve the shared controls before disposing of the old dialog. + document.querySelectorAll('.poptrox-exif-dock, .pp-mobile-nav, .pp-mobile-caption').forEach(function(el) { + document.body.appendChild(el); + }); $('.poptrox-overlay').remove(); $('.poptrox-popup').remove(); $main.find('.thumb > a.image').off('click'); $main.poptrox(PP_CONFIG); + prepareGalleryDialog(); rebuildLightboxData(); // 新卡片加入后重建 blur-up/全景/图集数据 if (typeof ensureExifObserver === 'function') ensureExifObserver(); }; @@ -326,10 +454,11 @@ document.addEventListener('DOMContentLoaded', function() { imgs.forEach(function(u, i) { panoMap[u] = !!panos[i]; }); imgs.forEach(function(u, i) { variantMap[u] = variants[i] || null; }); ALBUMS.push({ + title: a.getAttribute('aria-label') || '照片', images: nonEmpty(imgs), previews: nonEmpty(pre), exifs: nonEmpty(ppParseArr(a.dataset.exif)), - titles: nonEmpty(ppParseArr(a.dataset.titles)), + titles: ppParseArr(a.dataset.titles), descs: nonEmpty(ppParseArr(a.dataset.descs)), addrs: nonEmpty(ppParseArr(a.dataset.addresses)) }); @@ -343,8 +472,9 @@ document.addEventListener('DOMContentLoaded', function() { } // 记录点击的是哪张图(原始宽高),供 onPopupOpen 预置弹窗尺寸。 document.addEventListener('click', function(e) { - var a = e.target && e.target.closest ? e.target.closest('#main a.image[data-dims]') : null; + var a = e.target && e.target.closest ? e.target.closest('#main .thumb > a.image') : null; if (!a) return; + galleryOpener = a; try { var dims = JSON.parse(a.dataset.dims || '[]'); var m = String(dims[0] || '').split('x'); @@ -375,6 +505,13 @@ document.addEventListener('DOMContentLoaded', function() { }, 440); // 等 poptrox 的 .pic 淡入(420ms)完成后再显示标题 } // ---- 响应式图片:给灯箱主图设置 WebP srcset,并在有 AVIF 变体时用 包一层 ---- + function removeResponsivePicture(img) { + var picture = img.parentNode; + if (picture && picture.tagName === 'PICTURE' && picture.classList.contains('pp-picture')) { + picture.parentNode.insertBefore(img, picture); + picture.remove(); + } + } function applyResponsive(popup, img) { if (!popup || !img) return; var src = img.getAttribute('src') || ''; @@ -384,6 +521,7 @@ document.addEventListener('DOMContentLoaded', function() { if (isPanoUrl(src)) { img.removeAttribute('srcset'); img.removeAttribute('sizes'); + removeResponsivePicture(img); if (pic) pic.querySelectorAll('picture.pp-picture').forEach(function (p) { if (!p.contains(img)) p.remove(); }); return; } @@ -393,6 +531,7 @@ document.addEventListener('DOMContentLoaded', function() { if (!v) { img.removeAttribute('srcset'); img.removeAttribute('sizes'); + removeResponsivePicture(img); return; } var webp = v.webp || []; @@ -429,10 +568,8 @@ document.addEventListener('DOMContentLoaded', function() { } source.setAttribute('srcset', avifSet.join(', ')); source.setAttribute('sizes', sizes); - } else if (img.parentNode && img.parentNode.tagName === 'PICTURE') { - var p = img.parentNode; - p.parentNode.insertBefore(img, p); - p.remove(); + } else { + removeResponsivePicture(img); } } function applyLqip(popup) { @@ -442,6 +579,11 @@ document.addEventListener('DOMContentLoaded', function() { if (!img || !pic) return; applyResponsive(popup, img); var full = img.getAttribute('src') || ''; + var current = albumForSrc(full); + if (current) { + var title = current.album.titles[current.idx] || current.album.title; + img.alt = title + (current.album.images.length > 1 ? '(' + (current.idx + 1) + '/' + current.album.images.length + ')' : ''); + } else { img.alt = '照片'; } var pre = lqipFor(full); if (!pre || pre === full) { clearLqip(popup); img.style.opacity = '1'; captionFadeInAfterImage(); return; } // 每次切图递增序号:上一张的 onload/decode 回调晚到时会自动作废, @@ -555,22 +697,24 @@ document.addEventListener('DOMContentLoaded', function() { return null; } function inAlbumNav(popup, delta) { + if (!isPopupActive) return true; + if (popup && (popup.__switching || popup.classList.contains('loading'))) return true; var img = popup && popup.querySelector('.pic img'); if (!img || !img.getAttribute('src')) return false; var cur = albumForSrc(img.getAttribute('src')); if (!cur || cur.album.images.length <= 1) return false; var ni = cur.idx + delta; if (ni < 0 || ni >= cur.album.images.length) return false; // 边界:交给外部跨图集 - if (popup.__switching) return false; // 动画进行中,忽略重复切换,避免 opacity 状态错乱 popup.__switching = true; - setTimeout(function() { popup.__switching = false; }, 460); // 与过渡总时长一致 + var seq = popup.__switchSeq = (popup.__switchSeq || 0) + 1; + setTimeout(function() { if (seq === popup.__switchSeq) popup.__switching = false; }, 460); // 与过渡总时长一致 // 同步比例切换也要带淡出→淡入,避免“直接换图”的生硬感。 // 过渡走 .poptrox-popup .pic img 的 CSS opacity transition(柔和缓动)。 var nextSrc = cur.album.images[ni]; img.style.opacity = '0'; setTimeout(function() { // 灯箱可能已关闭 / 图片元素已被移除:此时不再操作 DOM,避免报错 - if (!img || !img.isConnected || !popup || !popup.isConnected) return; + if (!isPopupActive || seq !== popup.__switchSeq || !img.isConnected || !popup.isConnected) return; img.setAttribute('src', nextSrc); img.style.opacity = '1'; }, 220); // 略大于半程,让淡出先发生,再换图并淡入 @@ -619,3 +763,4 @@ document.addEventListener('DOMContentLoaded', function() { }); })(); + diff --git a/usr/themes/InfinityTime/index.php b/usr/themes/InfinityTime/index.php index 3148adf..31abfd7 100755 --- a/usr/themes/InfinityTime/index.php +++ b/usr/themes/InfinityTime/index.php @@ -22,12 +22,12 @@ } ?> - + <?php echo htmlspecialchars(pp_opt('infinitytimeSiteName', (string)$this->options->IndexName, $this->options)); ?> - <?php echo htmlspecialchars(pp_opt('infinitytimeSiteTagline', (string)$this->options->Indexdict, $this->options)); ?> - + @@ -102,8 +102,9 @@ }, $exifList); $exif0 = $exifList[0] ?? []; $addr0 = $addrList[0] ?? ($this->fields->location ? $this->fields->location : ''); + $imageAlt = trim((string)($imgTitles[0] ?? '')) ?: (trim((string)$this->title) ?: '照片'); ?> - <?php echo htmlspecialchars((string)$this->title, ENT_QUOTES); ?> + diff --git a/usr/themes/InfinityTime/update.md b/usr/themes/InfinityTime/update.md index 257d44a..c37659d 100644 --- a/usr/themes/InfinityTime/update.md +++ b/usr/themes/InfinityTime/update.md @@ -1,5 +1,13 @@ # 更新日志 +## 未发布 + +- 新图集统一从图片仓库同步尺寸及 WebP / AVIF 变体;重建同步实际宽高与文件大小。 +- 上传落盘到入库与维护共用请求级文件锁;孤儿清理重新核验引用、路径及文件身份,并保留最近一小时的新文件。 +- 修复灯箱复用图片时残留的 AVIF source,补齐键盘操作、焦点管理与图片文字说明。 +- 数据库升级验证实际列后才推进版本;失败保留重试机会,避免缺列被误报成功。 +- 增补图片维护及灯箱回归测试;此分支不变更正式版本号,不触发发布。 + ## 1.13.4 ### 安全过滤与主题输出 @@ -495,3 +503,4 @@ - 纯图片分享:上传自动转 WebP + 缩略图、EXIF 侧栏、每图标题/描述/地址; - 后台:WebP 质量滑块 + 恢复默认、站点信息、联系方式可视化选图标、图集维护; - 去除对 Typecho 原生文件的改动,安装主题 + 插件即可使用。 + From 55cb39b617866bfcfa0ceefac84f67e239964bcf Mon Sep 17 00:00:00 2001 From: Thregren <37279823+Thregren@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:18:49 +0800 Subject: [PATCH 03/24] test: report browser fixture event diagnostics --- tests/gallery-browser.cjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/gallery-browser.cjs b/tests/gallery-browser.cjs index e20118e..6f36fbf 100644 --- a/tests/gallery-browser.cjs +++ b/tests/gallery-browser.cjs @@ -67,6 +67,16 @@ const image = ' return Object.values(events).flat().filter(h => /^poptrox\d+$/.test(h.namespace || '')).length; }); const initialHandlers = await handlerCount(); + if (initialHandlers !== 3) { + console.error('Poptrox initialization diagnostics', await page.evaluate(() => ({ + jquery: jQuery.fn.jquery, + handlers: Object.entries(jQuery._data(window, 'events') || {}).flatMap(([type, handlers]) => handlers.map(h => ({ type, namespace: h.namespace }))), + hasDestroy: typeof document.querySelector('#main')._poptroxDestroy, + patchedVendor: jQuery.fn.poptrox.toString().includes('eventNamespace'), + cards: document.querySelectorAll('#main .thumb > a.image').length, + dialogs: document.querySelectorAll('.poptrox-overlay').length + })), errors); + } assert.equal(initialHandlers, 3, 'one instance has resize, orientation and keyboard handlers'); assert.equal(await page.locator('#a').getAttribute('role'), 'button'); assert.equal(await page.locator('#a').getAttribute('tabindex'), '0'); From 927af2947c01afff8e65cc2502df19efa20b1b20 Mon Sep 17 00:00:00 2001 From: Thregren <37279823+Thregren@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:22:38 +0800 Subject: [PATCH 04/24] fix: retain Poptrox teardown without invoking jQuery setter --- README.md | 2 +- tests/gallery.cjs | 2 +- usr/themes/InfinityTime/assets/js/jquery.poptrox.min.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f1be6be..ad5ae80 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ node tests/gallery-browser.cjs CI 同时检查 PHP / JS 语法、版本一致性和更新记录。仓库测试使用隔离临时文件及 DB/编码器替身;后台脚本测试模拟 DOM,覆盖预览、拖拽、移除和无 `DataTransfer` 时的提交顺序。这些检查不能替代真实 Typecho 后台及图片编码器的端到端验证。 -维护与上传现在通过同一上传根目录内的文件锁互斥,忙时提示稍后重试。孤儿清理只处理至少一小时前的文件,恢复任务时重新核验数据库引用与文件身份;旧版任务快照会安全跳过,重新启动清理即可。文件锁需要底层文件系统支持可靠的 `flock`(多节点部署需共用支持锁的上传卷)。 +维护与上传现在通过同一上传根目录内的文件锁互斥,媒体处理串行执行,忙时提示稍后重试;尚未实现并发上传队列。孤儿清理只处理至少一小时前的文件,恢复任务时重新核验数据库引用与文件身份;旧版任务快照会安全跳过,重新启动清理即可。文件锁需要底层文件系统支持可靠的 `flock`(多节点部署需共用支持锁的上传卷)。 ### 后端 - PHP ≥ 7.4(推荐 8.x)+ Typecho 1.3;`php-gd`(WebP/缩放)、`php-exif`、可选 `php-imagick` / ImageMagick / `heif-convert`。 diff --git a/tests/gallery.cjs b/tests/gallery.cjs index 0bea410..df6bb9c 100644 --- a/tests/gallery.cjs +++ b/tests/gallery.cjs @@ -141,7 +141,7 @@ console.log('Gallery responsive fallback, navigation cancellation, and keyboard css(key, value) { if (typeof key === 'object') this.forEach(n => Object.assign(n.style, key)); else if (value !== undefined) this.forEach(n => n.style[key] = value); return this; } attr(key, value) { if (value === undefined) return this[0]?.getAttribute(key); this.forEach(n => n.setAttribute(key, value)); return this; } removeAttr(key) { this.forEach(n => n.removeAttribute(key)); return this; } - prop(key, value) { if (value === undefined) return this[0]?.[key]; this.forEach(n => n[key] = value); return this; } + prop(key, value) { if (value === undefined) return this[0]?.[key]; this.forEach((n, i) => n[key] = typeof value === 'function' ? value.call(n, i, n[key]) : value); return this; } data(key, value) { this.forEach(n => n.data ||= {}); if (value === undefined) return this[0]?.data[key]; this.forEach(n => n.data[key] = value); return this; } find(selector) { return new Collection(this.flatMap(n => descendants(n).filter(c => matches(c, selector)))); } children() { return new Collection(this.flatMap(n => n.children)); } diff --git a/usr/themes/InfinityTime/assets/js/jquery.poptrox.min.js b/usr/themes/InfinityTime/assets/js/jquery.poptrox.min.js index 0d407d2..b1561c7 100755 --- a/usr/themes/InfinityTime/assets/js/jquery.poptrox.min.js +++ b/usr/themes/InfinityTime/assets/js/jquery.poptrox.min.js @@ -1,4 +1,4 @@ /* InfinityTime: instance-scoped events / rebind cleanup; cancel pending animations and loads on close. */ /* jquery.poptrox.js v2.5.2-dev | (c) @ajlkn | github.com/ajlkn/jquery.poptrox | MIT licensed */ -!function(e){var instanceId=0;e.fn.poptrox_disableSelection=function(){return e(this).css("user-select","none").css("-khtml-user-select","none").css("-moz-user-select","none").css("-o-user-select","none").css("-webkit-user-select","none")},e.fn.poptrox=function(o){if(0==this.length)return e(this);if(this.length>1){for(var t=0;t
'),l=e(window),u=[],d=0,h=!1,g=new Array,f=function(){p=l.width(),i=l.height()+s.windowHeightPad;var e=Math.abs(x.width()-x.outerWidth()),o=Math.abs(x.height()-x.outerHeight()),t=p-2*s.windowMargin-e,r=i-2*s.windowMargin-o;x.css("min-width",s.popupWidth).css("min-height",s.popupHeight),v.children().css("max-width",t).css("max-height",r)};s.usePopupLoader||(s.popupLoaderSelector=null),s.usePopupCloser||(s.popupCloserSelector=null),s.usePopupCaption||(s.popupCaptionSelector=null),s.usePopupNav||(s.popupNavPreviousSelector=null,s.popupNavNextSelector=null);var x;x=e(s.popupSelector?s.popupSelector:'
'+(s.popupLoaderSelector?'
'+s.popupLoaderText+"
":"")+'
'+(s.popupCaptionSelector?'
':"")+(s.popupCloserSelector?''+s.popupCloserText+"":"")+(s.popupNavPreviousSelector?'':"")+(s.popupNavNextSelector?'':"")+"
");var v=x.find(".pic"),w=e(),b=x.find(s.popupLoaderSelector),m=x.find(s.popupCaptionSelector),C=x.find(s.popupCloserSelector),y=x.find(s.popupNavNextSelector),S=x.find(s.popupNavPreviousSelector),P=y.add(S);if(s.usePopupDefaultStyling&&(x.css("background",s.popupBackgroundColor).css("color",s.popupTextColor).css("padding",s.popupPadding+"px"),m.length>0&&(x.css("padding-bottom",s.popupCaptionHeight+"px"),m.css("position","absolute").css("left","0").css("bottom","0").css("width","100%").css("text-align","center").css("height",s.popupCaptionHeight+"px").css("line-height",s.popupCaptionHeight+"px"),s.popupCaptionTextSize&&m.css("font-size",popupCaptionTextSize)),C.length>0&&C.html(s.popupCloserText).css("font-size",s.popupCloserTextSize).css("background",s.popupCloserBackgroundColor).css("color",s.popupCloserTextColor).css("display","block").css("width","40px").css("height","40px").css("line-height","40px").css("text-align","center").css("position","absolute").css("text-decoration","none").css("outline","0").css("top","0").css("right","-40px"),b.length>0&&b.html("").css("position","relative").css("font-size",s.popupLoaderTextSize).on("startSpinning",function(o){var t=e("
"+s.popupLoaderText+"
");t.css("height",Math.floor(s.popupHeight/2)+"px").css("overflow","hidden").css("line-height",Math.floor(s.popupHeight/2)+"px").css("text-align","center").css("margin-top",Math.floor((x.height()-t.height()+(m.length>0?m.height():0))/2)).css("color",s.popupTextColor?s.popupTextColor:"").on("xfin",function(){t.fadeTo(300,.5,function(){t.trigger("xfout")})}).on("xfout",function(){t.fadeTo(300,.05,function(){t.trigger("xfin")})}).trigger("xfin"),b.append(t)}).on("stopSpinning",function(e){var o=b.find("div");o.remove()}),2==P.length)){P.css("font-size","75px").css("text-align","center").css("color","#fff").css("text-shadow","none").css("height","100%").css("position","absolute").css("top","0").css("opacity","0.35").css("cursor","pointer").css("box-shadow","inset 0px 0px 10px 0px rgba(0,0,0,0)").poptrox_disableSelection();var k,T;s.usePopupEasyClose?(k="100px",T="100px"):(k="75%",T="25%"),y.css("right","0").css("width",k).html('
>
'),S.css("left","0").css("width",T).html('
<
')}return l.on("resize"+eventNamespace+" orientationchange"+eventNamespace,function(){f()}),m.on("update",function(e,o){o&&0!=o.length||(o=s.popupBlankCaptionText),m.html(o)}),C.css("cursor","pointer").on("click",function(e){return e.preventDefault(),e.stopPropagation(),x.trigger("poptrox_close"),!0}),y.on("click",function(e){e.stopPropagation(),e.preventDefault(),x.trigger("poptrox_next")}),S.on("click",function(e){e.stopPropagation(),e.preventDefault(),x.trigger("poptrox_previous")}),a.css("position","fixed").css("left",0).css("top",0).css("z-index",s.baseZIndex).css("width","100%").css("height","100%").css("text-align","center").css("cursor","pointer").appendTo(s.parent).prepend('
').append('
').hide().on("touchmove",function(e){return!1}).on("click",function(e){e.preventDefault(),e.stopPropagation(),x.trigger("poptrox_close")}),x.css("display","inline-block").css("vertical-align","middle").css("position","relative").css("z-index",1).css("cursor","auto").appendTo(a).hide().on("poptrox_next",function(){var e=d+1;e>=u.length&&(e=0),x.trigger("poptrox_switch",[e])}).on("poptrox_previous",function(){var e=d-1;e<0&&(e=u.length-1),x.trigger("poptrox_switch",[e])}).on("poptrox_reset",function(){f(),x.data("width",s.popupWidth).data("height",s.popupHeight),b.hide().trigger("stopSpinning"),m.hide(),C.hide(),P.hide(),v.hide(),w.off("load").prop("onload",null).prop("onerror",null).attr("src","").detach()}).on("poptrox_open",function(e,o){return!!h||(h=!0,s.useBodyOverflow&&n.css("overflow","hidden"),s.onPopupOpen&&s.onPopupOpen(),x.addClass("loading"),void a.fadeTo(s.fadeSpeed,1,function(){x.trigger("poptrox_switch",[o,!0])}))}).on("poptrox_switch",function(o,t,p){var i;if(!p&&h)return!0;if(h=!0,x.addClass("loading").css("width",x.data("width")).css("height",x.data("height")),m.hide(),w.attr("src")&&w.attr("src",""),w.detach(),i=u[t],w=i.object,w.off("load"),v.css("text-indent","-9999px").show().append(w),"ajax"==i.type?e.get(i.src,function(e){w.html(e),w.trigger("load")}):w.attr("src",i.src),"image"!=i.type){var r,n;r=i.width,n=i.height,"%"==r.slice(-1)&&(r=parseInt(r.substring(0,r.length-1))/100*l.width()),"%"==n.slice(-1)&&(n=parseInt(n.substring(0,n.length-1))/100*l.height()),w.css("position","relative").css("outline","0").css("z-index",s.baseZIndex+100).width(r).height(n)}b.trigger("startSpinning").fadeIn(300),x.show(),s.popupIsFixed?(x.removeClass("loading").width(s.popupWidth).height(s.popupHeight),w.on("load",function(){w.off("load"),b.hide().trigger("stopSpinning"),m.trigger("update",[i.captionText]).fadeIn(s.fadeSpeed),C.fadeIn(s.fadeSpeed),v.css("text-indent",0).hide().fadeIn(s.fadeSpeed,function(){h=!1}),d=t,P.fadeIn(s.fadeSpeed)})):w.on("load",function(){f(),w.off("load"),b.hide().trigger("stopSpinning");var e=w.width(),o=w.height(),p=function(){m.trigger("update",[i.captionText]).fadeIn(s.fadeSpeed),C.fadeIn(s.fadeSpeed),v.css("text-indent",0).hide().fadeIn(s.fadeSpeed,function(){h=!1}),d=t,P.fadeIn(s.fadeSpeed),x.removeClass("loading").data("width",e).data("height",o).css("width","auto").css("height","auto")};x.animate({width:e,height:o},s.popupSpeed,"swing",p)}),"image"!=i.type&&w.trigger("load")}).on("poptrox_close",function(){return!(!h||s.usePopupForceClose)||(h=!0,a.stop(!0,!1),x.stop(!0,!1),v.stop(!0,!1),b.stop(!0,!1),m.stop(!0,!1),C.stop(!0,!1),P.stop(!0,!1),x.hide().trigger("poptrox_reset"),s.onPopupClose&&s.onPopupClose(),void a.fadeOut(s.fadeSpeed,function(){s.useBodyOverflow&&n.css("overflow","auto"),h=!1}))}).trigger("poptrox_reset"),s.usePopupEasyClose?(m.on("click","a",function(e){e.stopPropagation()}),x.css("cursor","pointer").on("click",function(e){e.stopPropagation(),e.preventDefault(),x.trigger("poptrox_close")})):x.on("click",function(e){e.stopPropagation()}),l.on("keydown"+eventNamespace,function(e){if(x.is(":visible"))switch(e.keyCode){case 37:case 32:if(s.usePopupNav)return x.trigger("poptrox_previous"),!1;break;case 39:if(s.usePopupNav)return x.trigger("poptrox_next"),!1;break;case 27:return x.trigger("poptrox_close"),!1}}),r.find(s.selector).each(function(o){var t,p,i=e(this),r=i.find("img"),n=i.data("poptrox");if("ignore"!=n&&i.attr("href")){if(t={src:i.attr("href"),captionText:r.attr("title"),width:null,height:null,type:null,object:null,options:null},s.caption){if("function"==typeof s.caption)c=s.caption(i);else if("selector"in s.caption){var a;a=i.find(s.caption.selector),"attribute"in s.caption?c=a.attr(s.caption.attribute):(c=a.html(),s.caption.remove===!0&&a.remove())}}else c=r.attr("title");if(t.captionText=c,n){var l=n.split(",");0 in l&&(t.type=l[0]),1 in l&&(p=l[1].match(/([0-9%]+)x([0-9%]+)/),p&&3==p.length&&(t.width=p[1],t.height=p[2])),2 in l&&(t.options=l[2])}if(!t.type)switch(p=t.src.match(/\/\/([a-z0-9\.]+)\/.*/),(!p||p.length<2)&&(p=[!1]),p[1]){case"api.soundcloud.com":t.type="soundcloud";break;case"youtu.be":t.type="youtube";break;case"vimeo.com":t.type="vimeo";break;case"wistia.net":t.type="wistia";break;case"bcove.me":t.type="bcove";break;default:t.type="image"}switch(p=t.src.match(/\/\/[a-z0-9\.]+\/(.*)/),t.type){case"iframe":t.object=e(''),t.object.on("click",function(e){e.stopPropagation()}).css("cursor","auto"),t.width&&t.height||(t.width="600",t.height="400");break;case"ajax":t.object=e('
'),t.object.on("click",function(e){e.stopPropagation()}).css("cursor","auto").css("overflow","auto"),t.width&&t.height||(t.width="600",t.height="400");break;case"soundcloud":t.object=e(''),t.src="//w.soundcloud.com/player/?url="+escape(t.src)+(t.options?"&"+t.options:""),t.width="600",t.height="166";break;case"youtube":t.object=e(''),t.src="//www.youtube.com/embed/"+p[1]+(t.options?"?"+t.options:""),t.width&&t.height||(t.width="800",t.height="480");break;case"vimeo":t.object=e(''),t.src="//player.vimeo.com/video/"+p[1]+(t.options?"?"+t.options:""),t.width&&t.height||(t.width="800",t.height="480");break;case"wistia":t.object=e(''),t.src="//fast.wistia.net/"+p[1]+(t.options?"?"+t.options:""),t.width&&t.height||(t.width="800",t.height="480");break;case"bcove":t.object=e(''),t.src="//bcove.me/"+p[1]+(t.options?"?"+t.options:""),t.width&&t.height||(t.width="640",t.height="360");break;default:if(t.object=e(''),s.preload){var p=document.createElement("img");p.src=t.src,g.push(p)}t.width=i.attr("width"),t.height=i.attr("height")}"file:"==window.location.protocol&&t.src.match(/^\/\//)&&(t.src="http:"+t.src),u.push(t),r.removeAttr("title"),i.removeAttr("href").css("cursor","pointer").css("outline",0).on("click",function(e){e.preventDefault(),e.stopPropagation(),x.trigger("poptrox_open",[o])})}}),r.prop("_poptrox",s).prop("_poptroxDestroy",function(){l.off(eventNamespace)}),r}}(jQuery); +!function(e){var instanceId=0;e.fn.poptrox_disableSelection=function(){return e(this).css("user-select","none").css("-khtml-user-select","none").css("-moz-user-select","none").css("-o-user-select","none").css("-webkit-user-select","none")},e.fn.poptrox=function(o){if(0==this.length)return e(this);if(this.length>1){for(var t=0;t'),l=e(window),u=[],d=0,h=!1,g=new Array,f=function(){p=l.width(),i=l.height()+s.windowHeightPad;var e=Math.abs(x.width()-x.outerWidth()),o=Math.abs(x.height()-x.outerHeight()),t=p-2*s.windowMargin-e,r=i-2*s.windowMargin-o;x.css("min-width",s.popupWidth).css("min-height",s.popupHeight),v.children().css("max-width",t).css("max-height",r)};s.usePopupLoader||(s.popupLoaderSelector=null),s.usePopupCloser||(s.popupCloserSelector=null),s.usePopupCaption||(s.popupCaptionSelector=null),s.usePopupNav||(s.popupNavPreviousSelector=null,s.popupNavNextSelector=null);var x;x=e(s.popupSelector?s.popupSelector:'
'+(s.popupLoaderSelector?'
'+s.popupLoaderText+"
":"")+'
'+(s.popupCaptionSelector?'
':"")+(s.popupCloserSelector?''+s.popupCloserText+"":"")+(s.popupNavPreviousSelector?'':"")+(s.popupNavNextSelector?'':"")+"
");var v=x.find(".pic"),w=e(),b=x.find(s.popupLoaderSelector),m=x.find(s.popupCaptionSelector),C=x.find(s.popupCloserSelector),y=x.find(s.popupNavNextSelector),S=x.find(s.popupNavPreviousSelector),P=y.add(S);if(s.usePopupDefaultStyling&&(x.css("background",s.popupBackgroundColor).css("color",s.popupTextColor).css("padding",s.popupPadding+"px"),m.length>0&&(x.css("padding-bottom",s.popupCaptionHeight+"px"),m.css("position","absolute").css("left","0").css("bottom","0").css("width","100%").css("text-align","center").css("height",s.popupCaptionHeight+"px").css("line-height",s.popupCaptionHeight+"px"),s.popupCaptionTextSize&&m.css("font-size",popupCaptionTextSize)),C.length>0&&C.html(s.popupCloserText).css("font-size",s.popupCloserTextSize).css("background",s.popupCloserBackgroundColor).css("color",s.popupCloserTextColor).css("display","block").css("width","40px").css("height","40px").css("line-height","40px").css("text-align","center").css("position","absolute").css("text-decoration","none").css("outline","0").css("top","0").css("right","-40px"),b.length>0&&b.html("").css("position","relative").css("font-size",s.popupLoaderTextSize).on("startSpinning",function(o){var t=e("
"+s.popupLoaderText+"
");t.css("height",Math.floor(s.popupHeight/2)+"px").css("overflow","hidden").css("line-height",Math.floor(s.popupHeight/2)+"px").css("text-align","center").css("margin-top",Math.floor((x.height()-t.height()+(m.length>0?m.height():0))/2)).css("color",s.popupTextColor?s.popupTextColor:"").on("xfin",function(){t.fadeTo(300,.5,function(){t.trigger("xfout")})}).on("xfout",function(){t.fadeTo(300,.05,function(){t.trigger("xfin")})}).trigger("xfin"),b.append(t)}).on("stopSpinning",function(e){var o=b.find("div");o.remove()}),2==P.length)){P.css("font-size","75px").css("text-align","center").css("color","#fff").css("text-shadow","none").css("height","100%").css("position","absolute").css("top","0").css("opacity","0.35").css("cursor","pointer").css("box-shadow","inset 0px 0px 10px 0px rgba(0,0,0,0)").poptrox_disableSelection();var k,T;s.usePopupEasyClose?(k="100px",T="100px"):(k="75%",T="25%"),y.css("right","0").css("width",k).html('
>
'),S.css("left","0").css("width",T).html('
<
')}return l.on("resize"+eventNamespace+" orientationchange"+eventNamespace,function(){f()}),m.on("update",function(e,o){o&&0!=o.length||(o=s.popupBlankCaptionText),m.html(o)}),C.css("cursor","pointer").on("click",function(e){return e.preventDefault(),e.stopPropagation(),x.trigger("poptrox_close"),!0}),y.on("click",function(e){e.stopPropagation(),e.preventDefault(),x.trigger("poptrox_next")}),S.on("click",function(e){e.stopPropagation(),e.preventDefault(),x.trigger("poptrox_previous")}),a.css("position","fixed").css("left",0).css("top",0).css("z-index",s.baseZIndex).css("width","100%").css("height","100%").css("text-align","center").css("cursor","pointer").appendTo(s.parent).prepend('
').append('
').hide().on("touchmove",function(e){return!1}).on("click",function(e){e.preventDefault(),e.stopPropagation(),x.trigger("poptrox_close")}),x.css("display","inline-block").css("vertical-align","middle").css("position","relative").css("z-index",1).css("cursor","auto").appendTo(a).hide().on("poptrox_next",function(){var e=d+1;e>=u.length&&(e=0),x.trigger("poptrox_switch",[e])}).on("poptrox_previous",function(){var e=d-1;e<0&&(e=u.length-1),x.trigger("poptrox_switch",[e])}).on("poptrox_reset",function(){f(),x.data("width",s.popupWidth).data("height",s.popupHeight),b.hide().trigger("stopSpinning"),m.hide(),C.hide(),P.hide(),v.hide(),w.off("load").prop("onload",null).prop("onerror",null).attr("src","").detach()}).on("poptrox_open",function(e,o){return!!h||(h=!0,s.useBodyOverflow&&n.css("overflow","hidden"),s.onPopupOpen&&s.onPopupOpen(),x.addClass("loading"),void a.fadeTo(s.fadeSpeed,1,function(){x.trigger("poptrox_switch",[o,!0])}))}).on("poptrox_switch",function(o,t,p){var i;if(!p&&h)return!0;if(h=!0,x.addClass("loading").css("width",x.data("width")).css("height",x.data("height")),m.hide(),w.attr("src")&&w.attr("src",""),w.detach(),i=u[t],w=i.object,w.off("load"),v.css("text-indent","-9999px").show().append(w),"ajax"==i.type?e.get(i.src,function(e){w.html(e),w.trigger("load")}):w.attr("src",i.src),"image"!=i.type){var r,n;r=i.width,n=i.height,"%"==r.slice(-1)&&(r=parseInt(r.substring(0,r.length-1))/100*l.width()),"%"==n.slice(-1)&&(n=parseInt(n.substring(0,n.length-1))/100*l.height()),w.css("position","relative").css("outline","0").css("z-index",s.baseZIndex+100).width(r).height(n)}b.trigger("startSpinning").fadeIn(300),x.show(),s.popupIsFixed?(x.removeClass("loading").width(s.popupWidth).height(s.popupHeight),w.on("load",function(){w.off("load"),b.hide().trigger("stopSpinning"),m.trigger("update",[i.captionText]).fadeIn(s.fadeSpeed),C.fadeIn(s.fadeSpeed),v.css("text-indent",0).hide().fadeIn(s.fadeSpeed,function(){h=!1}),d=t,P.fadeIn(s.fadeSpeed)})):w.on("load",function(){f(),w.off("load"),b.hide().trigger("stopSpinning");var e=w.width(),o=w.height(),p=function(){m.trigger("update",[i.captionText]).fadeIn(s.fadeSpeed),C.fadeIn(s.fadeSpeed),v.css("text-indent",0).hide().fadeIn(s.fadeSpeed,function(){h=!1}),d=t,P.fadeIn(s.fadeSpeed),x.removeClass("loading").data("width",e).data("height",o).css("width","auto").css("height","auto")};x.animate({width:e,height:o},s.popupSpeed,"swing",p)}),"image"!=i.type&&w.trigger("load")}).on("poptrox_close",function(){return!(!h||s.usePopupForceClose)||(h=!0,a.stop(!0,!1),x.stop(!0,!1),v.stop(!0,!1),b.stop(!0,!1),m.stop(!0,!1),C.stop(!0,!1),P.stop(!0,!1),x.hide().trigger("poptrox_reset"),s.onPopupClose&&s.onPopupClose(),void a.fadeOut(s.fadeSpeed,function(){s.useBodyOverflow&&n.css("overflow","auto"),h=!1}))}).trigger("poptrox_reset"),s.usePopupEasyClose?(m.on("click","a",function(e){e.stopPropagation()}),x.css("cursor","pointer").on("click",function(e){e.stopPropagation(),e.preventDefault(),x.trigger("poptrox_close")})):x.on("click",function(e){e.stopPropagation()}),l.on("keydown"+eventNamespace,function(e){if(x.is(":visible"))switch(e.keyCode){case 37:case 32:if(s.usePopupNav)return x.trigger("poptrox_previous"),!1;break;case 39:if(s.usePopupNav)return x.trigger("poptrox_next"),!1;break;case 27:return x.trigger("poptrox_close"),!1}}),r.find(s.selector).each(function(o){var t,p,i=e(this),r=i.find("img"),n=i.data("poptrox");if("ignore"!=n&&i.attr("href")){if(t={src:i.attr("href"),captionText:r.attr("title"),width:null,height:null,type:null,object:null,options:null},s.caption){if("function"==typeof s.caption)c=s.caption(i);else if("selector"in s.caption){var a;a=i.find(s.caption.selector),"attribute"in s.caption?c=a.attr(s.caption.attribute):(c=a.html(),s.caption.remove===!0&&a.remove())}}else c=r.attr("title");if(t.captionText=c,n){var l=n.split(",");0 in l&&(t.type=l[0]),1 in l&&(p=l[1].match(/([0-9%]+)x([0-9%]+)/),p&&3==p.length&&(t.width=p[1],t.height=p[2])),2 in l&&(t.options=l[2])}if(!t.type)switch(p=t.src.match(/\/\/([a-z0-9\.]+)\/.*/),(!p||p.length<2)&&(p=[!1]),p[1]){case"api.soundcloud.com":t.type="soundcloud";break;case"youtu.be":t.type="youtube";break;case"vimeo.com":t.type="vimeo";break;case"wistia.net":t.type="wistia";break;case"bcove.me":t.type="bcove";break;default:t.type="image"}switch(p=t.src.match(/\/\/[a-z0-9\.]+\/(.*)/),t.type){case"iframe":t.object=e(''),t.object.on("click",function(e){e.stopPropagation()}).css("cursor","auto"),t.width&&t.height||(t.width="600",t.height="400");break;case"ajax":t.object=e('
'),t.object.on("click",function(e){e.stopPropagation()}).css("cursor","auto").css("overflow","auto"),t.width&&t.height||(t.width="600",t.height="400");break;case"soundcloud":t.object=e(''),t.src="//w.soundcloud.com/player/?url="+escape(t.src)+(t.options?"&"+t.options:""),t.width="600",t.height="166";break;case"youtube":t.object=e(''),t.src="//www.youtube.com/embed/"+p[1]+(t.options?"?"+t.options:""),t.width&&t.height||(t.width="800",t.height="480");break;case"vimeo":t.object=e(''),t.src="//player.vimeo.com/video/"+p[1]+(t.options?"?"+t.options:""),t.width&&t.height||(t.width="800",t.height="480");break;case"wistia":t.object=e(''),t.src="//fast.wistia.net/"+p[1]+(t.options?"?"+t.options:""),t.width&&t.height||(t.width="800",t.height="480");break;case"bcove":t.object=e(''),t.src="//bcove.me/"+p[1]+(t.options?"?"+t.options:""),t.width&&t.height||(t.width="640",t.height="360");break;default:if(t.object=e(''),s.preload){var p=document.createElement("img");p.src=t.src,g.push(p)}t.width=i.attr("width"),t.height=i.attr("height")}"file:"==window.location.protocol&&t.src.match(/^\/\//)&&(t.src="http:"+t.src),u.push(t),r.removeAttr("title"),i.removeAttr("href").css("cursor","pointer").css("outline",0).on("click",function(e){e.preventDefault(),e.stopPropagation(),x.trigger("poptrox_open",[o])})}}),r.prop("_poptrox",s),r[0]._poptroxDestroy=function(){l.off(eventNamespace)},r}}(jQuery); From 0f7a7745e7c3cac02320bc7ae7fb0ca9d56a3e13 Mon Sep 17 00:00:00 2001 From: Thregren <37279823+Thregren@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:32:08 +0800 Subject: [PATCH 05/24] feat: add durable upload retry keys and stable photo metadata --- .github/workflows/ci.yml | 2 + tests/migration.php | 35 +++++++++++- tests/repository.php | 9 ++- tests/schema-sqlite.php | 56 +++++++++++++++++++ .../InfinityTime/Lib/ImageRepository.php | 22 +++++++- usr/plugins/InfinityTime/Plugin.php | 49 ++++++++++++++-- 6 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 tests/schema-sqlite.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eafa288..f3c5f0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: '8.2' + extensions: pdo_sqlite - uses: actions/setup-node@v4 with: @@ -37,6 +38,7 @@ jobs: php tests/sanitizer-fallback.php php tests/repository.php php tests/migration.php + php tests/schema-sqlite.php php tests/upload-fallback.php php tests/theme-output.php node tests/admin-upload.cjs diff --git a/tests/migration.php b/tests/migration.php index 8e6d18a..934d202 100644 --- a/tests/migration.php +++ b/tests/migration.php @@ -18,6 +18,8 @@ class Db { public array $alters = []; public ?string $failColumn = null; public bool $race = false; + public bool $indexExists = false; + public bool $failIndex = false; public string $adapter = 'SQLite'; public static function get(): self { return self::$instance; } public function getPrefix(): string { return 'test_'; } @@ -26,8 +28,26 @@ public function select(...$args): Query { return new Query(); } public function insert($table): Query { return new Query(); } public function update($table): Query { return new Query(); } public function fetchRow($query) { return false; } + public function fetchAll($query): array { + if (!$this->indexExists) { return []; } + $index = 'it_upload_' . substr(sha1('test_infinitytime_images'), 0, 16); + if (strpos($query, 'SHOW INDEX') === 0) { return [ + ['Seq_in_index' => 1, 'Non_unique' => 0, 'Column_name' => 'cid'], + ['Seq_in_index' => 2, 'Non_unique' => 0, 'Column_name' => 'upload_key'], + ]; } + if (strpos($query, 'SELECT indexdef') === 0) { return [['indexdef' => 'CREATE UNIQUE INDEX ' . $index . ' ON test_infinitytime_images USING btree (cid, upload_key)']]; } + if (strpos($query, 'PRAGMA index_list') === 0) { return [['name' => $index, 'unique' => 1]]; } + if (strpos($query, 'PRAGMA index_info') === 0) { return [['name' => 'cid'], ['name' => 'upload_key']]; } + throw new \RuntimeException('Unexpected index probe'); + } public function query($query): void { if (!is_string($query)) { return; } + if (strpos($query, 'CREATE UNIQUE INDEX') === 0) { + if ($this->failIndex) { throw new \RuntimeException('index permission denied'); } + if ($this->indexExists) { throw new \RuntimeException('index already exists'); } + $this->indexExists = true; + return; + } if (preg_match('/^SELECT ["`]test_infinitytime_images["`]\.["`]([a-z_]+)["`]/', $query, $match)) { if (!in_array($match[1], $this->columns, true)) { throw new \RuntimeException('missing column'); } return; @@ -68,7 +88,7 @@ function checkMigration(bool $condition, string $message): void { checkMigration(\Utils\Helper::$options->infinitytimeSchemaVersion === 0, 'Failure must not advance schema version'); $db->failColumn = null; ImageRepository::ensureSchema(); - checkMigration((int)\Utils\Helper::$options->infinitytimeSchemaVersion === 3, 'Same-process retry completes'); + checkMigration((int)\Utils\Helper::$options->infinitytimeSchemaVersion === 4, 'Same-process retry completes'); checkMigration(count(array_filter($db->alters, static function ($c) { return $c === 'desc'; })) === 1, 'Retry skips already-created columns'); checkMigration(!in_array('title', $db->alters, true), 'Existing columns need no ALTER'); $count = count($db->alters); @@ -77,14 +97,23 @@ function checkMigration(bool $condition, string $message): void { $db->columns = ['title', 'desc']; \Utils\Helper::$options->infinitytimeSchemaVersion = 2; Plugin::migrateSchema(); - checkMigration(in_array('mid', $db->columns, true) && in_array('avif', $db->columns, true) && (int)\Utils\Helper::$options->infinitytimeSchemaVersion === 3, 'Repair old falsely successful v2 migration'); + checkMigration(in_array('mid', $db->columns, true) && in_array('avif', $db->columns, true) && (int)\Utils\Helper::$options->infinitytimeSchemaVersion === 4, 'Repair old falsely successful v2 migration'); foreach (['SQLite', 'Mysql', 'Pgsql'] as $adapter) { $db->adapter = $adapter; $db->columns = []; $db->race = true; \Utils\Helper::$options->infinitytimeSchemaVersion = 0; Plugin::migrateSchema(); - checkMigration(count($db->columns) === 5 && (int)\Utils\Helper::$options->infinitytimeSchemaVersion === 3, 'Concurrent duplicate is accepted only after verification: ' . $adapter); + checkMigration(count($db->columns) === 7 && (int)\Utils\Helper::$options->infinitytimeSchemaVersion === 4, 'Concurrent duplicate is accepted only after verification: ' . $adapter); } + $db->indexExists = false; + $db->failIndex = true; + \Utils\Helper::$options->infinitytimeSchemaVersion = 3; + $failed = false; + try { Plugin::migrateSchema(); } catch (RuntimeException $e) { $failed = true; } + checkMigration($failed && (int)\Utils\Helper::$options->infinitytimeSchemaVersion === 3, 'Index failure must not advance schema'); + $db->failIndex = false; + Plugin::migrateSchema(); + checkMigration($db->indexExists && (int)\Utils\Helper::$options->infinitytimeSchemaVersion === 4, 'Index retry completes safely'); echo "Migration failure / retry / verification passed\n"; } diff --git a/tests/repository.php b/tests/repository.php index 0d0dc1a..92800f3 100644 --- a/tests/repository.php +++ b/tests/repository.php @@ -148,10 +148,15 @@ function expect(bool $condition, string $name): void { Repository::lockMedia(); // 同一请求可复用锁,覆盖 ingest 到 insertRow 的间隙。 $db->images = [ - ['full' => '/b.webp', 'thumb' => '/b-thumb.webp', 'title' => 'B', 'width' => 2400, 'height' => 1600, 'mid' => '/b@1600.webp', 'avif' => '/b.avif', 'exif' => '{"gps":"private","iso":100}'], - ['full' => '/a.webp', 'thumb' => '/a-thumb.webp', 'title' => 'A', 'exif' => '{}'], + ['id' => 21, 'created' => 1767225600, 'full' => '/b.webp', 'thumb' => '/b-thumb.webp', 'title' => 'B', 'width' => 2400, 'height' => 1600, 'mid' => '/b@1600.webp', 'avif' => '/b.avif', 'exif' => '{"gps":"private","iso":100}'], + ['id' => 15, 'created' => 1735689600, 'full' => '/a.webp', 'thumb' => '/a-thumb.webp', 'title' => 'A', 'exif' => '{}'], ]; + expect(Repository::photoMonth(['datetime' => '2024:02:29 10:11:12'], 1735689600) === '2024-02', 'EXIF valid leap date keeps camera month'); + expect(Repository::photoMonth(['datetime' => '2025:02:29 10:11:12'], 1735689600) === '2025-01', 'Invalid EXIF day uses creation UTC month'); + expect(Repository::photoMonth([], 0) === '', 'Missing dates remain unknown'); Repository::syncPostFields(1); + expect(json_decode($db->fields['photo_ids'], true) === [21, 15], 'Stable IDs follow sorted rows, not positions'); + expect(json_decode($db->fields['months'], true) === ['2026-01', '2025-01'], 'Photo months synced in same order'); expect($db->fields['img'] === "/b.webp\n/a.webp", '图片路径与仓库排序一致'); expect($db->fields['thumb'] === "/b-thumb.webp\n/a-thumb.webp", '缩略图同序'); expect(json_decode($db->fields['dims'], true) === ['2400x1600', ''], '首次同步包含尺寸字段'); diff --git a/tests/schema-sqlite.php b/tests/schema-sqlite.php new file mode 100644 index 0000000..2da854f --- /dev/null +++ b/tests/schema-sqlite.php @@ -0,0 +1,56 @@ +pdo = new \PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); } + public static function get(): self { return self::$instance; } + public function getPrefix(): string { return 'fixture_'; } + public function getAdapterName(): string { return 'Pdo_SQLite'; } + public function select(...$args): Query { return new Query(); } + public function insert($table): Query { return new Query(); } + public function update($table): Query { return new Query(); } + public function fetchRow($query) { return false; } + public function fetchAll($query): array { return $this->pdo->query($query)->fetchAll(\PDO::FETCH_ASSOC); } + public function query($query) { return is_string($query) ? $this->pdo->exec($query) : null; } + } +} +namespace Utils { + class Helper { + public static object $options; + public static function options(): object { return self::$options; } + } +} +namespace { + interface Typecho_Plugin_Interface {} + require __DIR__ . '/../usr/plugins/InfinityTime/Plugin.php'; + use TypechoPlugin\InfinityTime\Plugin; + $db = \Typecho\Db::$instance = new \Typecho\Db(); + \Utils\Helper::$options = (object)['infinitytimeSchemaVersion' => 3]; + $db->query('CREATE TABLE fixture_infinitytime_images (id INTEGER PRIMARY KEY, cid INTEGER, full TEXT, title TEXT, "desc" TEXT, mid TEXT, avif TEXT, mid_avif TEXT)'); + $db->query("INSERT INTO fixture_infinitytime_images (id,cid,full) VALUES (1,10,'/keep-a.webp'),(2,10,'/keep-b.webp')"); + Plugin::migrateSchema(); + $db->query("INSERT INTO fixture_infinitytime_images (id,cid,upload_key,upload_fingerprint) VALUES (3,10,'retry-key','hash-a')"); + $rejected = false; + try { $db->query("INSERT INTO fixture_infinitytime_images (id,cid,upload_key) VALUES (4,10,'retry-key')"); } + catch (\PDOException $e) { $rejected = true; } + if (!$rejected) { throw new \RuntimeException('Retry key must be unique within the album'); } + $db->query("INSERT INTO fixture_infinitytime_images (id,cid,upload_key) VALUES (4,11,'retry-key')"); + // Retry migration after an interrupted version-option update, with index already present. + \Utils\Helper::$options->infinitytimeSchemaVersion = 3; + Plugin::migrateSchema(); + $rows = $db->fetchAll('SELECT id,full,upload_key FROM fixture_infinitytime_images ORDER BY id'); + if (count($rows) !== 4 || $rows[0]['full'] !== '/keep-a.webp' || $rows[1]['full'] !== '/keep-b.webp' + || $rows[0]['upload_key'] !== null || (int)\Utils\Helper::$options->infinitytimeSchemaVersion !== 4) { + throw new \RuntimeException('Migration must preserve existing images and nullable keys'); + } + echo "SQLite migration preservation / unique retry keys / interrupted retry passed\n"; +} diff --git a/usr/plugins/InfinityTime/Lib/ImageRepository.php b/usr/plugins/InfinityTime/Lib/ImageRepository.php index ad1c57a..274ec4f 100644 --- a/usr/plugins/InfinityTime/Lib/ImageRepository.php +++ b/usr/plugins/InfinityTime/Lib/ImageRepository.php @@ -240,6 +240,7 @@ public static function defaultDirs(): array */ public static function insertRow(int $cid, array $meta, int $sort = 0): int { + self::ensureSchema(); self::lockMedia(); $db = \Typecho\Db::get(); $table = self::table(); @@ -256,6 +257,8 @@ public static function insertRow(int $cid, array $meta, int $sort = 0): int 'size' => $meta['size'], 'sort' => $sort, 'hash' => $meta['hash'] ?? '', + 'upload_key' => $meta['upload_key'] ?? null, + 'upload_fingerprint' => $meta['upload_fingerprint'] ?? null, 'exif' => json_encode($meta['exif'] ?? [], JSON_UNESCAPED_UNICODE), 'gps_lat' => $meta['gps_lat'] ?? null, 'gps_lng' => $meta['gps_lng'] ?? null, @@ -363,9 +366,13 @@ public static function syncPostFields(int $cid, bool $clearEmpty = false): void $dims = []; $variants = []; $exifs = []; + $photoIds = []; + $months = []; $images = []; $thumbs = []; foreach ($rows as $r) { + $photoIds[] = (int)($r['id'] ?? 0); + $months[] = self::photoMonth(is_array($r['exif'] ?? null) ? $r['exif'] : [], (int)($r['created'] ?? 0)); $images[] = (string)($r['full'] ?? ''); $thumbs[] = (string)($r['thumb'] ?? ''); $addresses[] = (string)($r['address'] ?? ''); @@ -396,8 +403,8 @@ public static function syncPostFields(int $cid, bool $clearEmpty = false): void 'cid' => $cid, 'name' => $name, 'type' => 'str', 'str_value' => implode("\n", $paths), ])); } - $map = ['addresses' => $addresses, 'titles' => $titles, 'descs' => $descs, 'panos' => $panos, 'dims' => $dims, 'variants' => $variants, 'exif' => $exifs]; - foreach (['addresses', 'titles', 'descs', 'panos', 'dims', 'variants', 'exif'] as $f) { + $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)); $val = $map[$f]; // 注意:variants 的元素是数组,不能直接用 array_filter($val, 'strlen')(PHP 8 会对数组调 strlen 报错) @@ -417,6 +424,17 @@ public static function syncPostFields(int $cid, bool $clearEmpty = false): void } } + /** EXIF dates are wall-clock camera dates; timestamps fall back to documented UTC. */ + public static function photoMonth(array $exif, int $created): string + { + $date = (string)($exif['datetime'] ?? ''); + if (preg_match('/^(\d{4})[:-](\d{2})[:-](\d{2})(?:[ T]|$)/', $date, $parts) + && checkdate((int)$parts[2], (int)$parts[3], (int)$parts[1])) { + return $parts[1] . '-' . $parts[2]; + } + return $created > 0 ? gmdate('Y-m', $created) : ''; + } + /** 删除某 cid 的所有图片(含文件)。 */ public static function removeFor(int $cid): void { diff --git a/usr/plugins/InfinityTime/Plugin.php b/usr/plugins/InfinityTime/Plugin.php index b96196c..5406c3d 100644 --- a/usr/plugins/InfinityTime/Plugin.php +++ b/usr/plugins/InfinityTime/Plugin.php @@ -372,6 +372,8 @@ private static function createTables(): void {$q}size{$q} integer DEFAULT 0, {$q}sort{$q} integer DEFAULT 0, {$q}hash{$q} varchar(64) DEFAULT '', + {$q}upload_key{$q} varchar(64) DEFAULT NULL, + {$q}upload_fingerprint{$q} varchar(64) DEFAULT NULL, {$q}exif{$q} text, {$q}gps_lat{$q} real, {$q}gps_lng{$q} real, @@ -401,7 +403,7 @@ private static function createTables(): void } /** 当前 schema 版本。新增列时 +1,并在 migrateSchema 里补对应 ALTER。 */ - private const SCHEMA_VERSION = 3; + private const SCHEMA_VERSION = 4; /** 按版本补齐旧表缺失的列(幂等;每进程/每次激活最多跑一次 DDL)。 */ public static function migrateSchema(): void @@ -415,8 +417,9 @@ public static function migrateSchema(): void return; } // v3 一次性验证全部旧列,修复曾被 v2 错误标记成功的安装。 - $cols = ['title', 'desc', 'mid', 'avif', 'mid_avif']; - foreach ($cols as $col) { + $cols = ['title' => 'text', 'desc' => 'text', 'mid' => 'text', 'avif' => 'text', 'mid_avif' => 'text', + 'upload_key' => 'varchar(64) DEFAULT NULL', 'upload_fingerprint' => 'varchar(64) DEFAULT NULL']; + foreach ($cols as $col => $definition) { // 使用限定列名,避免 SQLite 将不存在的双引号列名当作字符串。 $probe = "SELECT {$q}{$table}{$q}.{$q}{$col}{$q} FROM {$q}{$table}{$q} WHERE 1 = 0"; try { @@ -426,7 +429,7 @@ public static function migrateSchema(): void // 缺列时尝试补齐;其他读取错误也不能冒充成功。 } try { - $db->query("ALTER TABLE {$q}{$table}{$q} ADD COLUMN {$q}{$col}{$q} text"); + $db->query("ALTER TABLE {$q}{$table}{$q} ADD COLUMN {$q}{$col}{$q} {$definition}"); } catch (\Throwable $e) { // 另一进程可能已加列;下面实际探测成功才允许继续。 } @@ -436,9 +439,47 @@ public static function migrateSchema(): void throw new \RuntimeException('InfinityTime 数据库升级失败,请检查数据库权限后重试(列:' . $col . ')', 0, $e); } } + self::ensureUploadIndex($db, $table); self::setOption('infinitytimeSchemaVersion', (string)self::SCHEMA_VERSION); } + /** A nullable key preserves existing rows and gives retries a database safety net. */ + private static function ensureUploadIndex($db, string $table): void + { + $adapter = strtolower($db->getAdapterName()); + $mysql = strpos($adapter, 'mysql') !== false; + $pgsql = strpos($adapter, 'pgsql') !== false || strpos($adapter, 'postgres') !== false; + $q = $mysql ? '`' : '"'; + $index = 'it_upload_' . substr(sha1($table), 0, 16); + try { + $db->query("CREATE UNIQUE INDEX {$q}{$index}{$q} ON {$q}{$table}{$q} ({$q}cid{$q}, {$q}upload_key{$q})"); + return; + } catch (\Throwable $e) { + // Only a verified existing unique index permits a concurrent migration retry. + } + $valid = false; + if ($mysql) { + $rows = $db->fetchAll("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("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("PRAGMA index_list(" . $q . $table . $q . ")") as $row) { + if (($row['name'] ?? '') !== $index || (int)($row['unique'] ?? 0) !== 1) { continue; } + $columns = $db->fetchAll("PRAGMA index_info(" . $q . $index . $q . ")"); + $valid = array_column($columns, 'name') === ['cid', 'upload_key']; + } + } + if (!$valid) { + throw new \RuntimeException('InfinityTime 上传去重索引创建失败,请检查数据库权限后重试'); + } + } + private static function safeName(string $name): string { $info = pathinfo($name); From 103ea2a8b1bc3dd60d5546ebf3cec8ab2e8a194c Mon Sep 17 00:00:00 2001 From: Thregren <37279823+Thregren@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:42:21 +0800 Subject: [PATCH 06/24] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=8D=89=E7=A8=BF?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E9=87=8D=E8=AF=95=E3=80=81=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E6=9D=83=E9=99=90=E5=8A=A0=E5=9B=BA=E4=B8=8E=E5=89=8D=E5=8F=B0?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E9=99=8D=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 7 +- tests/admin-upload.cjs | 398 +++++++++++-- tests/admin-workflow.php | 203 +++++++ tests/gallery-loading-browser.cjs | 171 ++++++ tests/gallery-loading.cjs | 172 ++++++ tests/migration.php | 8 +- tests/repository.php | 9 +- tests/schema-sqlite.php | 23 +- .../InfinityTime/Lib/AdminRepository.php | 156 ++++++ .../InfinityTime/Lib/AdminSecurity.php | 17 + .../InfinityTime/Lib/AdminWorkflow.php | 141 +++++ .../InfinityTime/Lib/ImageRepository.php | 23 +- usr/plugins/InfinityTime/Plugin.php | 31 +- usr/plugins/InfinityTime/assets/admin.css | 21 + usr/plugins/InfinityTime/assets/admin.js | 523 ++++++++++++------ usr/plugins/InfinityTime/panel.php | 496 ++++++++--------- usr/themes/InfinityTime/assets/css/main.css | 27 + usr/themes/InfinityTime/assets/js/init.js | 183 ++++-- .../assets/js/jquery.poptrox.min.js | 2 +- usr/themes/InfinityTime/assets/js/lightbox.js | 116 +++- usr/themes/InfinityTime/assets/js/main.js | 326 ++++++++--- 21 files changed, 2387 insertions(+), 666 deletions(-) create mode 100644 tests/admin-workflow.php create mode 100644 tests/gallery-loading-browser.cjs create mode 100644 tests/gallery-loading.cjs create mode 100644 usr/plugins/InfinityTime/Lib/AdminRepository.php create mode 100644 usr/plugins/InfinityTime/Lib/AdminSecurity.php create mode 100644 usr/plugins/InfinityTime/Lib/AdminWorkflow.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3c5f0a..295e17c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,10 +39,12 @@ jobs: php tests/repository.php php tests/migration.php php tests/schema-sqlite.php + php tests/admin-workflow.php php tests/upload-fallback.php php tests/theme-output.php node tests/admin-upload.cjs node tests/gallery.cjs + node tests/gallery-loading.cjs - name: Version consistency run: | @@ -71,4 +73,7 @@ jobs: npm install --no-save --package-lock=false playwright@1.62.1 npx playwright install --with-deps chromium - name: Gallery browser regressions - run: node tests/gallery-browser.cjs + run: | + node tests/gallery-browser.cjs + node tests/gallery-loading-browser.cjs + node tests/admin-upload.cjs --browser diff --git a/tests/admin-upload.cjs b/tests/admin-upload.cjs index 0a45a04..fd48ac0 100644 --- a/tests/admin-upload.cjs +++ b/tests/admin-upload.cjs @@ -1,64 +1,360 @@ -// 在无 DataTransfer 的环境运行真实后台脚本,验证预览、移除、排序和提交。 +// 使用可控的 DOM、网络与存储替身运行真实后台脚本。 +// 无需浏览器依赖;同时覆盖成功、中断和重复操作。 const assert = require('node:assert/strict'); const vm = require('node:vm'); const fs = require('node:fs'); +const source = fs.readFileSync(require.resolve('../usr/plugins/InfinityTime/assets/admin.js'), 'utf8'); + class Element { - constructor() { this.children = []; this.dataset = {}; this.style = {}; this.listeners = {}; this.classList = { add() {}, remove() {} }; } - addEventListener(name, fn) { this.listeners[name] = fn; } - appendChild(child) { child.parentNode = this; this.children.push(child); } - removeChild(child) { this.children.splice(this.children.indexOf(child), 1); } - set innerHTML(value) { this.children = []; } - querySelector() { return null; } + constructor(tag = 'div') { + this.tagName = tag.toUpperCase(); this.children = []; this.dataset = {}; this.style = {}; + this.listeners = {}; this.attributes = {}; this.className = ''; this.value = ''; this.disabled = false; + this.classList = { + contains: name => this.className.split(/\s+/).includes(name), + add: (...names) => { this.className = [...new Set(this.className.split(/\s+/).filter(Boolean).concat(names))].join(' '); }, + remove: (...names) => { this.className = this.className.split(/\s+/).filter(n => !names.includes(n)).join(' '); }, + toggle: (name, force) => { const yes = force === undefined ? !this.classList.contains(name) : force; this.classList[yes ? 'add' : 'remove'](name); return yes; } + }; + } + addEventListener(name, fn) { (this.listeners[name] ||= []).push(fn); } + emit(name, extra = {}) { + const event = { target: this, preventDefault() { this.defaultPrevented = true; }, stopPropagation() {}, ...extra }; + for (const fn of this.listeners[name] || []) fn(event); + return event; + } + click() { if (!this.disabled) this.emit('click'); } + appendChild(child) { if (child.parentNode) child.parentNode.removeChild(child); child.parentNode = this; this.children.push(child); return child; } + removeChild(child) { this.children.splice(this.children.indexOf(child), 1); child.parentNode = null; } + insertBefore(child, next) { child.parentNode = this; const i = this.children.indexOf(next); this.children.splice(i < 0 ? this.children.length : i, 0, child); } + get firstElementChild() { return this.children[0] || null; } + get nextSibling() { if (!this.parentNode) return null; return this.parentNode.children[this.parentNode.children.indexOf(this) + 1] || null; } + set textContent(value) { this._text = String(value); this.children = []; } + get textContent() { return (this._text || '') + this.children.map(c => c.textContent).join(''); } + set innerHTML(value) { this.children = []; this._text = String(value); } + get innerHTML() { return this._text || ''; } + set outerHTML(value) { this.replacedHTML = value; } + get outerHTML() { return '
'; } + setAttribute(name, value) { + this.attributes[name] = String(value); + if (name.startsWith('data-')) this.dataset[name.slice(5).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = String(value); + else if (name === 'class') this.className = value; + else if (['id', 'name', 'type'].includes(name)) this[name] = value; + } + getAttribute(name) { + if (name.startsWith('data-')) return this.dataset[name.slice(5).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] ?? null; + return this.attributes[name] ?? this[name] ?? null; + } + matches(selector) { + const attributes = [...selector.matchAll(/\[([^=\]]+)(?:=["']?([^\]"']+)["']?)?\]/g)]; + selector = selector.replace(/\[[^\]]+\]/g, ''); + const id = selector.match(/#([\w-]+)/), tag = selector.match(/^[\w-]+/), classes = [...selector.matchAll(/\.([\w-]+)/g)]; + return (!id || this.id === id[1]) && (!tag || this.tagName.toLowerCase() === tag[0]) && classes.every(m => this.classList.contains(m[1])) && + attributes.every(m => this.getAttribute(m[1]) !== null && (m[2] === undefined || this.getAttribute(m[1]) === m[2])); + } querySelectorAll(selector) { - if (selector === '.pp-up-thumb') { return this.children.map(c => c.children[0]); } - return []; + if (selector.startsWith(':scope > ')) return this.children.filter(c => c.matches(selector.slice(9))); + const selectors = selector.split(',').map(s => s.trim()), out = []; + const visit = child => { if (selectors.some(s => child.matches(s))) out.push(child); child.children.forEach(visit); }; + this.children.forEach(visit); return out; } + querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } + closest(selector) { return selector.split(',').some(s => this.matches(s.trim())) ? this : this.parentNode?.closest(selector) || null; } + scrollIntoView() {} + reset() { this.querySelectorAll('input,textarea').forEach(el => { el.value = el.defaultValue || ''; }); } } -const form = new Element(), input = new Element(), previews = new Element(); -input.name = 'files[]'; -const selectors = { '#pp-upload-form': form, '#pp-files-input': input, '#pp-upload-previews': previews }; -const document = new Element(); -document.querySelector = selector => selectors[selector] || null; -document.createElement = () => new Element(); -let serial = 0, sent, endpoint; -const activeUrls = new Set(); -const urlApi = { - createObjectURL() { const url = 'blob:test-' + ++serial; activeUrls.add(url); return url; }, - revokeObjectURL(url) { assert.ok(activeUrls.delete(url), 'URL 仅回收一次'); } -}; class FormDataMock { - constructor() { this.entries = []; } + constructor(form) { + this.entries = []; + if (form) form.querySelectorAll('input,textarea,select').filter(el => el.name && !el.disabled).forEach(el => this.append(el.name, el.value)); + } set(key, value) { this.delete(key); this.append(key, value); } + get(key) { return this.entries.find(entry => entry[0] === key)?.[1] ?? null; } delete(key) { this.entries = this.entries.filter(entry => entry[0] !== key); } append(key, value) { this.entries.push([key, value]); } } -const context = { - window: { PP_ADMIN: { url: '/admin/endpoint', token: 'test' }, URL: urlApi }, - URL: urlApi, document, FormData: FormDataMock, - XMLHttpRequest: class { - constructor() { this.upload = {}; } - open(method, url) { endpoint = url; } - send(data) { sent = data; } - } -}; -vm.runInNewContext(fs.readFileSync(require.resolve('../usr/plugins/InfinityTime/assets/admin.js'), 'utf8'), context); -document.listeners.DOMContentLoaded(); +const flush = async () => { for (let i = 0; i < 15; i++) await new Promise(resolve => setImmediate(resolve)); }; +const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; +const response = (data, ok = true) => ({ ok, json: async () => data, text: async () => typeof data === 'string' ? data : JSON.stringify(data) }); +function harness({ storage = new Map(), fetchHandler, initialCid = '', initialTarget = null, canPublish = true } = {}) { + const document = new Element('document'); + const make = (tag, props, parent = document) => { const element = new Element(tag); Object.assign(element, props); parent.appendChild(element); return element; }; + const container = make('div', { className: 'container typecho-page-main' }); + const form = make('form', { id: 'pp-upload-form' }, container); + ['title', 'address', 'device', 'tags', 'operation_key'].forEach(name => make('input', { name, type: name === 'operation_key' ? 'hidden' : 'text', value: name === 'title' ? '测试图集' : '' }, form)); + const cid = make('input', { id: 'pp-upload-cid', name: 'cid', type: 'hidden', value: initialCid }, form); + const input = make('input', { id: 'pp-files-input', name: 'files[]', type: 'file', files: [] }, form); + const previews = make('div', { id: 'pp-upload-previews' }, form); + const target = make('div', { id: 'pp-upload-target' }, form); + const summary = make('div', { id: 'pp-upload-summary' }, form); + const dropzone = make('div', { id: 'pp-dropzone' }, form); + const progress = make('div', { id: 'pp-upload-progress' }, form); + make('div', { id: 'pp-upload-bar' }, progress); make('span', { className: 'pp-msg' }, progress); + const submit = make('button', { id: 'pp-upload-submit', type: 'submit' }, form); + const retry = make('button', { id: 'pp-upload-retry', type: 'button' }, form); + const publish = make('button', { id: 'pp-upload-publish', type: 'button' }, form); + const fresh = make('button', { id: 'pp-upload-new', type: 'button' }, form); + const albums = make('div', { id: 'pp-albums-card', dataset: { page: '3', query: '旅行 & 海' } }); + const append = make('a', { dataset: { appendCid: '90', appendTitle: '已有图集' } }, albums); + const details = make('details', { className: 'pp-album', open: false }, albums); + const thumbs = make('div', { className: 'pp-thumbs', dataset: { cid: '90', loaded: '0' } }, details); + const maintenance = make('button', { dataset: { run: 'rebuild' } }); + const maintenanceMsg = make('div', { id: 'pp-msg-rebuild' }); + document.createElement = tag => new Element(tag); + const activeUrls = new Set(); let serial = 0; + const URLMock = { createObjectURL() { const url = 'blob:test-' + ++serial; activeUrls.add(url); return url; }, revokeObjectURL(url) { assert.ok(activeUrls.delete(url), '每个预览 URL 最多回收一次'); } }; + const requests = [], uploads = [], window = new Element('window'); + Object.assign(window, { PP_ADMIN: { url: '/admin/endpoint', token: 'secret-csrf', userId: 7, uploadTarget: initialTarget, canPublish: canPublish }, URL: URLMock, + sessionStorage: { getItem: key => storage.get(key), setItem: (key, value) => storage.set(key, value) } }); + let serverCount = 0, serverStatus = 'draft'; + const context = { window, document, URL: URLMock, FormData: FormDataMock, Promise, setTimeout, + confirm: () => true, location: { hash: '' }, history: { replaceState() {} }, + DOMParser: class { parseFromString() { const doc = new Element('document'); make('div', { id: 'pp-albums-card' }, doc); return doc; } }, + fetch: async (url, options = {}) => { + requests.push({ url, ...options }); + if (fetchHandler) { const handled = fetchHandler(url, options); if (handled !== undefined) return handled; } + if (options.body?.get('action') === 'create_draft') return response({ ok: true, cid: 42, status: 'draft' }); + if (options.body?.get('action') === 'publish_album') { serverStatus = 'publish'; return response({ ok: true }); } + if (options.body?.get('action') === 'maintenance') return response({ ok: true, finished: true, msg: '完成' }); + if (url.includes('job=album_status')) return response({ ok: true, cid: 42, title: '测试图集', count: serverCount, status: serverStatus }); + return response('
'); + }, + XMLHttpRequest: class { + constructor() { this.upload = {}; } + open(method, url) { this.method = method; this.url = url; } + send(data) { this.data = data; uploads.push(this); } + reply(data, status = 200) { if (data.ok && !data.replayed) serverCount++; this.status = status; this.responseText = JSON.stringify(data); this.onload(); } + } + }; + vm.runInNewContext(source, context); document.emit('DOMContentLoaded'); + return { document, form, input, previews, target, summary, submit, retry, publish, fresh, cid, dropzone, append, details, thumbs, maintenance, maintenanceMsg, + activeUrls, requests, uploads, storage, window, + select(files) { input.files = files; input.emit('change'); }, + submitForm() { form.emit('submit'); }, + setCount(count) { serverCount = count; }, + setStatus(status) { serverStatus = status; }, + states: () => previews.children.map(c => c.dataset.state), + field: name => form.querySelector('[name="' + name + '"]') }; +} const files = ['a.jpg', 'b.jpg', 'c.jpg'].map(name => ({ name, size: 10, lastModified: 1 })); -input.files = files; -input.listeners.change(); -assert.equal(previews.children.length, 3); -assert.equal(activeUrls.size, 3, '必须生成真实预览 URL'); -assert.equal(input.required, false, '文件队列由 JS 校验'); -const drop = value => ({ preventDefault() {}, dataTransfer: { getData() { return value; } } }); -previews.children[0].listeners.drop(drop('999')); -assert.equal(previews.children.length, 3, '越界拖拽不破坏队列'); -previews.children[0].listeners.drop(drop('2')); -assert.deepEqual(previews.children.map(c => c.children[0].alt), ['c.jpg', 'a.jpg', 'b.jpg']); -assert.equal(activeUrls.size, 3, '重排回收旧 URL'); -previews.children[1].children[1].listeners.click(); -assert.deepEqual(previews.children.map(c => c.children[0].alt), ['c.jpg', 'b.jpg']); -assert.equal(activeUrls.size, 2, '移除回收旧 URL'); -form.listeners.submit({ preventDefault() {} }); -assert.equal(endpoint, '/admin/endpoint', '提交地址不受原生 URL API 影响'); -assert.deepEqual(sent.entries.filter(([key]) => key === 'files[]').map(([, file]) => file.name), ['c.jpg', 'b.jpg'], '无 DataTransfer 也按预览顺序提交'); -console.log('Admin upload preview / removal / sorting / submission passed'); +const drop = index => ({ dataTransfer: { getData() { return String(index); } } }); + +(async () => { + const draft = deferred(); + const h = harness({ fetchHandler(url, options) { if (options.body?.get('action') === 'create_draft') return draft.promise; } }); + h.select(files); h.select(files); + assert.equal(h.previews.children.length, 3, '忽略重复选择的文件'); + assert.equal(h.activeUrls.size, 3); + assert.equal(h.input.required, false); + h.previews.children[0].emit('drop', drop(999)); + h.previews.children[0].emit('drop', drop(2)); + assert.deepEqual(h.previews.children.map(c => c.children[0].alt), ['c.jpg', 'a.jpg', 'b.jpg']); + h.previews.children[1].children[1].click(); + assert.deepEqual(h.previews.children.map(c => c.children[0].alt), ['c.jpg', 'b.jpg']); + assert.equal(h.activeUrls.size, 2, '移除预览后释放其 URL'); + h.previews.children[0].querySelector('.pp-up-tit').value = '首张标题'; + h.previews.children[0].querySelector('.pp-up-tit').emit('input'); + h.submitForm(); h.submitForm(); + assert.equal(h.requests.filter(r => r.body?.get('action') === 'create_draft').length, 1, '重复提交不会创建第二份草稿'); + h.previews.children[0].emit('drop', drop(1)); h.previews.children[0].children[1].emit('click'); h.select([files[0]]); + assert.deepEqual(h.previews.children.map(c => c.children[0].alt), ['c.jpg', 'b.jpg'], '处理期间不能更改队列'); + assert.equal(h.input.disabled, true); + draft.resolve(response({ ok: true, cid: 42, status: 'draft' })); await flush(); + assert.equal(h.uploads.length, 1, '同一时刻仅有首张图片请求在执行'); + assert.equal(h.uploads[0].url, '/admin/endpoint'); + assert.equal(h.uploads[0].method, 'POST'); + assert.equal(h.uploads[0].data.get('action'), 'upload_image'); + assert.equal(h.uploads[0].data.get('file').name, 'c.jpg', '没有 DataTransfer 时也按预览顺序串行提交'); + assert.equal(h.uploads[0].data.get('files[]'), null, '不提交批量文件字段'); + assert.equal(h.uploads[0].data.get('title'), '首张标题'); + assert.equal(h.uploads[0].data.get('_'), 'secret-csrf'); + assert.deepEqual(h.states(), ['uploading', 'queued']); + h.uploads[0].upload.onprogress({ lengthComputable: true, loaded: 10, total: 10 }); + assert.deepEqual(h.states(), ['converting', 'queued'], '转换阶段具有独立状态'); + h.uploads[0].reply({ ok: true, rowId: 100, cid: 42 }); await flush(); + assert.equal(h.uploads.length, 2); + assert.deepEqual(h.states(), ['done', 'uploading']); + const failedKey = h.uploads[1].data.get('item_key'); + h.uploads[1].reply({ ok: false, retryable: true, msg: '正忙' }); await flush(); + assert.deepEqual(h.states(), ['done', 'failed']); + assert.equal(h.publish.disabled, true, '部分失败时不能误发布'); + assert.equal(h.requests.some(r => r.body?.get('action') === 'publish_album'), false, '保存不会隐式发布图集'); + const listRead = h.requests.find(r => r.url.includes('job=albums_html')); + assert.match(listRead.url, /page=3/); + assert.match(listRead.url, /q=%E6%97%85%E8%A1%8C%20%26%20%E6%B5%B7/, 'AJAX 刷新保留搜索条件与页码'); + h.retry.click(); h.retry.click(); await flush(); + assert.equal(h.uploads.length, 3, '重试仅提交失败项,且不会重复提交'); + assert.equal(h.uploads[2].data.get('item_key'), failedKey, '重试保留原幂等键'); + h.uploads[2].reply({ ok: true, rowId: 101, cid: 42 }); await flush(); + assert.deepEqual(h.states(), ['done', 'done']); + assert.equal(h.publish.disabled, false); + h.publish.click(); h.publish.click(); await flush(); + assert.equal(h.requests.filter(r => r.body?.get('action') === 'publish_album').length, 1); + assert.equal(h.publish.hidden, true); + h.fresh.click(); assert.equal(h.activeUrls.size, 0, '新建另一草稿会释放旧预览'); + assert.equal(h.cid.value, ''); + + // 响应丢失时,图片可能已保存入库;刷新后必须保留原图片幂等键。 + const interrupted = harness(); interrupted.select(files.slice(0, 2)); interrupted.submitForm(); await flush(); + interrupted.uploads[0].reply({ ok: true, rowId: 110 }); await flush(); + const uncertainKey = interrupted.uploads[1].data.get('item_key'); + interrupted.uploads[1].upload.onload(); interrupted.uploads[1].onerror(); await flush(); + const recovered = harness({ storage: interrupted.storage }); recovered.setCount(2); await flush(); + assert.deepEqual(recovered.states(), ['done', 'failed'], '刷新后仍保留服务器已保存项的状态'); + assert.equal(recovered.uploads.length, 0, '刷新后不会假设本地文件内容仍然可用'); + recovered.retry.click(); await flush(); assert.equal(recovered.uploads.length, 0, '文件内容缺失时必须重新选择'); + recovered.select(files.slice(0, 2)); recovered.retry.click(); await flush(); + assert.equal(recovered.uploads.length, 1, '重新选择整批文件时跳过已保存项'); + assert.equal(recovered.uploads[0].data.get('item_key'), uncertainKey); + recovered.uploads[0].reply({ ok: true, replayed: true, rowId: 111 }); await flush(); + assert.deepEqual(recovered.states(), ['done', 'done']); + assert.match(recovered.target.textContent, /已保存 2 张/, '幂等重试后的数量以服务器结果为准'); + + // 创建响应丢失后必须复用原操作幂等键,避免重复创建图集。 + let creates = 0; + const lostDraft = harness({ fetchHandler(url, options) { + if (options.body?.get('action') === 'create_draft' && ++creates === 1) return Promise.reject(new Error('网络断开')); + } }); + lostDraft.select([files[0]]); lostDraft.submitForm(); await flush(); lostDraft.submitForm(); await flush(); + const createCalls = lostDraft.requests.filter(r => r.body?.get('action') === 'create_draft'); + assert.equal(createCalls.length, 2); assert.equal(createCalls[0].body.get('operation_key'), createCalls[1].body.get('operation_key')); + lostDraft.uploads[0].reply({ ok: true, rowId: 1 }); await flush(); + + // 已有已发布图集支持追加图片,并保持发布状态。 + const append = harness(); append.setStatus('publish'); append.setCount(4); append.append.click(); await flush(); + append.select([files[0]]); append.submitForm(); await flush(); + assert.equal(append.uploads[0].data.get('cid'), '90'); + assert.equal(append.requests.some(r => r.body?.get('action') === 'create_draft'), false); + append.uploads[0].reply({ ok: true, rowId: 90 }); await flush(); + assert.equal(append.publish.hidden, true); + + // 明确选中的、经服务器授权的图集优先于旧会话队列。 + const targeted = harness({ storage: interrupted.storage, initialTarget: { cid: 90, title: '明确选择的图集', status: 'publish', count: 4 } }); + targeted.setStatus('publish'); await flush(); + assert.equal(targeted.cid.value, '90'); assert.equal(targeted.previews.children.length, 0); + assert.equal(targeted.fresh.hidden, false, '服务器初始隐藏的新建按钮可正常显示'); + + // 贡献者可以保存草稿,但不能发布或修改已发布图集。 + const contributor = harness({ canPublish: false }); contributor.select([files[0]]); contributor.submitForm(); await flush(); + contributor.uploads[0].reply({ ok: true, rowId: 1 }); await flush(); + assert.equal(contributor.publish.hidden, true); contributor.publish.emit('click'); await flush(); + assert.equal(contributor.requests.some(r => r.body?.get('action') === 'publish_album'), false); + assert.match(contributor.target.textContent, /编辑或管理员/); + contributor.setStatus('publish'); contributor.append.click(); await flush(); + assert.equal(contributor.input.disabled, true); assert.equal(contributor.submit.disabled, true); + contributor.select([files[1]]); contributor.submitForm(); await flush(); + assert.equal(contributor.uploads.length, 1, '贡献者只能只读查看已发布图集'); + assert.equal(contributor.fresh.disabled, false, '只读图集不会阻止用户新建草稿'); + contributor.fresh.click(); assert.equal(contributor.input.disabled, false); + + // 图片详情加载失败可重试;维护操作仅使用带鉴权信息的 POST 请求。 + let detailAttempts = 0; + const lazy = harness({ fetchHandler(url) { if (url.includes('job=album_images')) return response('detail', ++detailAttempts > 1); } }); + lazy.details.open = true; lazy.details.emit('toggle'); await flush(); + assert.equal(lazy.thumbs.dataset.loaded, '0'); + lazy.thumbs.querySelector('button').click(); await flush(); + assert.equal(detailAttempts, 2); assert.equal(lazy.thumbs.dataset.loaded, '1'); + lazy.maintenance.click(); lazy.maintenance.click(); await flush(); + const jobs = lazy.requests.filter(r => r.body?.get('action') === 'maintenance'); + assert.equal(jobs.length, 1); assert.equal(jobs[0].method, 'POST'); assert.equal(jobs[0].body.get('job'), 'rebuild'); + assert.equal(jobs[0].body.get('_'), 'secret-csrf'); + assert.equal(lazy.requests.some(r => r.url.includes('_=')), false, 'CSRF 令牌不会进入只读请求 URL'); + console.log('后台串行队列、部分重试、幂等、恢复、追加、发布、分页、懒加载重试和 POST 维护测试通过'); +})().catch(error => { console.error(error); process.exitCode = 1; }); + +// 可选真实浏览器回归: CHROMIUM_PATH=/usr/bin/chromium node tests/admin-upload.cjs --browser +// 拦截全部网络请求;无需 PHP 服务器或真实账号。 +async function browserRegression() { + const { chromium } = require('playwright'); + const path = require('node:path'); + const os = require('node:os'); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'infinitytime-admin-')); + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', 'base64'); + const paths = ['a.png', 'b.png'].map(name => { const file = path.join(directory, name); fs.writeFileSync(file, png); return file; }); + let browser; + const html = ` +
+ `; + const fields = request => { + const body = request.postData() || '', result = {}; + const pattern = /Content-Disposition: form-data; name="([^"]+)"(?:; filename="([^"]*)")?\r\n(?:Content-Type:[^\r]+\r\n)?\r\n([\s\S]*?)(?=\r\n--)/g; + for (const match of body.matchAll(pattern)) result[match[1]] = match[2] || match[3]; + return result; + }; + try { + browser = await chromium.launch({ headless: true, ...(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); + const page = await browser.newPage({ viewport: { width: 1100, height: 850 } }); + const errors = [], calls = [], rows = new Map(); + page.on('pageerror', error => errors.push(error.message)); + page.on('dialog', dialog => dialog.accept()); + let draftCount = 0, published = false, failedOnce = false, active = 0, maxActive = 0, releaseFirst; + const firstGate = new Promise(resolve => { releaseFirst = resolve; }); + await page.route('**/*', async route => { + const request = route.request(), url = new URL(request.url()); + if (url.pathname === '/') return route.fulfill({ contentType: 'text/html', body: html }); + if (url.pathname === '/admin.js') return route.fulfill({ contentType: 'text/javascript', body: source }); + if (url.pathname === '/admin.css') return route.fulfill({ contentType: 'text/css', body: fs.readFileSync(require.resolve('../usr/plugins/InfinityTime/assets/admin.css'), 'utf8') }); + if (url.pathname !== '/admin/endpoint') return route.fulfill({ status: 404, body: '' }); + const data = fields(request); calls.push({ method: request.method(), url: request.url(), data }); + const json = object => route.fulfill({ contentType: 'application/json', body: JSON.stringify(object) }); + if (data.action === 'create_draft') { draftCount++; return json({ ok: true, cid: 42, status: 'draft' }); } + if (data.action === 'upload_image') { + active++; maxActive = Math.max(maxActive, active); + if (!rows.size && data.file === 'a.png') await firstGate; + active--; + if (data.file === 'b.png' && !failedOnce) { failedOnce = true; return json({ ok: false, retryable: true, msg: '媒体处理锁正忙' }); } + const replayed = rows.has(data.item_key); rows.set(data.item_key, data.file); + return json({ ok: true, rowId: rows.size, cid: 42, replayed }); + } + if (data.action === 'publish_album') { published = true; return json({ ok: true, msg: '已发布' }); } + if (url.searchParams.get('job') === 'album_status') return json({ ok: true, cid: 42, title: '浏览器测试图集', status: published ? 'publish' : 'draft', count: rows.size }); + return route.fulfill({ contentType: 'text/html', body: '
' }); + }); + await page.goto('https://admin.test/'); + await page.locator('#pp-files-input').setInputFiles(paths); + assert.equal(await page.locator('.pp-up-item').count(), 2); + await page.locator('#pp-upload-submit').click(); + await page.waitForFunction(() => document.querySelector('.pp-up-converting')); + await page.locator('#pp-upload-form').evaluate(form => form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))); + assert.equal(draftCount, 1); + assert.equal(await page.locator('#pp-files-input').isDisabled(), true); + assert.equal(await page.locator('.pp-up-remove').first().isDisabled(), true); + assert.equal(calls.filter(call => call.data.action === 'upload_image').length, 1); + releaseFirst(); + await page.waitForFunction(() => !document.querySelector('#pp-upload-submit').disabled && document.querySelector('.pp-up-failed')); + assert.equal(rows.size, 1); assert.equal(maxActive, 1); + assert.equal(published, false); + assert.equal(await page.locator('#pp-upload-publish').isDisabled(), true); + const failedKey = calls.find(call => call.data.file === 'b.png').data.item_key; + await page.reload(); + await page.waitForFunction(() => !document.querySelector('#pp-upload-submit').disabled); + assert.equal(await page.locator('.pp-up-done').count(), 1); + assert.equal(await page.locator('.pp-up-failed').count(), 1); + await page.locator('#pp-files-input').setInputFiles(paths); + await page.locator('#pp-upload-retry').click(); + await page.waitForFunction(() => document.querySelectorAll('.pp-up-done').length === 2 && !document.querySelector('#pp-upload-submit').disabled); + assert.equal(rows.size, 2); + const imageCalls = calls.filter(call => call.data.action === 'upload_image'); + assert.deepEqual(imageCalls.map(call => call.data.file), ['a.png', 'b.png', 'b.png']); + assert.equal(imageCalls[2].data.item_key, failedKey); + await page.locator('#pp-upload-publish').click(); + await page.waitForFunction(() => document.querySelector('#pp-upload-publish').hidden); + assert.equal(published, true); + assert.equal(calls.filter(call => call.data.action === 'publish_album').length, 1); + assert.ok(calls.filter(call => call.method === 'GET').every(call => !call.url.includes('test-token'))); + assert.ok(calls.filter(call => call.url.includes('albums_html')).every(call => call.url.includes('page=2') && call.url.includes('q=sky'))); + await page.setViewportSize({ width: 390, height: 844 }); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, '移动端队列布局不超出视口'); + await page.locator('#pp-upload-new').click(); + assert.equal(await page.locator('.pp-up-item').count(), 0); + assert.equal(await page.locator('#pp-upload-cid').inputValue(), ''); + assert.deepEqual(errors, []); + console.log('后台 Chromium 回归通过:串行转换、部分重试、刷新后重选、明确发布与移动布局'); + } finally { if (browser) await browser.close(); fs.rmSync(directory, { recursive: true, force: true }); } +} +if (process.argv.includes('--browser')) browserRegression().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/admin-workflow.php b/tests/admin-workflow.php new file mode 100644 index 0000000..d472238 --- /dev/null +++ b/tests/admin-workflow.php @@ -0,0 +1,203 @@ +kind = $kind; $this->table = $table; $this->fields = $fields ?: ['*']; } + public function from($table): self { $this->table = $table; return $this; } + public function rows($rows): self { $this->rows = $rows; return $this; } + public function where($clause, ...$values): self { + foreach ($values as $value) { $clause = substr_replace($clause, $this->quoteValue($value), strpos($clause, '?'), 1); } + $this->where[] = '(' . $clause . ')'; return $this; + } + public function limit($limit): self { $this->limit = ' LIMIT ' . (int)$limit; return $this; } + public function order($column, $direction): self { $this->order .= ($this->order ? ',' : ' ORDER BY ') . '"' . $column . '" ' . $direction; return $this; } + public function quoteValue($value): string { $this->params[] = $value; return '#param:' . (count($this->params) - 1) . '#'; } + public function prepare(string $sql): string { + return preg_replace_callback('/#param:(\d+)#/', function ($match) { $value = $this->params[(int)$match[1]]; return $value === null ? 'NULL' : Db::get()->pdo->quote((string)$value); }, $sql); + } + public function __toString(): string { + $table = '"' . $this->table . '"'; + if ($this->kind === 'insert') { + $names = implode(',', array_map(static function ($name) { return '"' . $name . '"'; }, array_keys($this->rows))); + $values = implode(',', array_map(function ($value) { return $this->quoteValue($value); }, array_values($this->rows))); + $sql = "INSERT INTO $table ($names) VALUES ($values)"; + } elseif ($this->kind === 'update') { + $values = []; + foreach ($this->rows as $name => $value) { $values[] = '"' . $name . '" = ' . $this->quoteValue($value); } + $sql = "UPDATE $table SET " . implode(',', $values); + } elseif ($this->kind === 'delete') { $sql = "DELETE FROM $table"; } + else { $sql = 'SELECT ' . implode(',', $this->fields) . " FROM $table"; } + return $this->prepare($sql . ($this->where ? ' WHERE ' . implode(' AND ', $this->where) : '') . $this->order . $this->limit); + } + public function isInsert(): bool { return $this->kind === 'insert'; } + } + class Db { + public const SORT_ASC = 'ASC', WRITE = 'WRITE', READ = 'READ'; + public static self $instance; + public \PDO $pdo; + public array $reads = []; + public array $operations = []; + public string $adapter = 'Pdo_SQLite'; + public function __construct() { $this->pdo = new \PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); } + public static function get(): self { return self::$instance; } + public function getPrefix(): string { return 'admin_'; } + public function getAdapterName(): string { return $this->adapter; } + public function select(...$fields): Query { return new Query('select', '', $fields); } + public function insert($table): Query { return new Query('insert', $table); } + public function update($table): Query { return new Query('update', $table); } + public function delete($table): Query { return new Query('delete', $table); } + public function fetchRow($query) { if ($query instanceof \PDOStatement) { return $query->fetch(\PDO::FETCH_ASSOC); } $this->reads[] = (string)$query; return $this->pdo->query((string)$query)->fetch(\PDO::FETCH_ASSOC); } + public function fetchAll($query): array { if ($query instanceof \PDOStatement) { return $query->fetchAll(\PDO::FETCH_ASSOC); } $this->reads[] = (string)$query; return $this->pdo->query((string)$query)->fetchAll(\PDO::FETCH_ASSOC); } + public function query($query, $op = self::READ) { + $this->operations[] = ['sql' => (string)$query, 'op' => $op, 'object' => is_object($query)]; + if (preg_match('/^SELECT /i', (string)$query)) { $this->reads[] = (string)$query; return $this->pdo->query((string)$query); } + $affected = $this->pdo->exec((string)$query); + return $query instanceof Query && $query->isInsert() ? (int)$this->pdo->lastInsertId() : $affected; + } + } +} +namespace TypechoPlugin\InfinityTime { + class Plugin { + public const DEFAULT_QUALITY = 76, DEFAULT_THUMB_MAX = 1280, DEFAULT_MAX_WIDTH = 2560, + DEFAULT_FULL_QUALITY = 82, DEFAULT_PANO_WIDTH = 0, DEFAULT_PANO_QUALITY = 92, DEFAULT_KEEP_ORIGINAL = '1'; + public static function opt($name, $default) { return $default; } + } +} +namespace TypechoPlugin\InfinityTime\Lib { + function is_uploaded_file($path): bool { return is_file($path); } + class ImageRepository { + public static bool $locked = false; + public static bool $busy = false; + public static bool $failSync = false; + public static int $ingested = 0; + public static int $synced = 0; + public static ?string $lastError = null; + public static array $lastOptions = []; + public static function lockMedia(): void { if (self::$busy) { throw new \RuntimeException('busy'); } self::$locked = true; } + public static function ensureSchema(): void {} + public static function table(): string { return 'admin_infinitytime_images'; } + public static function ingest($file, $options): ?array { + if (!self::$locked) { throw new \RuntimeException('入库前处理未持锁'); } + self::$ingested++; self::$lastOptions = $options; + if ($file['name'] === 'bad.heic') { self::$lastError = 'decoder failed'; return null; } + return ['full' => '/full/' . self::$ingested . '.webp', 'exif' => ['make' => 'Camera', 'model' => 'Test']]; + } + public static function insertRow($cid, $meta, $sort): int { + if (!self::$locked) { throw new \RuntimeException('图片写入未持锁'); } + return \Typecho\Db::get()->query(\Typecho\Db::get()->insert(self::table())->rows([ + 'cid' => $cid, 'sort' => $sort, 'full' => $meta['full'], 'exif' => json_encode($meta['exif']), + 'upload_key' => $meta['upload_key'], 'upload_fingerprint' => $meta['upload_fingerprint'], + ])); + } + public static function syncPostFields($cid): void { + if (!self::$locked) { throw new \RuntimeException('字段同步未持锁'); } + self::$synced++; + if (self::$failSync) { throw new \RuntimeException('sync interrupted'); } + } + } +} +namespace { + use TypechoPlugin\InfinityTime\Lib\AdminSecurity as Security; + use TypechoPlugin\InfinityTime\Lib\AdminRepository as Repository; + use TypechoPlugin\InfinityTime\Lib\AdminWorkflow as Workflow; + use TypechoPlugin\InfinityTime\Lib\ImageRepository as Images; + require __DIR__ . '/../usr/plugins/InfinityTime/Lib/AdminSecurity.php'; + require __DIR__ . '/../usr/plugins/InfinityTime/Lib/AdminRepository.php'; + require __DIR__ . '/../usr/plugins/InfinityTime/Lib/AdminWorkflow.php'; + $checks = 0; + function check(bool $condition, string $label): void { global $checks; if (!$condition) { throw new RuntimeException($label); } $checks++; } + function rejects(callable $fn, string $label): void { $rejected = false; try { $fn(); } catch (Throwable $e) { $rejected = true; } check($rejected, $label); } + check(Security::validMutation('POST', 'secret', 'secret'), '接受有效 POST 令牌'); + foreach ([['GET', 'secret', 'secret'], ['POST', '', ''], ['POST', 'secret', null], ['POST', 'secret', ['secret']], ['POST', 'secret', 'wrong']] as $case) { + check(!Security::validMutation(...$case), '拒绝 GET、不可用、缺失、数组或错误令牌'); + } + check(Security::validKey(str_repeat('a', 32)) && !Security::validKey('../bad') && !Security::validKey(str_repeat('a', 65)), '上传标识格式及长度有界'); + $db = \Typecho\Db::$instance = new \Typecho\Db(); + $db->query('CREATE TABLE admin_contents (cid INTEGER PRIMARY KEY, title TEXT, slug TEXT, created INTEGER, modified INTEGER, text TEXT, authorId INTEGER, type TEXT, status TEXT, allowComment TEXT, allowPing TEXT, allowFeed TEXT, template TEXT, password TEXT)'); + $db->query('CREATE TABLE admin_fields (cid INTEGER, name TEXT, type TEXT, str_value TEXT, PRIMARY KEY(cid,name))'); + $db->query('CREATE TABLE admin_infinitytime_images (id INTEGER PRIMARY KEY, cid INTEGER, sort INTEGER, full TEXT, exif TEXT, upload_key TEXT, upload_fingerprint TEXT, UNIQUE(cid,upload_key))'); + $draft = Workflow::draft(7, ['title' => 'Draft A'], str_repeat('a', 32)); + $cid = (int)$draft['cid']; + check($draft['status'] === 'draft', '新建图集先保存草稿'); + $same = Workflow::draft(7, ['title' => 'Changed title after lost response'], str_repeat('a', 32)); + check((int)$same['cid'] === $cid && $same['replayed'], '重试创建复用已保存图集'); + $other = Workflow::draft(8, ['title' => 'Other user'], str_repeat('a', 32)); + check((int)$other['cid'] !== $cid, '操作标识按创建者隔离'); + check(Repository::album($cid, 8, false) === null, '贡献者不能读取其他作者图集'); + check(Repository::album($cid, 8, true) !== null, '管理员可编辑真实图集'); + $db->query("INSERT INTO admin_contents (cid,title,created,authorId,type,status) VALUES (500,'Ordinary post',1,7,'post','publish'),(501,'Fake page',1,7,'page','publish')"); + Repository::setField(501, Repository::MARKER, '1'); + check(Repository::album(500, 7, true) === null && Repository::album(501, 7, true) === null && Repository::album(999, 7, true) === null, '管理员仍拒绝普通文章、页面和不存在标识'); + rejects(static function () use ($cid) { Workflow::publish($cid, 7, false, true); }, '空草稿不可发布'); + $tmp = tempnam(sys_get_temp_dir(), 'infinity-admin-'); file_put_contents($tmp, 'test image bytes'); + $file = ['name' => 'good.jpg', 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => 16]; + try { + $row = Workflow::upload($cid, 7, false, $file, ['title' => 'A'], str_repeat('1', 32)); + check($row['rowId'] > 0 && !$row['replayed'], '单张上传持久化图片行'); + check(Images::$lastOptions['keep_original'] === true && Images::$lastOptions['pano_quality'] === 92 && Images::$lastOptions['pano_width'] === 0, '保留原图与全景设置'); + check(Repository::field($cid, 'device') === 'Camera Test', '保留 EXIF 设备回填'); + $replay = Workflow::upload($cid, 7, false, $file, ['title' => 'A'], str_repeat('1', 32)); + check($replay['rowId'] === $row['rowId'] && $replay['replayed'] && Images::$ingested === 1, '重复重试不重新编码或插入'); + rejects(static function () use ($cid, $file) { Workflow::upload($cid, 7, false, $file, ['title' => 'Changed'], str_repeat('1', 32)); }, '同一标识对应不同元数据时拒绝'); + file_put_contents($tmp, 'different image bytes'); + rejects(static function () use ($cid, $file) { Workflow::upload($cid, 7, false, $file, ['title' => 'A'], str_repeat('1', 32)); }, '同一标识对应不同文件内容时拒绝'); + file_put_contents($tmp, 'test image bytes'); + rejects(static function () use ($cid, $file) { Workflow::upload($cid, 8, false, $file, [], str_repeat('2', 32)); }, '其他作者不可上传'); + $bad = $file; $bad['name'] = 'bad.heic'; + rejects(static function () use ($cid, $bad) { Workflow::upload($cid, 7, false, $bad, [], str_repeat('2', 32)); }, '单图转换失败不影响其他图片'); + check((Repository::counts([$cid])[$cid] ?? 0) === 1, '单项失败后成功图片保留'); + Images::$failSync = true; + rejects(static function () use ($cid, $file) { Workflow::upload($cid, 7, false, $file, [], str_repeat('3', 32)); }, '入库后字段同步失败可见'); + $before = Images::$ingested; + Images::$failSync = false; + $repair = Workflow::upload($cid, 7, false, $file, [], str_repeat('3', 32)); + check($repair['replayed'] && Images::$ingested === $before && (Repository::counts([$cid])[$cid] ?? 0) === 2, '响应丢失或失败后重放修复字段且不重复入库'); + Images::$busy = true; + rejects(static function () use ($cid, $file) { Workflow::upload($cid, 7, false, $file, [], str_repeat('4', 32)); }, '锁忙时在处理图片前拒绝'); + check(Images::$ingested === $before, '锁忙请求不处理文件'); + Images::$busy = false; + rejects(static function () use ($cid) { Workflow::publish($cid, 7, false); }, '贡献者不能绕过发布审核'); + Workflow::publish($cid, 7, false, true); + check(Repository::album($cid, 7, false)['status'] === 'publish', '明确发布操作改变状态'); + rejects(static function () use ($cid, $file) { Workflow::upload($cid, 7, false, $file, [], str_repeat('4', 32)); }, '贡献者不能追加到已发布图集'); + Workflow::upload($cid, 7, false, $file, [], str_repeat('4', 32), true); + check(Repository::album($cid, 7, false)['status'] === 'publish' && (Repository::counts([$cid])[$cid] ?? 0) === 3, '追加保留原图集身份及发布状态'); + $legacy = Workflow::draft(7, ['title' => 'Legacy album'], str_repeat('l', 32)); + Repository::setField((int)$legacy['cid'], 'img', "/old-a.webp\n/old-b.webp"); + $before = Images::$ingested; + rejects(static function () use ($legacy, $file) { Workflow::upload((int)$legacy['cid'], 7, false, $file, [], str_repeat('5', 32)); }, '历史图集追加在文件处理前拒绝'); + check(Images::$ingested === $before && Repository::field((int)$legacy['cid'], 'img') === "/old-a.webp\n/old-b.webp", '历史图集图片保持完整'); + foreach ($db->operations as $operation) { + if (in_array($operation['sql'], ['BEGIN', 'COMMIT', 'ROLLBACK'], true) || strpos($operation['sql'], 'SELECT ') === 0) { + check($operation['op'] === \Typecho\Db::WRITE && !$operation['object'], '事务与写流程读取使用预处理后的 SQL 主库连接'); + } + } + for ($i = 0; $i < 47; $i++) { Workflow::draft(7, ['title' => 'Search item ' . $i], str_pad((string)$i, 32, 'z', STR_PAD_LEFT)); } + Workflow::draft(7, ['title' => 'Literal %_! quote\''], str_repeat('q', 32)); + $db->reads = []; + $page = Repository::page(7, false, 2, 'Search item'); + check(count($page['items']) === 20 && $page['total'] === 47 && $page['pages'] === 3 && $page['page'] === 2, '支持搜索且分页有界'); + $queries = implode("\n", $db->reads); + check(strpos($queries, 'LIMIT 20 OFFSET 20') !== false && strpos($queries, 'COUNT(*) AS image_count') !== false, 'SQL 摘要查询有界并使用聚合计数'); + check(strpos($queries, 'exif') === false && strpos($queries, 'SELECT *') === false, '列表不加载图片完整数据'); + check(Repository::page(7, false, 1, "%_! quote'")['total'] === 1, '搜索通配符及引号安全转义'); + check(Repository::page(8, false)['total'] === 1, '列表限制为贡献者自己的图集'); + $db->adapter = 'Pdo_Pgsql'; + $db->reads = []; + check(Repository::album($cid, 7, false) !== null, 'PostgreSQL 权限查询 SQL 可执行'); + check(strpos($db->reads[0], 'c."authorId"') !== false && strpos($db->reads[0], 'c.authorId') === false, 'PostgreSQL 驼峰列名正确引用'); + $db->reads = []; + Repository::draftForOperation(7, str_repeat('a', 32)); + check(strpos($db->reads[0], 'c."authorId"') !== false, 'PostgreSQL 操作查询正确引用作者列名'); + echo "后台流程:$checks 项检查通过\n"; + } finally { unlink($tmp); } +} diff --git a/tests/gallery-loading-browser.cjs b/tests/gallery-loading-browser.cjs new file mode 100644 index 0000000..8a9cd4c --- /dev/null +++ b/tests/gallery-loading-browser.cjs @@ -0,0 +1,171 @@ +// 使用本地 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 theme = path.resolve(__dirname, '../usr/themes/InfinityTime'); +const origin = 'https://gallery.test'; +const url = name => `${origin}/${name}.webp`; +const attr = value => JSON.stringify(value).replace(/&/g, '&').replace(/'/g, '''); +const variants = name => ({ w: 3200, webp: [url(name), url(name + '-1600')], avif: [`${origin}/${name}.avif`, `${origin}/${name}-1600.avif`] }); +function card(id, cid, images, ids, options = {}) { + const previews = images.map((_, i) => `${origin}/${id}-thumb-${i}.webp`); + return `
Album ${id}

Album ${id}

`; +} +function pageHtml(cards, next = '/page2') { + return `
${cards}
+ ${['init', 'jquery.min', 'jquery.poptrox.min', 'browser.min', 'breakpoints.min', 'lightbox', 'main'].map(n => ``).join('')}`; +} +const initial = card('a', 1, [url('a'), url('b'), url('pano'), url('slow')], ['p-1', 'p-2', 'p-3', 'p-4'], { + variants: [variants('a'), variants('b'), variants('pano')], panos: [0, 0, 1, 0] +}) + card('same-album', 1, [url('other-month')], ['p-5']) + card('broken', 2, [url('broken')], ['p-6']); +const pixel = ''; +(async () => { + const browser = await chromium.launch({ headless: true, ...(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }); + try { + for (const viewport of [{ width: 1280, height: 900 }, { width: 390, height: 844 }]) { + const context = await browser.newContext({ viewport }); + await context.addInitScript(() => { + window.__samples = []; + window.__viewerCalls = 0; + const drawImage = CanvasRenderingContext2D.prototype.drawImage; + CanvasRenderingContext2D.prototype.drawImage = function(image, ...args) { + window.__samples.push({ connected: image.isConnected, source: image.currentSrc || image.src }); + return drawImage.call(this, image, ...args); + }; + const getContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function(type, ...args) { + if (/webgl/.test(type)) return null; + return getContext.call(this, type, ...args); + }; + window.pannellum = { viewer() { window.__viewerCalls++; throw new Error('挂载查看器前应先检测 WebGL'); } }; + window.__galleryEvents = []; + ['open', 'change', 'close', 'error'].forEach(type => document.addEventListener('infinitygallery:' + type, event => { + const d = event.detail; + window.__galleryEvents.push({ type, albumId: d && d.albumId, photoId: d && d.photoId }); + })); + }); + const page = await context.newPage(); + const errors = [], requests = []; + page.on('pageerror', e => errors.push(e.message)); + let broken = true, page2Fails = true; + const delayed = new Map(); + await page.route('**/*', async route => { + const pathname = new URL(route.request().url()).pathname; + requests.push(pathname); + if (pathname === '/') return route.fulfill({ contentType: 'text/html', body: pageHtml(initial) }); + if (pathname.startsWith('/assets/')) { + const filename = path.join(theme, pathname.slice(1)); + return fs.existsSync(filename) ? route.fulfill({ path: filename }) : route.fulfill({ status: 404, body: '' }); + } + if (pathname === '/page2') return route.fulfill(page2Fails + ? { status: 503, contentType: 'text/html', body: '

稍后重试

' } + : { contentType: 'text/html', body: pageHtml(card('appended', 3, [url('appended')], ['p-7']), '/page3') }); + if (pathname === '/page3') { + await new Promise(resolve => delayed.set('page3', resolve)); + return route.fulfill({ contentType: 'text/html', body: pageHtml(card('stale', 4, [url('stale')], ['p-8']), '') }).catch(() => {}); + } + if (pathname === '/page4') return route.fulfill({ contentType: 'text/html', body: pageHtml(card('fresh', 5, [url('fresh')], ['p-9']), '') }); + if (pathname === '/empty') return route.fulfill({ contentType: 'text/html', body: pageHtml('', '') }); + if (pathname === '/broken.webp' && broken) return route.fulfill({ status: 503, body: '' }); + if (pathname === '/slow.webp') { + await new Promise(resolve => delayed.set('slow', resolve)); + return route.fulfill({ status: 503, body: '' }).catch(() => {}); + } + return route.fulfill({ contentType: 'image/svg+xml', body: pixel }); + }); + await page.goto(origin); + await page.waitForFunction(() => !!window.InfinityGallery && !!window.InfinityWaterfall); + async function open(albumId, photoId) { + assert.equal(await page.evaluate(target => InfinityGallery.open(target), { albumId, photoId }), true); + } + async function ready(name) { + await page.waitForFunction(source => { + const p = document.querySelector('.poptrox-popup'), img = p && p.querySelector('.pic img'); + return img && img.getAttribute('src') === source && img.complete && img.naturalWidth && !p.classList.contains('loading') && img.style.opacity === '1'; + }, url(name)); + await page.waitForTimeout(500); + } + async function close() { + await page.evaluate(() => InfinityGallery.close()); + await page.waitForFunction(() => getComputedStyle(document.querySelector('.poptrox-overlay')).display === 'none'); + } + await open('1', 'p-1'); await ready('a'); + await page.waitForFunction(() => document.querySelector('.palette-hex')); + assert.equal(await page.locator('.palette-hex').first().innerText(), '#556677', '代表色保持原有精确色值'); + assert.ok((await page.evaluate(() => window.__samples)).every(sample => sample.connected), '主题色采样复用已显示、已解码的位图'); + assert.equal(requests.filter(p => p === '/b.webp').length, 0, '相邻图预加载不下载全尺寸 WebP'); + assert.ok(requests.includes('/b-1600.avif'), '相邻图预加载与响应式主图候选一致'); + await page.keyboard.press('ArrowRight'); await ready('b'); + assert.ok(await page.locator('.pic img').evaluate(img => img.currentSrc.endsWith('/b-1600.avif'))); + assert.ok(requests.includes('/pano.webp'), '全景预加载原始分辨率'); + assert.equal(requests.filter(p => /pano.*\.avif|pano-1600/.test(p)).length, 0); + await page.keyboard.press('ArrowRight'); await ready('pano'); + await page.waitForFunction(() => !!document.querySelector('.pp-pano-fallback')); + assert.equal(await page.locator('.pp-pano-viewer').count(), 0); + assert.equal(await page.locator('.poptrox-popup').evaluate(p => p.classList.contains('pp-pano-mode')), false); + assert.equal(await page.locator('.pic img').evaluate(img => getComputedStyle(img).visibility), 'visible'); + assert.equal(await page.evaluate(() => window.__viewerCalls), 0, '不支持 WebGL 时不创建全景查看器'); + await open('1', 'p-5'); await ready('other-month'); + assert.equal(await page.evaluate(() => InfinityGallery.getCurrent().opener.id), 'same-album', '稳定 ID 能区分同一相册的多张月份卡片'); + await open('2', 'p-6'); + await page.waitForSelector('.pp-image-error button', { state: 'visible' }); + assert.equal(await page.locator('.poptrox-popup').evaluate(p => p.classList.contains('loading')), false, '加载失败后释放 Poptrox 导航锁'); + broken = false; + await page.locator('.pp-image-error button').click(); await ready('broken'); + assert.equal(await page.locator('.pp-image-error').count(), 0); + await page.keyboard.press('ArrowRight'); await ready('a'); + await page.evaluate(() => InfinityGallery.close()); + await open('1', 'p-5'); await ready('other-month'); + assert.equal(await page.locator('.pp-pano-fallback').count(), 0, '全景降级提示不会残留到下一张普通照片'); + await open('1', 'p-4'); + await page.waitForFunction(() => document.querySelector('.pic img').getAttribute('src').endsWith('/slow.webp')); + await close(); + if (delayed.has('slow')) delayed.get('slow')(); + await page.waitForTimeout(500); + assert.equal(await page.locator('.pp-image-error').count(), 0, '迟到的图片错误不会在灯箱关闭后重新显示'); + assert.equal(await page.locator('.pic img').count(), 0); + await open('1', 'p-1'); await ready('a'); await close(); + assert.equal(await page.evaluate(() => { + window.__superseded = InfinityGallery.open({ albumId: '1', photoId: 'p-1' }); + return InfinityGallery.open({ albumId: '1', photoId: 'p-5' }); + }), true); + await ready('other-month'); + assert.equal(await page.evaluate(() => window.__superseded), false, '被新请求取代的打开操作不会恢复旧淡入'); + await close(); + assert.ok((await page.evaluate(() => window.__galleryEvents)).some(event => event.type === 'change' && event.photoId === 'p-5')); + // 显示失败状态;失败后暂停自动请求,等待用户主动重试。 + assert.equal(await page.evaluate(() => InfinityWaterfall.loadMore()), false); + assert.equal(await page.locator('#load-more').getAttribute('aria-busy'), 'false'); + assert.match(await page.locator('.pp-load-status').innerText(), /加载失败/); + const beforeAuto = requests.filter(p => p === '/page2').length; + await page.evaluate(() => window.dispatchEvent(new Event('scroll'))); + await page.waitForTimeout(100); + assert.equal(requests.filter(p => p === '/page2').length, beforeAuto); + page2Fails = false; + await page.locator('.pp-load-retry').click(); + await page.waitForSelector('#appended[role="button"]'); + assert.equal(await page.locator('#month-2026-10').count(), 1, '追加月份标题不重复 DOM ID'); + assert.equal(await page.locator('#gallery-pagination a[rel="next"]').getAttribute('href'), '/page3'); + // 取消旧分页并发起新请求,旧响应迟到后不能追加内容。 + await page.evaluate(() => { window.__pendingPage = InfinityWaterfall.loadMore(); }); + while (!delayed.has('page3')) await new Promise(resolve => setTimeout(resolve, 20)); + await page.evaluate(() => InfinityWaterfall.reset({ nextUrl: '/page4' })); + delayed.get('page3')(); + assert.equal(await page.evaluate(() => InfinityWaterfall.loadMore()), true); + assert.equal(await page.locator('#stale').count(), 0); + assert.equal(await page.locator('#fresh').count(), 1); + assert.equal(await page.locator('.pp-load-retry').isVisible(), false); + 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); + assert.deepEqual(errors, []); + await context.close(); + console.log(`图册加载、降级和重试回归测试通过:${viewport.width}px`); + } + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/gallery-loading.cjs b/tests/gallery-loading.cjs new file mode 100644 index 0000000..d57cd87 --- /dev/null +++ b/tests/gallery-loading.cjs @@ -0,0 +1,172 @@ +// 无依赖测试直接执行生产辅助函数;真实浏览器中的响应式候选选择 +// 由 gallery-loading-browser.cjs 覆盖。 +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); +const read = name => fs.readFileSync(path.resolve(__dirname, '../usr/themes/InfinityTime/assets/js/' + name + '.js'), 'utf8'); +function helper(source, name, indent) { + const spaces = ' '.repeat(indent); + const start = source.indexOf(spaces + 'function ' + name + '('); + assert.ok(start >= 0, `${name} 已定义`); + const end = source.indexOf('\n' + spaces + '}', start); + return source.slice(start, end + indent + 2); +} +class Element { + constructor(tag = 'div', classes = '') { + this.tagName = tag.toUpperCase(); this.children = []; this.attrs = {}; this.style = {}; + this.events = {}; this.isConnected = true; this.classes = new Set(classes.split(' ').filter(Boolean)); + this.classList = { + contains: c => this.classes.has(c), add: c => this.classes.add(c), remove: c => this.classes.delete(c), + toggle: (c, value) => value ? this.classes.add(c) : this.classes.delete(c) + }; + } + set className(value) { this.classes = new Set(value.split(' ')); } + get className() { return [...this.classes].join(' '); } + get parentNode() { return this.parent; } + appendChild(node) { node.remove(); node.parent = this; this.children.push(node); return node; } + insertBefore(node, before) { node.remove(); node.parent = this; this.children.splice(this.children.indexOf(before), 0, node); } + remove() { if (this.parent) { this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null; } } + setAttribute(key, value) { this.attrs[key] = String(value); } + getAttribute(key) { return this.attrs[key] ?? null; } + hasAttribute(key) { return key in this.attrs; } + removeAttribute(key) { delete this.attrs[key]; } + contains(node) { return this === node || this.children.some(child => child.contains(node)); } + matches(selector) { return selector.split(',').some(sel => { + sel = sel.trim(); + if (sel === 'picture.pp-picture') return this.tagName === 'PICTURE' && this.classes.has('pp-picture'); + if (sel === 'source[type="image/avif"]') return this.tagName === 'SOURCE' && this.type === 'image/avif'; + if (sel === 'a[rel="next"]') return this.tagName === 'A' && this.attrs.rel === 'next'; + return sel.startsWith('.') && this.classes.has(sel.slice(1)); + }); } + querySelectorAll(selector) { + if (selector === ':scope > .thumb') return this.children.filter(n => n.classes.has('thumb')); + return this.children.flatMap(n => [...(n.matches(selector) ? [n] : []), ...n.querySelectorAll(selector)]); + } + querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } + addEventListener(type, fn) { (this.events[type] ||= []).push(fn); } +} +(async () => { + // 预加载复用主图的 /sizes 逻辑,在 src 前配置响应式属性; + // 失败后允许重试,并限制常驻对象数量。 + { + const source = read('main'); + const images = []; + const context = { + document: { createElement: tag => new Element(tag) }, window: { innerWidth: 390, devicePixelRatio: 2 }, + variantMap: { '/b.webp': { w: 3200, webp: ['/b.webp', '/b-1600.webp'], avif: ['/b.avif', '/b-1600.avif'] } }, + isPanoUrl: url => url === '/pano.webp', normUrl: url => url, + __preloaded: new Map(), + Image: class extends Element { + constructor() { super('img'); images.push(this); } + set src(value) { this.assigned = value; this.sourceWhenAssigned = this.parentNode.tagName; this.srcsetWhenAssigned = this.getAttribute('srcset'); } + } + }; + vm.createContext(context); + vm.runInContext(['removeResponsivePicture', 'applyResponsive', 'preloadUrl'].map(name => helper(source, name, 4)).join('\n'), context); + context.preloadUrl('/b.webp'); + assert.equal(images.length, 1); + assert.equal(images[0].sourceWhenAssigned, 'PICTURE'); + assert.equal(images[0].srcsetWhenAssigned, '/b.webp 3200w, /b-1600.webp 1600w'); + assert.equal(images[0].parentNode.querySelector('source[type="image/avif"]').getAttribute('srcset'), '/b.avif 3200w, /b-1600.avif 1600w'); + assert.equal(images[0].fetchPriority, 'low'); + context.preloadUrl('/b.webp'); assert.equal(images.length, 1); + images[0].onerror(); context.preloadUrl('/b.webp'); assert.equal(images.length, 2, '相邻图预加载失败后允许重试'); + context.window.innerWidth = 1280; context.preloadUrl('/b.webp'); assert.equal(images.length, 3, '视口变化后重新选择候选图'); + context.preloadUrl('/pano.webp'); + assert.equal(images.at(-1).sourceWhenAssigned, 'DIV'); + assert.equal(images.at(-1).assigned, '/pano.webp'); + for (let i = 0; i < 100; i++) context.preloadUrl('/' + i + '.webp'); + assert.equal(context.__preloaded.size, 64); + } + // 主题色采样复用已解码主图,并保留原有三种代表色。 + // 跨域限制导致采样失败时,只能请求独立缩略图。 + { + const displayed = { complete: true, naturalWidth: 3, naturalHeight: 1, currentSrc: '/small.avif' }; + let drawn, taint = false, result, requests = []; + const source = read('lightbox'); + const context = { + __photoCache: {}, __photoPending: {}, + document: { createElement: () => ({ getContext: () => ({ + drawImage(image) { drawn = image; }, + getImageData() { if (taint && drawn === displayed) throw new Error('tainted'); return { data: [0,255,0,255, 255,0,255,255, 0,0,255,255] }; } + }) }) }, + Image: class { constructor() { this.naturalWidth = 3; this.naturalHeight = 1; requests.push(this); } set src(src) { this.requested = src; } } + }; + vm.createContext(context); + vm.runInContext(['rgbToHex', 'analyzePhoto'].map(name => helper(source, name, 8)).join('\n'), context); + context.analyzePhoto('/full.webp', displayed, '/preview.webp', value => result = value); + assert.equal(drawn, displayed); assert.equal(requests.length, 0); + assert.deepEqual(Array.from(result.colors), ['#00FF00', '#FF00FF', '#0000FF']); + context.analyzePhoto('/full.webp', displayed, '/preview.webp', value => result = value); + assert.equal(requests.length, 0, '重复采样使用缓存'); + taint = true; + let callbackCount = 0; + context.analyzePhoto('/cors.webp', displayed, '/preview.webp', () => callbackCount++); + context.analyzePhoto('/cors.webp', displayed, '/preview.webp', () => callbackCount++); + assert.equal(requests.length, 1, '同一缩略图的并发采样请求合并'); + assert.equal(requests[0].requested, '/preview.webp'); + assert.equal(requests[0].crossOrigin, 'anonymous'); + requests[0].onload(); assert.equal(callbackCount, 2); + context.analyzePhoto('/no-preview.webp', displayed, '/no-preview.webp', value => result = value); + assert.equal(requests.length, 1, '没有独立缩略图时不再解码第二份全尺寸图片'); + assert.deepEqual(Array.from(result.colors), []); + } + // 通过可控网络响应验证真实分页加载器的失败、去重、取消、失效响应、 + // 月份标题和末尾空批次处理。 + { + const source = read('init'); + const wf = new Element(), lm = new Element(), nav = new Element(); + const next = nav.appendChild(new Element('a')); next.setAttribute('rel', 'next'); + lm.setAttribute('data-next-url', '/page2'); + const nodes = { waterfall: wf, 'load-more': lm, 'gallery-pagination': nav, 'month-2026-10': new Element('h2') }; + const pending = [], events = {}, docs = {}; + let rebinds = 0; + const context = { + document: { getElementById: id => nodes[id] || null, createElement: tag => new Element(tag), documentElement: { offsetHeight: 3000 }, dispatchEvent() {} }, + window: { innerHeight: 800, scrollY: 0, addEventListener: (name, fn) => events[name] = fn, __rebindPoptrox: () => rebinds++ }, + location: { href: 'https://gallery.test/' }, URL, Promise, AbortController, + setTimeout, clearTimeout, applyDims() {}, initImageFallback() {}, + fetch: (url, options) => new Promise(resolve => pending.push({ url, options, resolve })), + DOMParser: class { parseFromString(body) { return docs[body]; } }, + CustomEvent: class { constructor(name, options) { this.type = name; this.detail = options.detail; } } + }; + function response(key, nextUrl, cards = 1) { + const grid = new Element(), pager = new Element(); pager.setAttribute('data-next-url', nextUrl); + const heading = grid.appendChild(new Element('h2', 'gallery-month-label')); heading.id = 'month-2026-10'; + for (let i = 0; i < cards; i++) grid.appendChild(new Element('article', 'thumb')); + docs[key] = { getElementById: id => ({ waterfall: grid, 'load-more': pager }[id]) }; + return { ok: true, text: () => Promise.resolve(key) }; + } + vm.createContext(context); vm.runInContext(helper(source, 'initWaterfall', 2), context); context.initWaterfall(); + const api = context.window.InfinityWaterfall; + const failed = api.loadMore(); + assert.equal(await api.loadMore(), false); assert.equal(pending.length, 1, '并发请求不会重复加载同一页'); + assert.equal(lm.getAttribute('aria-busy'), 'true'); + pending.shift().resolve({ ok: false, status: 503 }); + assert.equal(await failed, false); + assert.equal(lm.getAttribute('aria-busy'), 'false'); + assert.match(lm.children[0].textContent, /加载失败/); + context.window.scrollY = 4000; events.scroll(); assert.equal(pending.length, 0, '自动加载失败后等待主动重试'); + const retried = api.loadMore(); pending.shift().resolve(response('page2', '/page3')); + assert.equal(await retried, true); assert.equal(wf.children.length, 2); assert.equal(rebinds, 1); + assert.equal(wf.children[0].getAttribute('id'), null, '追加标题的重复 ID 已移除'); + assert.equal(next.getAttribute('href'), '/page3'); + const stale = api.loadMore(); const staleRequest = pending.shift(); + api.reset({ nextUrl: '/page4' }); assert.equal(staleRequest.options.signal.aborted, true); + staleRequest.resolve(response('stale', '/page5', 3)); + assert.equal(await stale, false); assert.equal(wf.children.length, 2, '重置后旧响应不得追加卡片'); + const fresh = api.loadMore(); pending.shift().resolve(response('fresh', '', 1)); + assert.equal(await fresh, true); assert.equal(wf.children.length, 4); + assert.equal(lm.children[1].hidden, true); + api.reset({ nextUrl: '/empty' }); + const empty = api.loadMore(); pending.shift().resolve(response('empty', '', 0)); + assert.equal(await empty, true); assert.equal(lm.children[1].hidden, true); + api.reset({ nextUrl: '/invalid' }); + docs.invalid = { getElementById: () => null }; + const invalid = api.loadMore(); pending.shift().resolve({ ok: true, text: () => Promise.resolve('invalid') }); + assert.equal(await invalid, false); assert.match(lm.children[0].textContent, /加载失败/); + api.cancel(); + } + console.log('图册预加载、主题色和分页生命周期回归测试通过'); +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/migration.php b/tests/migration.php index 934d202..8f46aa4 100644 --- a/tests/migration.php +++ b/tests/migration.php @@ -11,8 +11,11 @@ public function from($table): self { return $this; } public function where(...$args): self { return $this; } public function limit($limit): self { return $this; } public function rows($values): self { $this->values = $values; return $this; } + public function __toString(): string { return 'SELECT option_fixture'; } + public function prepare(string $sql): string { return $sql; } } class Db { + public const WRITE = 2; public static self $instance; public array $columns = []; public array $alters = []; @@ -40,7 +43,10 @@ public function fetchAll($query): array { if (strpos($query, 'PRAGMA index_info') === 0) { return [['name' => 'cid'], ['name' => 'upload_key']]; } throw new \RuntimeException('Unexpected index probe'); } - public function query($query): void { + public function query($query, $op = null) { + if (is_string($query) && $op !== self::WRITE) { throw new \RuntimeException('Schema SQL must use writer'); } + if ($query === 'SELECT option_fixture') { return null; } + if (is_string($query) && preg_match('/^(SHOW INDEX|SELECT indexdef|PRAGMA)/', $query)) { return $query; } if (!is_string($query)) { return; } if (strpos($query, 'CREATE UNIQUE INDEX') === 0) { if ($this->failIndex) { throw new \RuntimeException('index permission denied'); } diff --git a/tests/repository.php b/tests/repository.php index 92800f3..be72956 100644 --- a/tests/repository.php +++ b/tests/repository.php @@ -12,9 +12,12 @@ public function from($table): self { $this->table = $table; return $this; } public function rows($values): self { $this->values = $values; return $this; } public function where($clause, ...$values): self { $this->conditions[$clause] = $values; return $this; } public function order(...$args): self { return $this; } + public function __toString(): string { return 'fixture:' . base64_encode(serialize($this)); } + public function prepare(string $sql): string { return $sql; } } class Db { public const SORT_ASC = 'ASC'; + public const WRITE = 2; public static self $instance; public array $images = []; public array $fields = []; @@ -26,7 +29,11 @@ public function insert($table): Query { return (new Query('insert'))->from($tabl public function delete($table): Query { return (new Query('delete'))->from($table); } public function update($table): Query { return (new Query('update'))->from($table); } public function fetchAll($query): array { return $query->table === 'test_fields' ? $this->legacyFields : $this->images; } - public function query($query): void { + 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))); + } 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); } diff --git a/tests/schema-sqlite.php b/tests/schema-sqlite.php index 2da854f..26cfea4 100644 --- a/tests/schema-sqlite.php +++ b/tests/schema-sqlite.php @@ -1,14 +1,20 @@ pdo = new \PDO('sqlite::memory:', null, null, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); } @@ -19,8 +25,11 @@ public function select(...$args): Query { return new Query(); } public function insert($table): Query { return new Query(); } public function update($table): Query { return new Query(); } public function fetchRow($query) { return false; } - public function fetchAll($query): array { return $this->pdo->query($query)->fetchAll(\PDO::FETCH_ASSOC); } - public function query($query) { return is_string($query) ? $this->pdo->exec($query) : null; } + public function fetchAll($query): array { return ($query instanceof \PDOStatement ? $query : $this->pdo->query($query))->fetchAll(\PDO::FETCH_ASSOC); } + public function query($query, $op = null) { + if (!is_string($query)) { return null; } + return preg_match('/^(SELECT|PRAGMA)/', $query) ? $this->pdo->query($query) : $this->pdo->exec($query); + } } } namespace Utils { @@ -42,15 +51,15 @@ interface Typecho_Plugin_Interface {} $rejected = false; try { $db->query("INSERT INTO fixture_infinitytime_images (id,cid,upload_key) VALUES (4,10,'retry-key')"); } catch (\PDOException $e) { $rejected = true; } - if (!$rejected) { throw new \RuntimeException('Retry key must be unique within the album'); } + if (!$rejected) { throw new \RuntimeException('同一相册内上传重试键必须唯一'); } $db->query("INSERT INTO fixture_infinitytime_images (id,cid,upload_key) VALUES (4,11,'retry-key')"); - // Retry migration after an interrupted version-option update, with index already present. + // 模拟索引已建好、版本选项尚未更新时中断,再次升级必须安全完成。 \Utils\Helper::$options->infinitytimeSchemaVersion = 3; Plugin::migrateSchema(); $rows = $db->fetchAll('SELECT id,full,upload_key FROM fixture_infinitytime_images ORDER BY id'); if (count($rows) !== 4 || $rows[0]['full'] !== '/keep-a.webp' || $rows[1]['full'] !== '/keep-b.webp' || $rows[0]['upload_key'] !== null || (int)\Utils\Helper::$options->infinitytimeSchemaVersion !== 4) { - throw new \RuntimeException('Migration must preserve existing images and nullable keys'); + throw new \RuntimeException('升级必须保留已有图片及空重试键'); } - echo "SQLite migration preservation / unique retry keys / interrupted retry passed\n"; + echo "SQLite 升级数据保留、重试键唯一约束及中断恢复检查通过\n"; } diff --git a/usr/plugins/InfinityTime/Lib/AdminRepository.php b/usr/plugins/InfinityTime/Lib/AdminRepository.php new file mode 100644 index 0000000..4110c70 --- /dev/null +++ b/usr/plugins/InfinityTime/Lib/AdminRepository.php @@ -0,0 +1,156 @@ +getAdapterName(), 'mysql') !== false ? '`' : '"'; + return $q . str_replace($q, $q . $q, $db->getPrefix() . $name) . $q; + } + + private static function authorColumn(): string + { + return stripos(Db::get()->getAdapterName(), 'mysql') !== false ? 'c.`authorId`' : 'c."authorId"'; + } + + public static function readRow($query, bool $writer = false): array + { + $db = Db::get(); + if ($writer && is_object($query) && method_exists($query, 'prepare')) { $query = $query->prepare((string)$query); } + return $db->fetchRow($writer ? $db->query($query, Db::WRITE) : $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); + } + + /** 所有参数均由 Typecho 适配器转义;不直接拼接用户输入。 */ + public static function sql(string $sql, array $values = []): string + { + $query = Db::get()->select(); + foreach ($values as $value) { + $pos = strpos($sql, '?'); + if ($pos === false) { throw new \LogicException('SQL 参数数量不匹配'); } + $sql = substr_replace($sql, $query->quoteValue($value), $pos, 1); + } + return $query->prepare($sql); + } + + private static function identity(): string + { + $fields = self::table('fields'); + $images = self::table('infinitytime_images'); + return "(EXISTS (SELECT 1 FROM $images i WHERE i.cid = c.cid)" + . " OR EXISTS (SELECT 1 FROM $fields f WHERE f.cid = c.cid" + . " AND ((f.name = 'img' AND f.str_value <> '')" + . " OR (f.name = 'infinitytime_album' AND f.str_value = '1'))))"; + } + + public static function album(int $cid, int $uid, bool $admin, bool $writer = false): ?array + { + if ($cid <= 0 || $uid <= 0) { return null; } + $sql = 'SELECT c.cid, c.title, ' . self::authorColumn() . ', c.status, c.created FROM ' . self::table('contents') + . " c WHERE c.cid = ? AND c.type = 'post' AND c.status IN ('draft','publish') AND " . self::identity(); + $values = [$cid]; + if (!$admin) { $sql .= ' AND ' . self::authorColumn() . ' = ?'; $values[] = $uid; } + $row = self::readRow(self::sql($sql . ' LIMIT 1', $values), $writer); + return $row ?: null; + } + + public static function page(int $uid, bool $admin, int $page = 1, string $search = ''): array + { + $page = max(1, min(100000, $page)); + $search = trim(function_exists('mb_substr') ? mb_substr($search, 0, 100) : substr($search, 0, 100)); + $where = "c.type = 'post' AND c.status IN ('draft','publish') AND " . self::identity(); + $values = []; + if (!$admin) { $where .= ' AND ' . self::authorColumn() . ' = ?'; $values[] = $uid; } + if ($search !== '') { + // 按字面子串搜索;统一使用 ! 转义通配符,兼容各数据库适配器。 + $where .= " AND c.title LIKE ? ESCAPE '!'"; + $values[] = '%' . strtr($search, ['!' => '!!', '%' => '!%', '_' => '!_']) . '%'; + } + $from = ' FROM ' . self::table('contents') . ' c WHERE ' . $where; + $total = Db::get()->fetchRow(self::sql('SELECT COUNT(*) AS total' . $from, $values)); + $total = (int)($total['total'] ?? 0); + $pages = max(1, (int)ceil($total / self::PAGE_SIZE)); + $page = min($page, $pages); + $rows = Db::get()->fetchAll(self::sql('SELECT c.cid, c.title, c.status, c.created' . $from + . ' ORDER BY c.created DESC, c.cid DESC LIMIT ' . self::PAGE_SIZE + . ' OFFSET ' . (($page - 1) * self::PAGE_SIZE), $values)); + if ($rows) { + $cids = array_map(static function ($row) { return (int)$row['cid']; }, $rows); + $counts = self::counts($cids); + $fields = Db::get()->fetchAll('SELECT cid, name, str_value FROM ' . self::table('fields') + . ' WHERE cid IN (' . implode(',', $cids) . ") AND name IN ('device','tags','location')"); + $meta = []; + foreach ($fields as $field) { $meta[(int)$field['cid']][$field['name']] = $field['str_value']; } + foreach ($rows as &$row) { + $cid = (int)$row['cid']; + $row['img_count'] = $counts[$cid] ?? 0; + foreach (['device', 'tags', 'location'] as $name) { $row[$name] = (string)($meta[$cid][$name] ?? ''); } + } + unset($row); + } + return ['items' => $rows, 'page' => $page, 'pages' => $pages, 'total' => $total, 'query' => $search]; + } + + public static function counts(array $cids, bool $writer = false): array + { + $cids = array_values(array_filter(array_unique(array_map('intval', $cids)), static function ($cid) { return $cid > 0; })); + if (!$cids) { return []; } + $ids = implode(',', $cids); + $counts = []; + foreach (self::readAll('SELECT cid, COUNT(*) AS image_count FROM ' . self::table('infinitytime_images') + . " WHERE cid IN ($ids) GROUP BY cid", $writer) as $row) { + $counts[(int)$row['cid']] = (int)$row['image_count']; + } + // 仅有旧字段的历史图集:在 SQL 内聚合计数,不回传图片列表字段。 + $newline = stripos(Db::get()->getAdapterName(), 'pgsql') !== false || stripos(Db::get()->getAdapterName(), 'postgres') !== false ? 'CHR(10)' : 'CHAR(10)'; + foreach (self::readAll('SELECT cid, LENGTH(str_value) - LENGTH(REPLACE(str_value, ' . $newline . ", '')) + 1 AS image_count FROM " + . self::table('fields') . " WHERE cid IN ($ids) AND name = 'img' AND str_value <> ''", $writer) as $row) { + $cid = (int)$row['cid']; + if (!isset($counts[$cid])) { $counts[$cid] = (int)$row['image_count']; } + } + return $counts; + } + + public static function field(int $cid, string $name, bool $writer = false): string + { + $db = Db::get(); + $row = self::readRow($db->select('str_value')->from($db->getPrefix() . 'fields') + ->where('cid = ?', $cid)->where('name = ?', $name)->limit(1), $writer); + return (string)($row['str_value'] ?? ''); + } + + public static function setField(int $cid, string $name, string $value): void + { + $db = Db::get(); + $table = $db->getPrefix() . 'fields'; + $db->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])); + } + } + + public static function draftForOperation(int $uid, string $key): ?array + { + $sql = 'SELECT c.cid, c.title, c.status FROM ' . self::table('contents') . ' c WHERE ' . self::authorColumn() . ' = ?' + . " AND c.type = 'post' AND c.status IN ('draft','publish') AND " . self::identity() + . ' AND EXISTS (SELECT 1 FROM ' . self::table('fields') + . " f WHERE f.cid = c.cid AND f.name = 'infinitytime_upload' AND f.str_value = ?) LIMIT 1"; + $row = self::readRow(self::sql($sql, [$uid, $key]), true); + return $row ?: null; + } +} diff --git a/usr/plugins/InfinityTime/Lib/AdminSecurity.php b/usr/plugins/InfinityTime/Lib/AdminSecurity.php new file mode 100644 index 0000000..2df5655 --- /dev/null +++ b/usr/plugins/InfinityTime/Lib/AdminSecurity.php @@ -0,0 +1,17 @@ + true]; } + $title = trim((string)($data['title'] ?? '')); + if ($title === '') { throw new \InvalidArgumentException('请填写图集标题'); } + $db = Db::get(); + $now = time(); + $db->query('BEGIN', Db::WRITE); + try { + $cid = (int)$db->query($db->insert($db->getPrefix() . 'contents')->rows([ + 'title' => $title, 'slug' => 'album-' . date('YmdHis', $now) . '-' . bin2hex(random_bytes(6)), + 'created' => $now, 'modified' => $now, 'text' => '', 'authorId' => $uid, + 'type' => 'post', 'status' => 'draft', 'allowComment' => '0', 'allowPing' => '0', + 'allowFeed' => '0', 'template' => '', 'password' => '', + ])); + if ($cid <= 0) { throw new \RuntimeException('图集创建失败'); } + AdminRepository::setField($cid, AdminRepository::MARKER, '1'); + AdminRepository::setField($cid, AdminRepository::OPERATION, $key); + foreach (['device' => 'device', 'tags' => 'tags', 'location' => 'address'] as $name => $input) { + AdminRepository::setField($cid, $name, trim((string)($data[$input] ?? ''))); + } + $db->query('COMMIT', Db::WRITE); + } catch (\Throwable $e) { + $db->query('ROLLBACK', Db::WRITE); + throw $e; + } + return ['cid' => $cid, 'title' => $title, 'status' => 'draft', 'replayed' => false]; + } + + public static function requireAlbum(int $cid, int $uid, bool $admin, bool $canPublish = false): array + { + $album = AdminRepository::album($cid, $uid, $admin, true); + if (!$album) { throw new \DomainException('图集不存在或没有权限'); } + if ($album['status'] === 'publish' && !$canPublish) { throw new \DomainException('已发布图集需要编辑或管理员权限才能修改'); } + return $album; + } + + /** 上传标识绑定文件内容与逐图元数据,不受转换设置变化影响。 */ + public static function fingerprint(array $file, array $data): string + { + $hash = @hash_file('sha256', (string)($file['tmp_name'] ?? '')); + if (!is_string($hash)) { throw new \InvalidArgumentException('无法读取上传文件'); } + return hash('sha256', json_encode([$hash, trim((string)($data['title'] ?? '')), + trim((string)($data['desc'] ?? '')), trim((string)($data['address'] ?? ''))], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + } + + public static function upload(int $cid, int $uid, bool $admin, array $file, array $data, string $key, bool $canPublish = false): array + { + if (!AdminSecurity::validKey($key)) { throw new \InvalidArgumentException('图片上传标识无效,请重新选择文件'); } + ImageRepository::lockMedia(); + self::requireAlbum($cid, $uid, $admin, $canPublish); + ImageRepository::ensureSchema(); + $managed = AdminRepository::readRow(Db::get()->select('COUNT(*) AS total')->from(ImageRepository::table())->where('cid = ?', $cid), true); + if ((int)($managed['total'] ?? 0) === 0 && AdminRepository::field($cid, 'img', true) !== '') { + throw new \DomainException('此历史图集只有旧图片字段,暂不支持追加。请新建图集上传;原图集未改动'); + } + $error = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE); + if ($error !== UPLOAD_ERR_OK) { + $messages = [UPLOAD_ERR_INI_SIZE => '图片超过服务器上传限制', UPLOAD_ERR_FORM_SIZE => '图片超过表单上传限制', + UPLOAD_ERR_PARTIAL => '图片只上传了一部分,请重试', UPLOAD_ERR_NO_FILE => '没有收到图片文件', + UPLOAD_ERR_NO_TMP_DIR => '服务器缺少临时目录', UPLOAD_ERR_CANT_WRITE => '服务器无法写入临时文件', + UPLOAD_ERR_EXTENSION => '服务器扩展阻止了上传']; + throw new \InvalidArgumentException($messages[$error] ?? '上传失败'); + } + if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) { throw new \InvalidArgumentException('非法上传文件'); } + $bytes = @filesize($file['tmp_name']); + if ($bytes === false || $bytes <= 0 || $bytes > 64 * 1024 * 1024) { throw new \InvalidArgumentException('图片为空或超过 64MB 上限'); } + $fingerprint = self::fingerprint($file, $data); + $db = Db::get(); + $row = AdminRepository::readRow($db->select()->from(ImageRepository::table()) + ->where('cid = ?', $cid)->where('upload_key = ?', $key)->limit(1), true); + if ($row) { + if (!is_string($row['upload_fingerprint'] ?? null) || !hash_equals($row['upload_fingerprint'], $fingerprint)) { + throw new \DomainException('同一上传标识不能用于不同图片或描述,请重新选择文件'); + } + self::sync($cid, $row); + return ['cid' => $cid, 'rowId' => (int)$row['id'], 'replayed' => true]; + } + $dim = @getimagesize($file['tmp_name']); + if (is_array($dim) && (($dim[0] ?? 0) > 20000 || ($dim[1] ?? 0) > 20000)) { + throw new \InvalidArgumentException('图片单边超过 20000px 上限'); + } + $meta = ImageRepository::ingest($file, [ + 'quality' => (int)Plugin::opt('infinitytimeQuality', Plugin::DEFAULT_QUALITY), + 'thumb_max' => (int)Plugin::opt('infinitytimeThumbMax', Plugin::DEFAULT_THUMB_MAX), + 'max_width' => (int)Plugin::opt('infinitytimeMaxWidth', Plugin::DEFAULT_MAX_WIDTH), + 'full_quality' => (int)Plugin::opt('infinitytimeFullQuality', Plugin::DEFAULT_FULL_QUALITY), + 'pano_width' => (int)Plugin::opt('infinitytimePanoWidth', Plugin::DEFAULT_PANO_WIDTH), + 'pano_quality' => (int)Plugin::opt('infinitytimePanoQuality', Plugin::DEFAULT_PANO_QUALITY), + 'keep_original' => (bool)Plugin::opt('infinitytimeKeepOriginal', Plugin::DEFAULT_KEEP_ORIGINAL), + 'address' => trim((string)($data['address'] ?? '')), + ]); + if (!$meta) { throw new \RuntimeException(ImageRepository::$lastError ?: '图片转换失败,请重试'); } + $meta['title'] = trim((string)($data['title'] ?? '')); + $meta['desc'] = trim((string)($data['desc'] ?? '')); + $meta['upload_key'] = $key; + $meta['upload_fingerprint'] = $fingerprint; + $max = AdminRepository::readRow($db->select('MAX(sort) AS last_sort')->from(ImageRepository::table())->where('cid = ?', $cid), true); + $sort = isset($max['last_sort']) ? (int)$max['last_sort'] + 1 : 0; + // 标识与图片行原子入库;字段同步失败后仍可幂等重试。 + $rowId = ImageRepository::insertRow($cid, $meta, $sort); + if ($rowId <= 0) { throw new \RuntimeException('图片记录写入失败,请重试'); } + self::sync($cid, $meta); + return ['cid' => $cid, 'rowId' => $rowId, 'replayed' => false]; + } + + private static function sync(int $cid, array $row): void + { + ImageRepository::syncPostFields($cid); + // 保留手动填写的设备;否则从成功图片的 EXIF 提取设备。 + if (AdminRepository::field($cid, 'device', true) === '') { + $exif = is_array($row['exif'] ?? null) ? $row['exif'] : (json_decode((string)($row['exif'] ?? '{}'), true) ?: []); + $device = trim((string)($exif['make'] ?? '') . ' ' . (string)($exif['model'] ?? '')); + if ($device !== '') { AdminRepository::setField($cid, 'device', $device); } + } + } + + public static function publish(int $cid, int $uid, bool $admin, bool $canPublish = false): void + { + if (!$canPublish) { throw new \DomainException('发布图集需要编辑或管理员权限'); } + ImageRepository::lockMedia(); + self::requireAlbum($cid, $uid, $admin, $canPublish); + $counts = AdminRepository::counts([$cid], true); + if (($counts[$cid] ?? 0) < 1) { throw new \InvalidArgumentException('请先上传至少一张图片再发布'); } + ImageRepository::syncPostFields($cid); + $db = Db::get(); + $db->query($db->update($db->getPrefix() . 'contents')->rows(['status' => 'publish', 'modified' => time()])->where('cid = ?', $cid)); + } +} diff --git a/usr/plugins/InfinityTime/Lib/ImageRepository.php b/usr/plugins/InfinityTime/Lib/ImageRepository.php index 274ec4f..da69080 100644 --- a/usr/plugins/InfinityTime/Lib/ImageRepository.php +++ b/usr/plugins/InfinityTime/Lib/ImageRepository.php @@ -269,12 +269,23 @@ public static function insertRow(int $cid, array $meta, int $sort = 0): int ])); } + /** 持锁后的变更及引用检查必须读取主库,避免从库延迟造成重复或误删。 */ + private static function fetchRows($db, $query): array + { + if (is_resource(self::$mediaLock)) { + // Typecho 会按 Query 类型重新选择连接池;先编译绑定参数,再显式选择主库。 + $query = $db->query($query->prepare((string)$query), \Typecho\Db::WRITE); + } + return $db->fetchAll($query); + } + /** 从插件表取某 cid 的图片列表。 */ public static function rowsFor(int $cid): array { self::ensureSchema(); $db = \Typecho\Db::get(); - $rows = $db->fetchAll($db->select()->from(self::table())->where('cid = ?', $cid)->order('sort', \Typecho\Db::SORT_ASC)->order('id', \Typecho\Db::SORT_ASC)); + $query = $db->select()->from(self::table())->where('cid = ?', $cid)->order('sort', \Typecho\Db::SORT_ASC)->order('id', \Typecho\Db::SORT_ASC); + $rows = self::fetchRows($db, $query); return array_map(function ($r) { $r['exif'] = json_decode($r['exif'] ?? '{}', true); return $r; @@ -292,7 +303,7 @@ public static function rowsForCids(array $cids): array } $db = \Typecho\Db::get(); $ph = implode(',', array_fill(0, count($cids), '?')); - $rows = $db->fetchAll( + $rows = self::fetchRows($db, $db->select()->from(self::table()) ->where('cid IN (' . $ph . ')', ...$cids) ->order('sort', \Typecho\Db::SORT_ASC) @@ -424,7 +435,7 @@ public static function syncPostFields(int $cid, bool $clearEmpty = false): void } } - /** EXIF dates are wall-clock camera dates; timestamps fall back to documented UTC. */ + /** EXIF 日期按相机记录的日历日期解释;缺失时按入库时间的 UTC 月份回退。 */ public static function photoMonth(array $exif, int $created): string { $date = (string)($exif['datetime'] ?? ''); @@ -481,13 +492,13 @@ private static function referencedPaths(): array $real = realpath($path); if ($real !== false) { $referenced[$real] = true; } }; - foreach ($db->fetchAll($db->select()->from(self::table())) as $row) { + foreach (self::fetchRows($db, $db->select()->from(self::table())) as $row) { foreach (['original', 'full', 'thumb', 'mid', 'avif', 'mid_avif'] as $field) { $add($row[$field] ?? null); } } // 历史图集可能只有文章字段,没有仓库行,也必须保留其公开图片。 - $fields = $db->fetchAll($db->select('name', 'str_value')->from($db->getPrefix() . 'fields') + $fields = self::fetchRows($db, $db->select('name', 'str_value')->from($db->getPrefix() . 'fields') ->where('name IN (?, ?, ?)', 'img', 'thumb', 'variants')); foreach ($fields as $field) { if (($field['name'] ?? '') === 'variants') { @@ -565,7 +576,7 @@ public static function rebuild(array $opts = []): array ], $opts); $db = \Typecho\Db::get(); - $rows = $db->fetchAll($db->select()->from(self::table())); + $rows = self::fetchRows($db, $db->select()->from(self::table())); $rebuilt = 0; $failed = 0; $syncedCids = []; diff --git a/usr/plugins/InfinityTime/Plugin.php b/usr/plugins/InfinityTime/Plugin.php index 5406c3d..5aee4e1 100644 --- a/usr/plugins/InfinityTime/Plugin.php +++ b/usr/plugins/InfinityTime/Plugin.php @@ -385,7 +385,7 @@ private static function createTables(): void if ($isMysql) { $sql .= " engine=InnoDB DEFAULT CHARSET=utf8mb4"; } - $db->query($sql); + $db->query($sql, Db::WRITE); // 旧表升级:按 schema 版本补齐缺失列 self::migrateSchema(); @@ -393,9 +393,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->query("CREATE INDEX idx_{$table}_cid ON {$q}{$table}{$q} (`cid`)", Db::WRITE); } else { - $db->query("CREATE INDEX idx_cid ON {$q}{$table}{$q} ({$q}cid{$q})"); + $db->query("CREATE INDEX idx_cid ON {$q}{$table}{$q} ({$q}cid{$q})", Db::WRITE); } } catch (\Throwable $e) { // 已存在或不受支持时忽略 @@ -423,18 +423,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->query($probe, Db::WRITE); continue; } catch (\Throwable $e) { // 缺列时尝试补齐;其他读取错误也不能冒充成功。 } try { - $db->query("ALTER TABLE {$q}{$table}{$q} ADD COLUMN {$q}{$col}{$q} {$definition}"); + $db->query("ALTER TABLE {$q}{$table}{$q} ADD COLUMN {$q}{$col}{$q} {$definition}", Db::WRITE); } catch (\Throwable $e) { // 另一进程可能已加列;下面实际探测成功才允许继续。 } try { - $db->query($probe); + $db->query($probe, Db::WRITE); } catch (\Throwable $e) { throw new \RuntimeException('InfinityTime 数据库升级失败,请检查数据库权限后重试(列:' . $col . ')', 0, $e); } @@ -443,7 +443,7 @@ public static function migrateSchema(): void self::setOption('infinitytimeSchemaVersion', (string)self::SCHEMA_VERSION); } - /** A nullable key preserves existing rows and gives retries a database safety net. */ + /** 可空键兼容已有记录,唯一索引为上传重试提供数据库级去重保护。 */ private static function ensureUploadIndex($db, string $table): void { $adapter = strtolower($db->getAdapterName()); @@ -452,26 +452,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->query("CREATE UNIQUE INDEX {$q}{$index}{$q} ON {$q}{$table}{$q} ({$q}cid{$q}, {$q}upload_key{$q})", Db::WRITE); return; } catch (\Throwable $e) { - // Only a verified existing unique index permits a concurrent migration retry. + // 建索引失败时,只有验证已存在的索引确为目标唯一索引才允许继续。 } $valid = false; if ($mysql) { - $rows = $db->fetchAll("SHOW INDEX FROM {$q}{$table}{$q} WHERE Key_name = '" . $index . "'"); + $rows = $db->fetchAll($db->query("SHOW INDEX FROM {$q}{$table}{$q} WHERE Key_name = '" . $index . "'", Db::WRITE)); 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("SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND indexname = '" . $index . "'"); + $rows = $db->fetchAll($db->query("SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND indexname = '" . $index . "'", Db::WRITE)); $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("PRAGMA index_list(" . $q . $table . $q . ")") as $row) { + foreach ($db->fetchAll($db->query("PRAGMA index_list(" . $q . $table . $q . ")", Db::WRITE)) as $row) { if (($row['name'] ?? '') !== $index || (int)($row['unique'] ?? 0) !== 1) { continue; } - $columns = $db->fetchAll("PRAGMA index_info(" . $q . $index . $q . ")"); + $columns = $db->fetchAll($db->query("PRAGMA index_info(" . $q . $index . $q . ")", Db::WRITE)); $valid = array_column($columns, 'name') === ['cid', 'upload_key']; } } @@ -503,9 +503,8 @@ public static function setOption(string $name, $value): void $db = Db::get(); $prefix = $db->getPrefix(); $val = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : (string)$value; - $exists = $db->fetchRow( - $db->select('name')->from($prefix . 'options')->where('name = ?', $name)->limit(1) - ); + $query = $db->select('name')->from($prefix . 'options')->where('name = ?', $name)->limit(1); + $exists = $db->fetchRow($db->query($query->prepare((string)$query), Db::WRITE)); if ($exists) { $db->query($db->update($prefix . 'options')->rows(['value' => $val])->where('name = ?', $name)); } else { diff --git a/usr/plugins/InfinityTime/assets/admin.css b/usr/plugins/InfinityTime/assets/admin.css index c62b529..7e1229f 100644 --- a/usr/plugins/InfinityTime/assets/admin.css +++ b/usr/plugins/InfinityTime/assets/admin.css @@ -442,3 +442,24 @@ summary { cursor: pointer; } .pp-card { padding: 18px 16px; } .pp-tabs { position: static; } } + +/* 串行上传状态:保留成功项,只重试失败项。 */ +.pp-up-state { font-size: 12px; line-height: 1.5; color: #52606d; overflow-wrap: anywhere; } +.pp-up-uploading, .pp-up-converting { border-color: var(--pp-brand); } +.pp-up-uploading .pp-up-state, .pp-up-converting .pp-up-state { color: var(--pp-brand-dark); font-weight: 600; } +.pp-up-done { border-color: #b9d9b5; background: #fcfefb; } +.pp-up-done .pp-up-state { color: #2e7d32; } +.pp-up-failed { border-color: #e5a69f; } +.pp-up-failed .pp-up-state { color: var(--pp-danger); } +.pp-up-remove:disabled { opacity: .35; cursor: not-allowed; } +.pp-up-tit:disabled, .pp-up-desc:disabled { color: #52606d; background: #f6f8fa; } +.pp-up-saved { display: flex; align-items: center; justify-content: center; color: #2e7d32; font-size: 13px; } +.pp-up-missing { border: 1px dashed #d6dbe0; } +.pp-dropzone.disabled { opacity: .55; cursor: not-allowed; } +#pp-upload-target { display: block; color: #3e4c59; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; } +.pp-wrap [hidden] { display: none !important; } +.pp-album-search, .pp-pagination { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 12px 0; } +.pp-pagination a, .pp-pagination span { padding: 6px 10px; border-radius: 6px; } +.pp-pagination [aria-current="page"] { background: var(--pp-brand); color: #fff; } + +.pp-album-search input[type="search"] { min-width: 0; max-width: 100%; padding: 7px 10px; border: 1px solid #d6dbe0; border-radius: 8px; } diff --git a/usr/plugins/InfinityTime/assets/admin.js b/usr/plugins/InfinityTime/assets/admin.js index c847ace..954fb52 100644 --- a/usr/plugins/InfinityTime/assets/admin.js +++ b/usr/plugins/InfinityTime/assets/admin.js @@ -9,6 +9,7 @@ var ENDPOINT = CFG.url || ''; var TOKEN = CFG.token || ''; var JOB = CFG.job || null; + var CAN_PUBLISH = CFG.canPublish === true; var $ = function (sel, root) { return (root || document).querySelector(sel); }; var $$ = function (sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }; @@ -40,21 +41,34 @@ if (action) fd.set('action', action); if (TOKEN) fd.set('_', TOKEN); return fetch(ENDPOINT, { method: 'POST', body: fd, credentials: 'same-origin' }) - .then(function (r) { return r.json().catch(function () { return { ok: false, msg: '服务器返回异常' }; }); }); + .then(function (r) { + return r.json().catch(function () { return { ok: false, msg: '服务器返回异常' }; }) + .then(function (d) { if (!r.ok) return { ok: false, msg: (d && d.msg) || '请求失败,请重试' }; return d; }); + }); + } + + function readURL(job, params) { + var query = 'ajax=1&job=' + encodeURIComponent(job); + Object.keys(params || {}).forEach(function (key) { query += '&' + encodeURIComponent(key) + '=' + encodeURIComponent(params[key]); }); + return ENDPOINT + (ENDPOINT.indexOf('?') === -1 ? '?' : '&') + query; } + var albumsRefresh = 0; function refreshAlbums() { - fetch(ENDPOINT + (ENDPOINT.indexOf('?') === -1 ? '?' : '&') + 'ajax=1&job=albums_html', { credentials: 'same-origin' }) - .then(function (r) { return r.text(); }) + var cur = $('#pp-albums-card'); + if (!cur) return Promise.resolve(); + var request = ++albumsRefresh; + return fetch(readURL('albums_html', { page: cur.dataset.page || '1', q: cur.dataset.query || '' }), { credentials: 'same-origin' }) + .then(function (r) { if (!r.ok) throw new Error('list'); return r.text(); }) .then(function (html) { + if (request !== albumsRefresh) return; var fresh = new DOMParser().parseFromString(html, 'text/html').querySelector('#pp-albums-card'); - var cur = $('#pp-albums-card'); - if (fresh && cur) { - cur.outerHTML = fresh.outerHTML; - Albums.bind(); - } + var current = $('#pp-albums-card'); + if (!fresh || !current) throw new Error('list'); + current.outerHTML = fresh.outerHTML; + Albums.bind(); }) - .catch(function () {}); + .catch(function () { notice('操作已保存,但图集列表刷新失败,请刷新页面查看。', 'error'); }); } /* ---------- 标签页 ---------- */ @@ -80,199 +94,337 @@ } }; - /* ---------- 上传发布 ---------- */ + /* ---------- 串行上传:先保存草稿,再明确发布 ---------- */ var Upload = { sel: [], + running: false, + append: function () {}, init: function () { - var form = $('#pp-upload-form'); - var input = $('#pp-files-input'); - var wrap = $('#pp-upload-previews'); + var form = $('#pp-upload-form'), input = $('#pp-files-input'), wrap = $('#pp-upload-previews'); if (!form || !input || !wrap) return; - // JS 按选中队列校验;拖入文件的浏览器不一定支持回写 input.files。 input.required = false; - var submitBtn = form.querySelector('button[type=submit]'); - var dropzone = $('#pp-dropzone'); - var bar = $('#pp-upload-bar'); - var barOuter = $('#pp-upload-progress'); + var submitBtn = $('#pp-upload-submit') || form.querySelector('button[type=submit]'); + var retryBtn = $('#pp-upload-retry'), publishBtn = $('#pp-upload-publish'), newBtn = $('#pp-upload-new'); + var cidInput = $('#pp-upload-cid'), target = $('#pp-upload-target'); + var operationInput = form.querySelector('[name="operation_key"]'); + var dropzone = $('#pp-dropzone'), bar = $('#pp-upload-bar'), barOuter = $('#pp-upload-progress'); var barMsg = barOuter ? barOuter.querySelector('.pp-msg') : null; - + var 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 value(name) { var el = field(name); return el ? el.value : ''; } + function key() { + if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID(); + return 'it-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2) + '-' + Math.random().toString(36).slice(2); + } function fileKey(f) { return f.name + '|' + f.size + '|' + (f.lastModified || 0); } - + function humanSize(n) { + if (!n) return '0 B'; + var units = ['B', 'KB', 'MB', 'GB'], i = 0; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return n.toFixed(i ? 1 : 0) + ' ' + units[i]; + } + function savedFields() { + return { title: value('title'), address: value('address'), device: value('device'), tags: value('tags') }; + } + function persist() { + if (cidInput) cidInput.value = cid; + if (operationInput) operationInput.value = operationKey; + try { + window.sessionStorage.setItem(storageKey, JSON.stringify({ + cid: cid, title: albumTitle, status: albumStatus, operationKey: operationKey, fields: savedFields(), + items: Upload.sel.map(function (it) { + return { fingerprint: it.fingerprint, name: it.name, size: it.size, itemKey: it.itemKey, + title: it.title, desc: it.desc, address: it.address, state: it.state, rowId: it.rowId || null }; + }) + })); + } catch (e) {} + } function syncInput() { try { var dt = new DataTransfer(); - Upload.sel.forEach(function (item) { dt.items.add(item.file); }); + Upload.sel.forEach(function (item) { if (item.file && item.state !== 'done') dt.items.add(item.file); }); input.files = dt.files; } catch (e) {} } - - function humanSize(n) { - if (!n) return '0 B'; - var u = ['B', 'KB', 'MB', 'GB'], i = 0; - while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } - return n.toFixed(i ? 1 : 0) + ' ' + u[i]; + function discard() { + Upload.sel.forEach(function (item) { if (item.url) URL.revokeObjectURL(item.url); }); + Upload.sel = []; + input.value = ''; } - - function updateSummary() { + function readOnlyTarget() { return !!cid && albumStatus === 'publish' && !CAN_PUBLISH; } + function controls() { + var busy = Upload.running || readOnlyTarget(); + input.disabled = busy; + ['title', 'device', 'tags'].forEach(function (name) { var el = field(name); if (el) el.disabled = busy || !!cid; }); + var address = field('address'); if (address) address.disabled = busy; + if (submitBtn) { submitBtn.disabled = busy; submitBtn.textContent = readOnlyTarget() ? '已发布图集(只读)' : (busy ? '处理中…' : (cid ? (verified ? '保存新增图片' : '重新核验图集') : '保存草稿并上传')); } + if (retryBtn) { retryBtn.hidden = !Upload.sel.some(function (it) { return it.state === 'failed'; }); retryBtn.disabled = busy; } + if (publishBtn) { + publishBtn.hidden = !CAN_PUBLISH || !cid || albumStatus !== 'draft'; + publishBtn.disabled = busy || !verified || !albumCount || Upload.sel.some(function (it) { return it.state !== 'done'; }); + } + if (newBtn) { newBtn.disabled = Upload.running; newBtn.hidden = !cid && !Upload.sel.length && !operationKey; } + if (dropzone) { dropzone.classList.toggle('disabled', busy); dropzone.setAttribute('aria-disabled', String(busy)); } + if (target) target.textContent = cid ? + (albumStatus === 'publish' ? (readOnlyTarget() ? '已发布图集(只读):' : '追加到已发布图集:') : '当前草稿:') + albumTitle + '(#' + cid + ',已保存 ' + albumCount + ' 张)' + (!verified ? ',待核验' : '') + (!CAN_PUBLISH && albumStatus === 'draft' ? '。请编辑或管理员审核发布。' : '') : + (CAN_PUBLISH ? '新建草稿:图片保存后,点击“发布图集”才会公开。' : '新建草稿:保存后需编辑或管理员审核发布。'); + } + function 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) return; - if (!Upload.sel.length) { el.textContent = ''; return; } - var total = Upload.sel.reduce(function (s, it) { return s + (it.file.size || 0); }, 0); - el.textContent = '已选 ' + Upload.sel.length + ' 张,共 ' + humanSize(total); + if (el) el.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 render() { - // 回收旧预览图的 object URL,避免反复增删造成内存累积 - $$('.pp-up-thumb', wrap).forEach(function (img) { - if (img.dataset.objectUrl) { try { URL.revokeObjectURL(img.dataset.objectUrl); } catch (e) {} } - }); wrap.innerHTML = ''; Upload.sel.forEach(function (item, idx) { var card = document.createElement('div'); - card.className = 'pp-up-item'; + card.className = 'pp-up-item pp-up-' + item.state; card.dataset.idx = idx; - - var thumb = document.createElement('img'); - thumb.className = 'pp-up-thumb'; - thumb.alt = item.file.name; - var url = ''; - try { url = URL.createObjectURL(item.file); } catch (e) {} - if (url) { thumb.dataset.objectUrl = url; thumb.src = url; } - + 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 = '移除这张图片'; + 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 () { - Upload.sel.splice(idx, 1); - syncInput(); render(); updateSummary(); + 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.file.name; - + 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; - tit.addEventListener('input', function () { item.title = tit.value; card.classList.add('touched'); }); - var desc = document.createElement('textarea'); desc.rows = 2; desc.className = 'pp-up-desc'; desc.placeholder = '图片描述(可选)'; desc.value = item.desc; - desc.addEventListener('input', function () { item.desc = desc.value; card.classList.add('touched'); }); - - card.appendChild(thumb); card.appendChild(rm); card.appendChild(name); card.appendChild(tit); card.appendChild(desc); - Upload.bindDrag(card, idx, render); + 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); }); - updateSummary(); + summary(); } - function addFiles(files) { - var existing = {}; - Upload.sel.forEach(function (item) { existing[fileKey(item.file)] = true; }); + if (Upload.running || readOnlyTarget()) return; Array.prototype.forEach.call(files, function (f) { - var k = fileKey(f); - if (existing[k]) return; - existing[k] = true; - Upload.sel.push({ file: f, title: '', desc: '' }); + var fingerprint = fileKey(f); + var existing = Upload.sel.filter(function (it) { return it.fingerprint === fingerprint; })[0]; + if (existing) { + if (!existing.file && existing.state !== 'done') { existing.file = f; existing.error = '文件已重新选择,请点击“重试失败项”'; } + return; + } + Upload.sel.push({ file: f, fingerprint: fingerprint, name: f.name, size: f.size, + itemKey: key(), title: '', desc: '', state: 'queued', error: '' }); }); - syncInput(); render(); + syncInput(); persist(); render(); } - - input.addEventListener('change', function () { addFiles(input.files); }); - - if (dropzone) { - ['dragenter', 'dragover'].forEach(function (ev) { - dropzone.addEventListener(ev, function (e) { e.preventDefault(); dropzone.classList.add('over'); }); - }); - ['dragleave', 'drop'].forEach(function (ev) { - dropzone.addEventListener(ev, function (e) { e.preventDefault(); dropzone.classList.remove('over'); }); - }); - dropzone.addEventListener('drop', function (e) { - if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) addFiles(e.dataTransfer.files); - }); - dropzone.addEventListener('click', function () { input.click(); }); + function verifyTarget() { + if (!cid) return Promise.resolve(); + return fetch(readURL('album_status', { cid: cid }), { credentials: 'same-origin' }) + .then(function (r) { if (!r.ok) throw new Error('无法核验图集,请稍后重试。'); return r.json(); }) + .then(function (d) { + if (!d || !d.ok) throw new Error((d && d.msg) || '图集不存在或无权限,请选择其他图集或新建草稿。'); + albumTitle = d.title || albumTitle; albumStatus = d.status || 'draft'; albumCount = Number(d.count) || 0; + verified = true; persist(); controls(); + if (readOnlyTarget()) throw new Error('已发布图集为只读,追加图片需编辑或管理员权限。'); + }); } - - form.addEventListener('submit', function (e) { - e.preventDefault(); - if (!Upload.sel.length) { notice('请至少选择一张图片', 'error'); return; } - $$('input[name="img_titles[]"], textarea[name="img_descs[]"]', form).forEach(function (el) { el.parentNode.removeChild(el); }); - Upload.sel.forEach(function (item) { - var t = document.createElement('input'); - t.type = 'hidden'; t.name = 'img_titles[]'; t.value = item.title || ''; - form.appendChild(t); - var d = document.createElement('textarea'); - d.name = 'img_descs[]'; d.value = item.desc || ''; d.style.display = 'none'; - form.appendChild(d); + function ensureDraft() { + if (cid) return verified ? Promise.resolve() : verifyTarget(); + operationKey = operationKey || key(); + persist(); + var data = savedFields(); data.operation_key = operationKey; + return post('create_draft', data).then(function (d) { + if (!d || !d.ok || !d.cid) throw new Error((d && d.msg) || '草稿保存失败,请重试。'); + cid = String(d.cid); albumTitle = data.title; albumStatus = d.status || 'draft'; verified = true; + persist(); controls(); }); - syncInput(); - if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '发布中…'; } - if (barOuter) barOuter.style.display = 'flex'; - if (bar) bar.style.width = '0%'; - - var fd = new FormData(form); - // 以预览队列为唯一顺序来源,不依赖 DataTransfer 回写是否成功。 - fd.delete(input.name); - Upload.sel.forEach(function (item) { fd.append(input.name, item.file, item.file.name); }); - fd.set('ajax', '1'); - if (TOKEN) fd.set('_', TOKEN); - var xhr = new XMLHttpRequest(); - xhr.open('POST', ENDPOINT, true); - xhr.withCredentials = true; - xhr.upload.onprogress = function (ev) { - if (!ev.lengthComputable) return; - if (bar) bar.style.width = Math.round(ev.loaded * 100 / ev.total) + '%'; - if (barMsg) barMsg.textContent = humanSize(ev.loaded) + ' / ' + humanSize(ev.total); - }; - xhr.onload = function () { - var d = null; - try { d = JSON.parse(xhr.responseText); } catch (e) { d = { ok: false, msg: '服务器返回异常' }; } - if (d && d.ok) { - notice(d.msg || '已发布图集', 'success'); - Upload.sel = []; - form.reset(); - try { input.value = ''; } catch (e) {} - render(); - refreshAlbums(); - } else { - notice((d && d.msg) ? d.msg : '发布失败', 'error'); + } + function uploadOne(item) { + return new Promise(function (resolve) { + if (!item.file) { item.state = 'failed'; item.error = '请重新选择此文件,浏览器刷新后不会保留文件内容'; persist(); render(); resolve(); return; } + item.state = 'uploading'; item.error = ''; activeProgress = 0; + if (typeof item.address === 'undefined') item.address = value('address'); + persist(); render(); + var fd = new FormData(); + fd.set('action', 'upload_image'); fd.set('ajax', '1'); fd.set('cid', cid); fd.set('item_key', item.itemKey); + fd.set('title', item.title || ''); fd.set('desc', item.desc || ''); fd.set('address', item.address || ''); + if (TOKEN) fd.set('_', TOKEN); + fd.append('file', item.file, item.name); + var xhr = new XMLHttpRequest(), settled = false; + function finish(d) { + if (settled) return; + settled = true; + if (d && d.ok) { + item.state = 'done'; item.rowId = d.rowId; item.error = ''; + if (!d.replayed) albumCount++; + } else { + item.state = 'failed'; + item.error = (d && d.msg) || '网络或上传出错,请重试;同一图片不会重复保存'; + } + activeProgress = 0; persist(); render(); resolve(); } - }; - xhr.onerror = function () { notice('网络/上传出错,请重试', 'error'); }; - xhr.onloadend = function () { - if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '发布图集'; } - if (barOuter) setTimeout(function () { barOuter.style.display = 'none'; }, 600); - }; - xhr.send(fd); + xhr.open('POST', ENDPOINT, true); xhr.withCredentials = true; + // 超时后的保存结果不确定:重试时保留原幂等键。 + xhr.timeout = 180000; + xhr.upload.onprogress = function (ev) { + if (settled || !ev.lengthComputable) 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(); } + }; + xhr.upload.onload = function () { if (!settled) { item.state = 'converting'; persist(); render(); } }; + xhr.onload = function () { + var d; try { d = JSON.parse(xhr.responseText); } catch (e) { d = { ok: false, msg: '服务器响应异常,请重试此项' }; } + if (xhr.status < 200 || xhr.status >= 300) d = { ok: false, msg: (d && d.msg) || '上传请求失败,请重试' }; + finish(d); + }; + xhr.onerror = xhr.onabort = function () { finish(null); }; + xhr.ontimeout = function () { finish({ ok: false, msg: '请求超时,保存结果未确认,请重试此项' }); }; + try { xhr.send(fd); } catch (e) { finish(null); } + }); + } + function run(onlyFailed) { + if (Upload.running) return; + if (readOnlyTarget()) { notice('已发布图集为只读,请新建草稿或联系编辑 / 管理员。', 'error'); return; } + var queue = Upload.sel.filter(function (it) { return it.state === (onlyFailed ? 'failed' : 'queued'); }); + if (!queue.length && cid && !verified) { + Upload.running = true; render(); + verifyTarget().then(function () { notice('图集状态已更新。', 'success'); }) + .catch(function (err) { notice(err.message, 'error'); }) + .then(function () { Upload.running = false; render(); }); + return; + } + if (!queue.length) { notice(onlyFailed ? '没有失败项需要重试。' : '请先选择图片;失败项请使用“重试失败项”。', 'error'); return; } + if (!cid && !value('title').trim()) { notice('请填写图集标题。', 'error'); return; } + if (queue.some(function (it) { return !it.file; })) { notice('请重新选择未完成的原文件,再重试失败项。已保存图片仍在图集中。', 'error'); return; } + Upload.running = true; render(); + ensureDraft().then(function () { + return queue.reduce(function (previous, item) { return previous.then(function () { return uploadOne(item); }); }, Promise.resolve()); + }).then(function () { + // 根据服务器持久化状态核对未确认响应及幂等重试结果。 + return verifyTarget().catch(function () { verified = false; }); + }).then(function () { + var failed = Upload.sel.filter(function (it) { return it.state === 'failed'; }).length; + notice(!verified ? '上传已结束,但图集状态核验失败。已保存图片不会丢失,请点击“重新核验图集”。' : (failed ? '已保留成功图片,' + failed + ' 项失败,请重试失败项。' : (albumStatus === 'draft' ? (CAN_PUBLISH ? '图片已保存为草稿,确认后点击“发布图集”。' : '图片已保存为草稿,请编辑或管理员审核发布。') : '图片已追加到已发布图集。')), (!verified || failed) ? 'error' : 'success'); + refreshAlbums(); + }).catch(function (err) { notice(err.message || '草稿保存失败,请重试。', 'error'); }) + .then(function () { Upload.running = false; activeProgress = 0; persist(); render(); }); + } + input.addEventListener('change', function () { addFiles(input.files); }); + ['title', 'device', 'tags', 'address'].forEach(function (name) { var el = field(name); if (el) el.addEventListener('input', persist); }); + if (dropzone) { + ['dragenter', 'dragover'].forEach(function (event) { dropzone.addEventListener(event, function (e) { e.preventDefault(); if (!Upload.running && !readOnlyTarget()) dropzone.classList.add('over'); }); }); + ['dragleave', 'drop'].forEach(function (event) { dropzone.addEventListener(event, function (e) { e.preventDefault(); dropzone.classList.remove('over'); }); }); + dropzone.addEventListener('drop', function (e) { if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files); }); + dropzone.addEventListener('click', function () { if (!Upload.running && !readOnlyTarget()) input.click(); }); + } + form.addEventListener('submit', function (e) { e.preventDefault(); run(false); }); + if (retryBtn) retryBtn.addEventListener('click', function () { run(true); }); + if (publishBtn) publishBtn.addEventListener('click', function () { + if (!CAN_PUBLISH || Upload.running || !cid || albumStatus !== 'draft') return; + if (Upload.sel.some(function (it) { return it.state !== 'done'; })) { notice('请先处理未完成项,或将其从队列移除。', 'error'); return; } + Upload.running = true; render(); + verifyTarget().then(function () { + if (!albumCount) throw new Error('草稿中还没有图片,请先上传。'); + return post('publish_album', { cid: cid }); + }).then(function (d) { + if (!d || !d.ok) throw new Error((d && d.msg) || '发布失败,请重试。'); + albumStatus = 'publish'; persist(); notice(d.msg || '图集已发布。', 'success'); refreshAlbums(); + }).catch(function (err) { notice(err.message || '发布请求未确认,请刷新图集状态后重试。', 'error'); }) + .then(function () { Upload.running = false; render(); }); }); - + function resetTarget() { + discard(); cid = ''; albumTitle = ''; albumCount = 0; albumStatus = 'draft'; verified = false; operationKey = ''; + form.reset(); + ['title', 'device', 'tags', 'address'].forEach(function (name) { var el = field(name); if (el) el.value = ''; }); + persist(); render(); + } + if (newBtn) newBtn.addEventListener('click', function () { + if (Upload.running) return; + if (Upload.sel.some(function (it) { return it.state !== 'done'; }) && !confirm('清空当前未完成的上传队列并新建草稿?已保存的图片和草稿会保留。')) return; + resetTarget(); + }); + Upload.append = function (nextCid, title) { + if (Upload.running) { notice('请等待当前上传完成后再切换图集。', 'error'); return; } + if (cid && cid !== String(nextCid)) { + if (Upload.sel.some(function (it) { return it.state !== 'done'; }) && !confirm('切换图集会清空当前未完成的队列,已保存内容会保留。继续?')) return; + discard(); + } + cid = String(nextCid); albumTitle = title || ''; albumCount = 0; verified = false; operationKey = ''; + var titleField = field('title'); if (titleField) titleField.value = albumTitle; + Upload.running = true; persist(); render(); + var tab = document.querySelector('.pp-tab[data-tab="upload"]'); if (tab) tab.click(); + verifyTarget().catch(function (err) { notice(err.message, 'error'); }) + .then(function () { Upload.running = false; render(); }); + }; + window.addEventListener('beforeunload', function (e) { + persist(); + if (Upload.running || Upload.sel.some(function (it) { return it.state !== 'done'; })) { e.preventDefault(); e.returnValue = ''; } + }); + try { + var stored = JSON.parse(window.sessionStorage.getItem(storageKey) || 'null'); + if (stored && typeof stored === 'object' && (!initialTarget || String(initialTarget.cid) === String(stored.cid))) { + cid = /^\d+$/.test(String(stored.cid)) ? String(stored.cid) : ''; + operationKey = typeof stored.operationKey === 'string' ? stored.operationKey : ''; + albumTitle = stored.title || ''; albumStatus = stored.status || 'draft'; + ['title', 'device', 'tags', 'address'].forEach(function (name) { var el = field(name); if (el && stored.fields && typeof stored.fields[name] === 'string') el.value = stored.fields[name]; }); + (Array.isArray(stored.items) ? stored.items : []).forEach(function (it) { + if (!it.itemKey || !it.fingerprint) return; + Upload.sel.push({ fingerprint: it.fingerprint, name: it.name, size: it.size, itemKey: it.itemKey, + title: it.title || '', desc: it.desc || '', address: it.address, rowId: it.rowId, state: it.state === 'done' ? 'done' : 'failed', error: it.state === 'done' ? '' : '请重新选择原文件后重试' }); + }); + } + } catch (e) {} + if (initialTarget && initialTarget.cid) { + cid = String(initialTarget.cid); albumTitle = initialTarget.title || ''; albumStatus = initialTarget.status || 'draft'; + albumCount = Number(initialTarget.count) || 0; verified = false; operationKey = ''; + var initialTitle = field('title'); if (initialTitle) initialTitle.value = albumTitle; + } render(); + if (cid) { + Upload.running = true; render(); + verifyTarget().then(function () { + notice('已恢复图集“' + albumTitle + '”,服务器已保存 ' + albumCount + ' 张。' + (Upload.sel.some(function (it) { return it.state !== 'done'; }) ? '未完成项需重新选择原文件;浏览器不会保留文件内容。' : '可在图集列表查看已保存图片,或继续追加。'), 'success'); + }).catch(function (err) { notice(err.message, 'error'); }) + .then(function () { Upload.running = false; render(); }); + } else if (Upload.sel.length) notice('已恢复未完成项的信息,请重新选择原文件并点击“重试失败项”。', 'error'); }, - // 预览卡片拖拽排序 bindDrag: function (card, idx, rerender) { - // 只有从非输入区域按下时才允许拖拽,避免拖动标题/描述时误拖整张卡片 card.draggable = false; - card.addEventListener('mousedown', function (e) { - card.draggable = !(e.target && e.target.closest && e.target.closest('input, textarea, button, a, select')); - }); + function movable() { return !Upload.running && Upload.sel[idx] && Upload.sel[idx].state !== 'done'; } + card.addEventListener('mousedown', function (e) { card.draggable = movable() && !(e.target && e.target.closest && e.target.closest('input, textarea, button, a, select')); }); card.addEventListener('dragstart', function (e) { - e.dataTransfer.setData('text/plain', String(idx)); - e.dataTransfer.effectAllowed = 'move'; - card.classList.add('dragging'); + if (!movable()) { e.preventDefault(); return; } + e.dataTransfer.setData('text/plain', String(idx)); e.dataTransfer.effectAllowed = 'move'; card.classList.add('dragging'); }); - card.addEventListener('dragend', function () { card.classList.remove('dragging'); }); - card.addEventListener('dragover', function (e) { e.preventDefault(); card.classList.add('drop-target'); }); + card.addEventListener('dragend', function () { card.classList.remove('dragging'); card.draggable = false; }); + card.addEventListener('dragover', function (e) { if (movable()) { e.preventDefault(); card.classList.add('drop-target'); } }); card.addEventListener('dragleave', function () { card.classList.remove('drop-target'); }); card.addEventListener('drop', function (e) { - e.preventDefault(); - card.classList.remove('drop-target'); + e.preventDefault(); card.classList.remove('drop-target'); + if (!movable() || !e.dataTransfer) return; var from = parseInt(e.dataTransfer.getData('text/plain'), 10); - if (isNaN(from) || from < 0 || from >= Upload.sel.length || from === idx) return; - var moved = Upload.sel.splice(from, 1)[0]; - Upload.sel.splice(idx, 0, moved); - rerender(); + if (isNaN(from) || from < 0 || from >= Upload.sel.length || from === idx || Upload.sel[from].state === 'done') return; + Upload.sel.splice(idx, 0, Upload.sel.splice(from, 1)[0]); rerender(); }); } }; @@ -280,20 +432,34 @@ /* ---------- 已发布图集 ---------- */ var Albums = { bind: function () { - // 图集展开时才拉取图片列表(避免图集多时首屏 DOM 过大) + // 详情请求失败时保持可重试状态,不将图集标记为已加载。 $$('details.pp-album').forEach(function (d) { if (d.__ppLazyBound) return; d.__ppLazyBound = true; - d.addEventListener('toggle', function () { + function load() { if (!d.open) return; var box = d.querySelector('.pp-thumbs[data-cid]'); - if (!box || box.getAttribute('data-loaded') === '1') return; - var cid = box.getAttribute('data-cid'); - box.setAttribute('data-loaded', '1'); - fetch(ENDPOINT + (ENDPOINT.indexOf('?') === -1 ? '?' : '&') + 'ajax=1&job=album_images&cid=' + encodeURIComponent(cid), { credentials: 'same-origin' }) - .then(function (r) { return r.text(); }) - .then(function (html) { box.innerHTML = html; Albums.bind(); }) - .catch(function () { box.setAttribute('data-loaded', '0'); box.innerHTML = '
加载失败,请重试
'; }); + if (!box || box.getAttribute('data-loaded') === '1' || box.__ppLoading) return; + box.__ppLoading = true; + box.setAttribute('aria-busy', 'true'); + fetch(readURL('album_images', { cid: box.getAttribute('data-cid') }), { credentials: 'same-origin' }) + .then(function (r) { if (!r.ok) throw new Error('detail'); return r.text(); }) + .then(function (html) { box.innerHTML = html; box.setAttribute('data-loaded', '1'); box.__ppSortBound = false; Albums.bind(); }) + .catch(function () { + box.setAttribute('data-loaded', '0'); box.innerHTML = ''; + var retry = document.createElement('button'); + retry.type = 'button'; retry.className = 'pp-btn gray pp-small'; retry.textContent = '加载失败,点击重试'; + retry.addEventListener('click', load); box.appendChild(retry); + }).then(function () { box.__ppLoading = false; box.setAttribute('aria-busy', 'false'); }); + } + d.addEventListener('toggle', load); + if (d.open) load(); + }); + $$('[data-append-cid]').forEach(function (button) { + if (button.__ppAppendBound) return; + button.__ppAppendBound = true; + button.addEventListener('click', function (e) { + e.preventDefault(); Upload.append(button.getAttribute('data-append-cid'), button.getAttribute('data-append-title')); }); }); $$('.pp-thumbs').forEach(function (box) { @@ -397,19 +563,21 @@ } /* ---------- 维护任务 ---------- */ + var runningJobs = {}; function runJob(job) { + if (runningJobs[job]) return; + runningJobs[job] = true; var bar = $('#pp-bar-' + job); var msg = $('#pp-msg-' + job); var btn = document.querySelector('[data-run="' + job + '"]'); if (btn) btn.disabled = true; if (bar) bar.style.width = '0%'; // 每次开始先归零,避免上一次的 100% 残留 if (msg) msg.textContent = '准备中…'; - var url = ENDPOINT + (ENDPOINT.indexOf('?') === -1 ? '?' : '&') + 'ajax=1&job=' + job + '&_=' + encodeURIComponent(TOKEN); function tick() { - fetch(url, { credentials: 'same-origin' }) - .then(function (r) { return r.json(); }) + post('maintenance', { job: job }) .then(function (d) { - if (d.msg && d.finished) { if (msg) msg.textContent = d.msg; if (btn) btn.disabled = false; return; } + if (!d || d.ok === false) throw new Error((d && d.msg) || '出错,请重试'); + if (d.msg && d.finished) { if (msg) msg.textContent = d.msg; if (btn) btn.disabled = false; runningJobs[job] = false; return; } var pct = d.total ? Math.round(d.done * 100 / d.total) : 100; if (bar) bar.style.width = pct + '%'; if (d.total > 0) { @@ -421,10 +589,12 @@ if (!d.finished) { setTimeout(tick, 300); return; } if (msg) msg.textContent += d.failed > 0 ? (' ✓ 完成(' + d.failed + ' 张失败,请查看日志)') : ' ✓ 完成'; if (btn) btn.disabled = false; + runningJobs[job] = false; }) - .catch(function () { - if (msg) msg.textContent = '出错,请重试'; + .catch(function (err) { + if (msg) msg.textContent = err.message || '出错,请重试'; if (btn) btn.disabled = false; + runningJobs[job] = false; }); } tick(); @@ -527,6 +697,7 @@ if (h2 && h2.nextSibling) card.insertBefore(empty, h2.nextSibling); else card.appendChild(empty); } notice((d && d.msg) ? d.msg : '已删除', 'success'); + refreshAlbums(); } else { if (btn) { btn.disabled = false; if (btn.dataset.loading) btn.textContent = '删除'; } notice((d && d.msg) ? d.msg : '删除失败', 'error'); @@ -543,16 +714,22 @@ if (!form || form.id === 'pp-upload-form' || form.classList.contains('pp-delete-album')) return; var actEl = form.querySelector('input[name="action"]'); var act = actEl ? actEl.value : ''; - var actions = ['save_site', 'save_contacts', 'save_settings', 'update_album', 'set_image_meta', 'delete_image']; + var actions = ['save_site', 'save_contacts', 'save_settings', 'update_album', 'set_image_meta', 'delete_image', 'publish_album']; if (actions.indexOf(act) === -1) return; + if (act === 'publish_album' && !CAN_PUBLISH) { e.preventDefault(); notice('发布图集需编辑或管理员权限。', 'error'); return; } + if (act === 'publish_album' && Upload.running) { e.preventDefault(); notice('请等待当前上传完成后再发布。', 'error'); return; } + if (form.__ppSaving) { e.preventDefault(); return; } if (act === 'delete_image' && !confirm('删除这张图片及其文件?')) { e.preventDefault(); return; } e.preventDefault(); + form.__ppSaving = true; var btn = form.querySelector('button[type="submit"]'); if (btn) btn.disabled = true; var fd = new FormData(form); post('', fd).then(function (d) { if (d && d.ok) { - if (act === 'delete_image') { + if (act === 'publish_album') { + refreshAlbums(); + } else if (act === 'delete_image') { var pic = form.closest('.pp-img'); if (pic && pic.parentNode) pic.parentNode.removeChild(pic); } else if (act === 'update_album') { @@ -564,7 +741,8 @@ } notice((d && d.msg) ? d.msg : '已保存', (d && d.ok) ? 'success' : 'error'); if (btn) btn.disabled = false; - }).catch(function () { notice('网络/保存出错,请重试', 'error'); if (btn) btn.disabled = false; }); + form.__ppSaving = false; + }).catch(function () { notice('网络/保存出错,请重试', 'error'); if (btn) btn.disabled = false; form.__ppSaving = false; }); }); // 上次维护任务未完成:提示可继续 @@ -584,3 +762,4 @@ } }); })(); + diff --git a/usr/plugins/InfinityTime/panel.php b/usr/plugins/InfinityTime/panel.php index 81083a3..eb05a43 100644 --- a/usr/plugins/InfinityTime/panel.php +++ b/usr/plugins/InfinityTime/panel.php @@ -13,6 +13,9 @@ use TypechoPlugin\InfinityTime\Plugin; use TypechoPlugin\InfinityTime\Lib\ImageRepository; use TypechoPlugin\InfinityTime\Lib\MediaProcessor; +use TypechoPlugin\InfinityTime\Lib\AdminRepository; +use TypechoPlugin\InfinityTime\Lib\AdminSecurity; +use TypechoPlugin\InfinityTime\Lib\AdminWorkflow; $db = Db::get(); $user = User::alloc(); @@ -25,7 +28,7 @@ /* ---------------------------------- 工具函数 ---------------------------------- */ -function pp_reply(string $msg = '', string $type = 'success'): void +function pp_reply(string $msg = '', string $type = 'success', int $cid = 0): void { $options = Helper::options(); $url = Helper::url('InfinityTime/panel.php'); @@ -33,16 +36,19 @@ function pp_reply(string $msg = '', string $type = 'success'): void $sep = strpos($url, '?') === false ? '?' : '&'; $url .= $sep . 'notice=' . urlencode($msg) . '¬iceType=' . $type; } + if ($cid > 0) { $url .= (strpos($url, '?') === false ? '?' : '&') . 'append=' . $cid . '#upload'; } \Typecho\Response::getInstance()->setStatus(302); @header('Location: ' . $url); exit; } /** 供 AJAX 使用的 JSON 响应。 */ -function pp_reply_json(bool $ok, string $msg): void +function pp_reply_json(bool $ok, string $msg, array $data = [], int $status = 200): void { + http_response_code($status); header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['ok' => $ok, 'msg' => $msg], JSON_UNESCAPED_UNICODE); + header('Cache-Control: no-store'); + echo json_encode(['ok' => $ok, 'msg' => $msg] + $data, JSON_UNESCAPED_UNICODE); exit; } @@ -53,11 +59,17 @@ function pp_is_admin(): bool return $user->pass('administrator', true); } +function pp_can_publish(): bool +{ + global $user; + return $user->pass('editor', true); +} + /** 统一权限拒绝:AJAX 返回 JSON,普通请求跳回面板提示。 */ function pp_deny(string $msg): void { if (!empty($_POST['ajax']) || !empty($_GET['ajax'])) { - pp_reply_json(false, _t($msg)); + pp_reply_json(false, _t($msg), [], 403); } pp_reply(_t($msg), 'error'); } @@ -70,18 +82,12 @@ function pp_require_admin(): void } } -/** 当前用户能否编辑指定图集:管理员,或该图集的作者本人。 */ +/** 管理员与作者都必须指向真实的 InfinityTime 图集。 */ function pp_can_edit_cid(int $cid): bool { - global $user, $db, $prefix; - if ($cid <= 0) { - return false; - } - if (pp_is_admin()) { - return true; - } - $r = $db->fetchRow($db->select('authorId')->from($prefix . 'contents')->where('cid = ?', $cid)->limit(1)); - return $r && (int)$r['authorId'] === (int)$user->uid; + global $user; + $album = AdminRepository::album($cid, (int)$user->uid, pp_is_admin(), ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST'); + return $album !== null && ($album['status'] === 'draft' || pp_can_publish()); } /** 后台 CSRF token(绑定当前登录用户,同一会话内稳定)。 */ @@ -94,21 +100,21 @@ function pp_csrf_token(): string } } -/** 校验 CSRF:优先比对 token;缺失时回退到“非空且同源 referer”。 */ +/** 严格校验,不回退到 Referer;原生表单及维护任务同样适用。 */ function pp_csrf_check(): bool { - $t = pp_csrf_token(); - if ($t !== '' && isset($_POST['_']) && hash_equals($t, (string)$_POST['_'])) { - return true; - } - // AJAX 请求必须带有效 token(前端统一附带);token 不可用/原生提交时回退到同源 referer - if (!empty($_POST['ajax']) && $t !== '') { - return false; - } - $ref = (string)($_SERVER['HTTP_REFERER'] ?? ''); - $reqHost = (string)($_SERVER['HTTP_HOST'] ?? ''); - $refHost = $ref !== '' ? (string)parse_url($ref, PHP_URL_HOST) : ''; - return $refHost !== '' && strcasecmp($refHost, $reqHost) === 0; + return AdminSecurity::validMutation((string)($_SERVER['REQUEST_METHOD'] ?? ''), pp_csrf_token(), $_POST['_'] ?? null); +} + +function pp_csrf_input(): string +{ + return ''; +} + +function pp_panel_url(array $query = []): string +{ + $url = Helper::url('InfinityTime/panel.php'); + return $url . ($query ? (strpos($url, '?') === false ? '?' : '&') . http_build_query($query) : ''); } /** 清洗“关于介绍”里的 HTML:保留常规排版标签,去掉脚本/事件/危险协议。 */ @@ -265,35 +271,31 @@ function pp_exif_summary(array $exif): string /* ---------------------------------- AJAX 进度 ---------------------------------- */ -if (!empty($_GET['ajax'])) { - $job = (string)($_GET['job'] ?? ''); - // 写操作类 job 需要 CSRF token:否则可被 之类的 GET 请求触发 - if (in_array($job, ['rebuild', 'cleanup', 'resync'], true)) { - // 维护任务会批量改动全站文件:仅管理员 - if (!$user->pass('administrator', true)) { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['finished' => true, 'total' => 0, 'done' => 0, 'current' => '', 'failed' => 0, 'msg' => '需要管理员权限'], JSON_UNESCAPED_UNICODE); - exit; - } - $__t = pp_csrf_token(); - $__ok = $__t !== '' && isset($_GET['_']) && hash_equals($__t, (string)$_GET['_']); - if (!$__ok && $__t === '') { - // token 不可用时回退到同源 referer(GET 由前端同源 fetch 发起) - $__ref = (string)($_SERVER['HTTP_REFERER'] ?? ''); - $__refHost = $__ref !== '' ? (string)parse_url($__ref, PHP_URL_HOST) : ''; - $__ok = $__refHost !== '' && strcasecmp($__refHost, (string)($_SERVER['HTTP_HOST'] ?? '')) === 0; - } - if (!$__ok) { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['finished' => true, 'total' => 0, 'done' => 0, 'current' => '', 'failed' => 0, 'msg' => '安全校验失败,请刷新后台页面后重试'], JSON_UNESCAPED_UNICODE); - exit; - } - // 原子锁串行化维护状态,并与上传的落盘/入库临界区互斥。 - try { ImageRepository::lockMedia(); } catch (\Throwable $e) { - header('Content-Type: application/json; charset=utf-8'); - echo json_encode(['finished' => true, 'total' => 0, 'done' => 0, 'current' => '', 'failed' => 0, 'msg' => $e->getMessage()], JSON_UNESCAPED_UNICODE); - exit; - } +$ppMethod = (string)($_SERVER['REQUEST_METHOD'] ?? 'GET'); +$ppAction = is_string($_POST['action'] ?? null) ? $_POST['action'] : ''; +$ppMaintenance = $ppMethod === 'POST' && $ppAction === 'maintenance'; +if ($ppMethod === 'POST') { + if (!pp_csrf_check()) { + pp_reply_json(false, _t('安全校验失败,请刷新后台页面后重试'), [], 403); + } + // 锁覆盖权限检查、数据库及文件写入,避免并发丢失更新。 + try { ImageRepository::lockMedia(); } catch (\Throwable $e) { + pp_reply_json(false, $e->getMessage(), ['retryable' => true, 'finished' => true], 409); + } +} +if (!$ppMaintenance && in_array((string)($_GET['job'] ?? ''), ['rebuild', 'cleanup', 'resync'], true)) { + header('Allow: POST'); + pp_reply_json(false, '维护任务只接受 POST 请求', ['finished' => true], 405); +} + +if (!empty($_GET['ajax']) || $ppMaintenance) { + header('Cache-Control: no-store'); + $job = $ppMaintenance ? (string)($_POST['job'] ?? '') : (string)($_GET['job'] ?? ''); + if ($ppMaintenance && !in_array($job, ['rebuild', 'cleanup', 'resync'], true)) { + pp_reply_json(false, '未知维护任务', ['finished' => true], 400); + } + if ($ppMaintenance) { + pp_require_admin(); // 简单并发锁:同一时间只允许一个维护任务(不同管理员 90 秒内不能抢跑) $__uid = (int)($user->uid ?? 0); $__lockFile = pp_data_file() . '/job.lock'; @@ -465,158 +467,95 @@ function pp_exif_summary(array $exif): string } elseif ($job === 'albums_html') { // 局部刷新图集列表:只返回卡片 HTML,避免为刷新列表重新渲染整个后台页面 header('Content-Type: text/html; charset=utf-8'); - echo pp_render_albums_card(pp_albums($prefix, pp_is_admin() ? 0 : (int)$user->uid), $options); + echo pp_render_albums_card(pp_albums(), $options); exit; + } elseif ($job === 'album_status') { + $cid = (int)($_GET['cid'] ?? 0); + $album = AdminRepository::album($cid, (int)$user->uid, pp_is_admin()); + if (!$album) { pp_reply_json(false, '图集不存在或没有权限', [], 404); } + $counts = AdminRepository::counts([$cid]); + pp_reply_json(true, '', ['cid' => $cid, 'title' => (string)$album['title'], 'status' => $album['status'], 'count' => $counts[$cid] ?? 0]); } elseif ($job === 'album_images') { // 图集图片按需加载:展开某个图集时才拉取它的图片列表 header('Content-Type: text/html; charset=utf-8'); $cid = (int)($_GET['cid'] ?? 0); - if (!pp_can_edit_cid($cid)) { - echo '
没有权限查看该图集
'; + if (!AdminRepository::album($cid, (int)$user->uid, pp_is_admin())) { + http_response_code(403); + echo '
图集不存在或没有权限查看
'; exit; } - echo pp_render_album_thumbs($cid > 0 ? ImageRepository::rowsFor($cid) : []); + echo pp_render_album_thumbs($cid > 0 ? ImageRepository::rowsFor($cid) : [], pp_can_edit_cid($cid)); exit; } + if ($ppMaintenance && empty($_POST['ajax'])) { + pp_reply('维护任务已处理 ' . (int)($result['done'] ?? 0) . ' / ' . (int)($result['total'] ?? 0) . (!empty($result['finished']) ? ',已完成' : ';再次提交可继续')); + } header('Content-Type: application/json'); - if (!empty($result['finished'])) { @unlink(pp_data_file() . '/job.lock'); } + if ($ppMaintenance && !empty($result['finished'])) { @unlink(pp_data_file() . '/job.lock'); } echo json_encode($result, JSON_UNESCAPED_UNICODE); exit; } /* ---------------------------------- POST 处理 ---------------------------------- */ -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - // CSRF:优先校验 Typecho token;旧表单/无 token 时至少要求“非空且同源”的 referer - if (!pp_csrf_check()) { - pp_reply_json(false, _t('安全校验失败,请刷新后台页面后重试')); - } - $action = (string)($_POST['action'] ?? ''); - - if ($action === 'create_album') { - $ajax = !empty($_POST['ajax']); - $title = trim((string)($_POST['title'] ?? '')); - $address = trim((string)($_POST['address'] ?? '')); - $device = trim((string)($_POST['device'] ?? '')); - $tags = trim((string)($_POST['tags'] ?? '')); - if ($title === '') { - if ($ajax) { pp_reply_json(false, _t('请填写图集标题')); } - pp_reply(_t('请填写图集标题'), 'error'); - } - // When post_max_size is exceeded PHP drops the entire $_FILES array. - // Report that explicitly instead of silently returning to the panel. - if (empty($_FILES['files']) && !empty($_SERVER['CONTENT_LENGTH'])) { - if ($ajax) { pp_reply_json(false, _t('上传内容超过服务器 post_max_size 限制,请调大 post_max_size 后重试')); } - pp_reply(_t('上传内容超过服务器 post_max_size 限制,请调大 post_max_size 后重试'), 'error'); - } - if (empty($_FILES['files']['name'][0]) || !is_array($_FILES['files']['name'])) { - if ($ajax) { pp_reply_json(false, _t('请至少选择一张图片')); } - pp_reply(_t('请至少选择一张图片'), 'error'); - } - - $now = time(); - $slug = 'album-' . date('YmdHis', $now) . '-' . random_int(100, 999); - $cid = (int)$db->query($db->insert($prefix . 'contents')->rows([ - 'title' => $title, 'slug' => $slug, 'created' => $now, 'modified' => $now, - 'text' => '', 'authorId' => $user->uid, 'type' => 'post', 'status' => 'publish', - 'allowComment' => '0', 'allowPing' => '0', 'allowFeed' => '0', 'template' => '', 'password' => '', - ])); +if ($ppMethod === 'POST') { + $action = $ppAction; + $ajax = !empty($_POST['ajax']); - $imgs = []; - $firstExif = null; $index = 0; $fail = 0; - $quality = (int)Plugin::opt('infinitytimeQuality', Plugin::DEFAULT_QUALITY); - $thumbMax = (int)Plugin::opt('infinitytimeThumbMax', Plugin::DEFAULT_THUMB_MAX); - $maxWidth = (int)Plugin::opt('infinitytimeMaxWidth', Plugin::DEFAULT_MAX_WIDTH); - $fullQuality = (int)Plugin::opt('infinitytimeFullQuality', Plugin::DEFAULT_FULL_QUALITY); - $keep = (bool)Plugin::opt('infinitytimeKeepOriginal', Plugin::DEFAULT_KEEP_ORIGINAL); - $panoWidth = (int)Plugin::opt('infinitytimePanoWidth', Plugin::DEFAULT_PANO_WIDTH); - $panoQuality = (int)Plugin::opt('infinitytimePanoQuality', Plugin::DEFAULT_PANO_QUALITY); - - $count = count($_FILES['files']['name']); - // 逐图标题/描述(与 files[] 同序,后端据此写入每张图的 title/desc) - $postTitles = (array)($_POST['img_titles'] ?? []); - $postDescs = (array)($_POST['img_descs'] ?? []); - $uploadErr = 0; - $oversize = 0; - $overpx = 0; - $maxFileBytes = 64 * 1024 * 1024; // 单文件 64MB 上限 - $maxPixels = 20000; // 单边 20000px 上限 - for ($i = 0; $i < $count; $i++) { - $fe = (int)($_FILES['files']['error'][$i] ?? UPLOAD_ERR_NO_FILE); - if ($fe !== UPLOAD_ERR_OK) { - if ($uploadErr === 0) { - $uploadErr = $fe; - } - $fail++; - continue; + if (in_array($action, ['create_draft', 'upload_image', 'publish_album', 'create_album'], true)) { + try { + if ($action === 'create_draft') { + $draft = AdminWorkflow::draft((int)$user->uid, $_POST, (string)($_POST['operation_key'] ?? '')); + pp_reply_json(true, '草稿已保存', $draft); } - // 前置校验:超大文件 / 超大像素直接跳过,避免拖垮 GD 内存 - if ((int)($_FILES['files']['size'][$i] ?? 0) > $maxFileBytes) { - $oversize++; - $fail++; - continue; + $cid = (int)($_POST['cid'] ?? 0); + if ($action === 'publish_album') { + AdminWorkflow::publish($cid, (int)$user->uid, pp_is_admin(), pp_can_publish()); + if ($ajax) { pp_reply_json(true, '图集已发布', ['cid' => $cid, 'status' => 'publish']); } + pp_reply('图集已发布'); } - $__dim = @getimagesize((string)($_FILES['files']['tmp_name'][$i] ?? '')); - if (is_array($__dim) && (($__dim[0] ?? 0) > $maxPixels || ($__dim[1] ?? 0) > $maxPixels)) { - $overpx++; - $fail++; - continue; + if ($action === 'upload_image') { + $result = AdminWorkflow::upload($cid, (int)$user->uid, pp_is_admin(), (array)($_FILES['file'] ?? []), $_POST, (string)($_POST['item_key'] ?? ''), pp_can_publish()); + pp_reply_json(true, !empty($result['replayed']) ? '图片已保存(已恢复上传结果)' : '图片已保存', $result); } - $meta = ImageRepository::ingest([ - 'name' => $_FILES['files']['name'][$i], - 'tmp_name' => $_FILES['files']['tmp_name'][$i], - 'size' => $_FILES['files']['size'][$i] ?? 0, - 'error' => $_FILES['files']['error'][$i] ?? UPLOAD_ERR_OK, - ], ['quality' => $quality, 'thumb_max' => $thumbMax, 'max_width' => $maxWidth, 'pano_width' => $panoWidth, 'pano_quality' => $panoQuality, 'full_quality' => $fullQuality, 'keep_original' => $keep, 'address' => $address]); - if (!$meta) { - $fail++; - continue; + // 无 JavaScript 的原生回退:沿用草稿与幂等规则,每次处理有上限的一批。 + $key = (string)($_POST['operation_key'] ?? ''); + if (!AdminSecurity::validKey($key)) { throw new \InvalidArgumentException('上传标识无效,请刷新页面'); } + if (empty($_FILES['files']['name']) || !is_array($_FILES['files']['name'])) { + throw new \InvalidArgumentException('没有收到图片,请检查上传限制并重新选择文件'); } - $imgTitle = isset($postTitles[$i]) ? trim((string)$postTitles[$i]) : ''; - $imgDesc = isset($postDescs[$i]) ? trim((string)$postDescs[$i]) : ''; - $meta['title'] = $imgTitle; - $meta['desc'] = $imgDesc; - try { - $rowId = ImageRepository::insertRow($cid, $meta, $index); - if ($rowId <= 0) { throw new \RuntimeException('图片记录写入失败'); } - } catch (\Throwable $e) { - Plugin::log('create_album insertRow: ' . $e->getMessage()); - ImageRepository::$lastError = '图片记录写入失败,请稍后重试'; - $fail++; - continue; + if ($cid > 0) { AdminWorkflow::requireAlbum($cid, (int)$user->uid, pp_is_admin(), pp_can_publish()); } + else { + $draft = AdminWorkflow::draft((int)$user->uid, $_POST, $key); + $cid = (int)$draft['cid']; } - $imgs[] = $meta['full']; - if ($firstExif === null) { - $firstExif = $meta['exif']; + $saved = 0; $failed = 0; $errors = []; + $count = count($_FILES['files']['name']); + for ($i = 0; $i < min(50, $count); $i++) { + $file = []; + foreach (['name', 'tmp_name', 'size', 'error'] as $field) { $file[$field] = $_FILES['files'][$field][$i] ?? null; } + try { + AdminWorkflow::upload($cid, (int)$user->uid, pp_is_admin(), $file, [ + 'title' => $_POST['img_titles'][$i] ?? '', 'desc' => $_POST['img_descs'][$i] ?? '', + 'address' => $_POST['address'] ?? '', + ], hash('sha256', $key . ':' . $i), pp_can_publish()); + $saved++; + } catch (\Throwable $e) { + $failed++; + $errors[] = (string)$file['name'] . ':' . $e->getMessage(); + } } - $index++; - } - - if (empty($imgs)) { - ImageRepository::removeFor($cid); - $db->query($db->delete($prefix . 'contents')->where('cid = ?', $cid)); - $reason = trim((string)(ImageRepository::$lastError ?? '')); - $msg = $uploadErr !== 0 - ? pp_upload_error($uploadErr) - : ($oversize > 0 - ? sprintf(_t('有 %d 张图片超过 64MB 上限,已跳过'), $oversize) - : ($overpx > 0 - ? sprintf(_t('有 %d 张图片单边超过 20000px 上限,已跳过'), $overpx) - : ($reason !== '' ? $reason : _t('没有图片成功入库(可能格式不支持或缺少转换工具)')))); - if ($ajax) { pp_reply_json(false, $msg); } - pp_reply($msg, 'error'); - } - - ImageRepository::syncPostFields($cid); - pp_set_field($cid, 'device', $device !== '' ? $device : trim((string)($firstExif['make'] ?? '') . ' ' . (string)($firstExif['model'] ?? ''))); - pp_set_field($cid, 'location', $address); - if ($tags !== '') { - pp_set_field($cid, 'tags', $tags); + $failed += max(0, $count - 50); + $msg = '图集 #' . $cid . ' 已保存 ' . $saved . ' 张图片。新图集保留为草稿,请在图集列表中确认发布。'; + if ($failed) { $msg .= '失败 ' . $failed . ' 张;成功项目已保留。' . implode(';', array_slice($errors, 0, 3)); } + if ($ajax) { pp_reply_json(!$failed, $msg, ['cid' => $cid, 'saved' => $saved, 'failed' => $failed]); } + pp_reply($msg, $failed ? 'error' : 'success', $cid); + } catch (\Throwable $e) { + Plugin::log('admin upload workflow: ' . $e->getMessage()); + if ($ajax) { pp_reply_json(false, $e->getMessage(), ['retryable' => $e instanceof \RuntimeException], $e instanceof \DomainException ? 403 : 400); } + pp_reply($e->getMessage(), 'error'); } - $msg = sprintf(_t('已发布图集「%s」,共 %d 张图片'), $title, count($imgs)); - if ($ajax) { pp_reply_json(true, $msg); } - pp_reply($msg); } if ($action === 'update_album') { @@ -643,7 +582,7 @@ function pp_exif_summary(array $exif): string $desc = trim((string)($_POST['desc'] ?? '')); $addr = trim((string)($_POST['address'] ?? '')); if ($rowId > 0) { - $row = $db->fetchRow($db->select('cid')->from(ImageRepository::table())->where('id = ?', $rowId)->limit(1)); + $row = AdminRepository::readRow($db->select('cid')->from(ImageRepository::table())->where('id = ?', $rowId)->limit(1), true); if (!$row || !pp_can_edit_cid((int)($row['cid'] ?? 0))) { pp_deny('没有权限修改该图片'); } ImageRepository::setImageMeta($rowId, $title, $desc, $addr); if ($row && (int)$row['cid'] > 0) { @@ -660,7 +599,12 @@ function pp_exif_summary(array $exif): string $ajax = !empty($_POST['ajax']); $cid = (int)($_POST['cid'] ?? 0); if (!pp_can_edit_cid($cid)) { pp_deny('没有权限修改该图集'); } - $rowIds = (array)($_POST['rowIds'] ?? []); + $rowIds = array_map('intval', (array)($_POST['rowIds'] ?? [])); + $existing = array_map(static function ($row) { return (int)$row['id']; }, AdminRepository::readAll($db->select('id')->from(ImageRepository::table())->where('cid = ?', $cid), true)); + $requested = $rowIds; sort($requested); sort($existing); + if ($requested !== $existing || count($rowIds) !== count(array_unique($rowIds))) { + pp_deny('图片列表已变化或包含其他图集图片,请刷新后重试'); + } $order = 0; foreach ($rowIds as $rid) { $rid = (int)$rid; @@ -681,7 +625,7 @@ function pp_exif_summary(array $exif): string $ajax = !empty($_POST['ajax']); $rowId = (int)($_POST['rowId'] ?? 0); if ($rowId > 0) { - $row = $db->fetchRow($db->select()->from(ImageRepository::table())->where('id = ?', $rowId)->limit(1)); + $row = AdminRepository::readRow($db->select()->from(ImageRepository::table())->where('id = ?', $rowId)->limit(1), true); if (!$row || !pp_can_edit_cid((int)($row['cid'] ?? 0))) { pp_deny('没有权限删除该图片'); } ImageRepository::unlinkFiles($row['original'], $row['full'], $row['mid'] ?? null, $row['avif'] ?? null, $row['mid_avif'] ?? null, $row['thumb']); $db->query($db->delete(ImageRepository::table())->where('id = ?', $rowId)); @@ -697,7 +641,7 @@ function pp_exif_summary(array $exif): string pp_require_admin(); // 一键清理全站非插件文章:仅管理员 // 汇总“本插件发布的图集”cid:有 img 自定义字段 或 在 infinitytime_images 表里 $pluginCids = []; - foreach ($db->fetchAll($db->select('cid')->from($prefix . 'fields')->where('name = ?', 'img')) as $f) { + foreach ($db->fetchAll($db->select('cid')->from($prefix . 'fields')->where('name IN (?, ?)', 'img', AdminRepository::MARKER)) as $f) { $pluginCids[(int)$f['cid']] = true; } foreach ($db->fetchAll($db->select('cid')->from(ImageRepository::table())) as $f) { @@ -858,84 +802,72 @@ function pp_exif_summary(array $exif): string /* ---------------------------------- 数据准备 ---------------------------------- */ -function pp_albums(string $prefix, int $authorId = 0): array +function pp_albums(): array { - $db = Db::get(); - $q = $db->select('cid', 'title')->from($prefix . 'contents')->where('type = ?', 'post')->where('status = ?', 'publish'); - if ($authorId > 0) { - // 贡献者只看到自己的图集(写操作已按作者校验,读取同样隔离) - $q->where('authorId = ?', $authorId); - } - $rows = $db->fetchAll($q->order('created', Db::SORT_DESC)); - if (!$rows) { - return []; - } - - // 一次性取回所有图集的 img/device/tags/location 字段,避免 N+1 查询 - $cids = array_map(fn($r) => (int)$r['cid'], $rows); - $markers = implode(',', array_fill(0, count($cids), '?')); - $all = $db->fetchAll( - $db->select('cid', 'name', 'str_value')->from($prefix . 'fields') - ->where('cid IN (' . $markers . ')', ...$cids) - ->where("name IN ('img','device','tags','location')") - ); - $byCid = []; - foreach ($all as $f) { - $byCid[(int)$f['cid']][$f['name']] = $f['str_value']; - } - - $out = []; - foreach ($rows as $r) { - $cid = (int)$r['cid']; - $img = $byCid[$cid]['img'] ?? ''; - if ($img !== '') { - $r['img_count'] = count(array_filter(explode("\n", $img))); - $r['device'] = $byCid[$cid]['device'] ?? ''; - $r['tags'] = $byCid[$cid]['tags'] ?? ''; - $r['location'] = $byCid[$cid]['location'] ?? ''; - $out[] = $r; - } - } - return $out; + global $user; + return AdminRepository::page((int)$user->uid, pp_is_admin(), max(1, (int)($_GET['page'] ?? 1)), is_string($_GET['q'] ?? null) ? $_GET['q'] : ''); } -/** 渲染「已发布图集」卡片:面板主视图与 AJAX 局部刷新共用。 */ -function pp_render_albums_card(array $albums, $options): string +/** 仅渲染当前页的图集摘要;图片列表按需读取。 */ +function pp_render_albums_card(array $result, $options): string { + $albums = $result['items']; + $page = (int)$result['page']; + $query = (string)$result['query']; ob_start(); ?> -
-

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

+
+

图集 共 组 · 每页 组

+
暂无图集。
- -
+ +
> - - ( 张) - + + ( 张)·
- 共 张 + 共 张 · 拖动图片可调整顺序 - 前台查看 -
+ + 追加图片 + 查看图片 / 无脚本编辑 + + + + + + +
+ +
+ - +
+ 已发布图集只读;修改需编辑或管理员权限
- +
编辑图集信息 -
+ + - +
-
+
@@ -943,18 +875,25 @@ function pp_render_albums_card(array $albums, $options): string
- - -
展开后加载图片…
+ +
展开后加载图片…
'; } + ?>
+
@@ -963,7 +902,9 @@ function pp_render_album_thumbs(array $images): string
×
+
+ @@ -975,10 +916,12 @@ function pp_render_album_thumbs(array $images): string
+
+ request->get('notice'); $noticeType = $options->request->get('noticeType', 'success'); -$albums = pp_albums($prefix, pp_is_admin() ? 0 : (int)$user->uid); +$albums = pp_albums(); $quality = (int)Plugin::opt('infinitytimeQuality', Plugin::DEFAULT_QUALITY); $thumbMax = (int)Plugin::opt('infinitytimeThumbMax', Plugin::DEFAULT_THUMB_MAX); $maxWidth = (int)Plugin::opt('infinitytimeMaxWidth', Plugin::DEFAULT_MAX_WIDTH); @@ -1006,6 +949,17 @@ function pp_render_album_thumbs(array $images): string if (strpos($ppRel, '/') !== 0) { $ppRel = '/usr/plugins/InfinityTime'; } $ppPluginWeb = rtrim((string)$options->siteUrl, '/') . $ppRel; $ppCsrf = pp_csrf_token(); +$ppUploadTarget = null; +if (!empty($_GET['append'])) { + $ppUploadTarget = AdminRepository::album((int)$_GET['append'], (int)$user->uid, pp_is_admin()); + if ($ppUploadTarget) { + $counts = AdminRepository::counts([(int)$ppUploadTarget['cid']]); + $ppUploadTarget['count'] = $counts[(int)$ppUploadTarget['cid']] ?? 0; + } else { + $notice = '图集不存在或没有权限追加图片'; $noticeType = 'error'; + } +} +$ppOperationKey = bin2hex(random_bytes(16)); $ppJobState = pp_read_json(pp_data_file() . '/job.json'); /* ---------------------------------- 视图 ---------------------------------- */ @@ -1028,7 +982,7 @@ function pp_render_album_thumbs(array $images): string

InfinityTime 图片分享