From ac520d8ae54906d946b58dbfef209eaed0289ff9 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 7 Jul 2026 10:55:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(mindspace-public):=20=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E9=A1=B5=E5=8C=BA=E5=88=86=E4=BD=9C=E8=80=85=E4=B8=8E=E8=AE=BF?= =?UTF-8?q?=E5=AE=A2=E8=A7=86=E5=9B=BE=20+=20=E5=9B=BE=E7=89=87=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E8=87=AA=E5=8A=A8=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作区直链 /MindSpace//public/*.html 此前对所有人(含匿名访客、 转发链接收到的其他登录用户)展示完全相同的悬浮操作按钮,导致非作者也能 看到"发布 Plaza"入口——而 Plaza 发布会把内容归属到操作者自己的账号下, 必须只对作者开放。 - server.mjs: serveUserPublishFile 用现有 session/cookie 鉴权判断访问者是 否等于 URL 中的 ownerKey,结果透传给 sendPublishFile;该响应内容因人 而异,显式加 Cache-Control: private, no-store(原来未设置任何缓存头) - mindspace-public-share-widget.mjs: injectPublicFileShareButton 新增 isOwner 参数(默认 true 保持兼容),非作者时隐藏"发布 Plaza"按钮与确认 弹窗,"保存长图"/"公开分享"对所有访客保留 - mindspace-public-delivery.mjs: 透传 isOwner,并注入图片重试脚本 - mindspace-public-image-retry.mjs(新增): 页面级 onerror 之前用捕获阶段 监听拦截 mindspace 资产图片的加载失败,带退避自动重试 3 次,避免刚生成 页面时的资产物化竞态被"永久占位符"放大成显示故障 本地起服务用 owner / 非 owner 已登录用户 / 匿名访客三种身份实测验证。 Co-authored-by: Cursor --- mindspace-public-delivery.mjs | 9 +++- mindspace-public-delivery.test.mjs | 36 +++++++++++-- mindspace-public-image-retry.mjs | 67 +++++++++++++++++++++++ mindspace-public-image-retry.test.mjs | 37 +++++++++++++ mindspace-public-share-widget.mjs | 73 +++++++++++++++----------- mindspace-public-share-widget.test.mjs | 26 +++++++++ server.mjs | 19 ++++++- 7 files changed, 227 insertions(+), 40 deletions(-) create mode 100644 mindspace-public-image-retry.mjs create mode 100644 mindspace-public-image-retry.test.mjs diff --git a/mindspace-public-delivery.mjs b/mindspace-public-delivery.mjs index 9566ffd..8c0654a 100644 --- a/mindspace-public-delivery.mjs +++ b/mindspace-public-delivery.mjs @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { preparePublishedPlatformBrand } from './mindspace-page-tag.mjs'; +import { injectPublicImageRetryScript } from './mindspace-public-image-retry.mjs'; const INLINE_SCRIPT_PATTERN = /]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi; @@ -48,6 +49,7 @@ export async function handleMindSpaceLongImageDownload({ export function decorateMindSpacePublishedHtml({ html, embed = false, + isOwner = true, context, htmlFilePath = '', fileExists = fs.existsSync, @@ -82,9 +84,14 @@ export function decorateMindSpacePublishedHtml({ nextHtml = injectWechatShareBridge(nextHtml, { pageUrl: context.pageUrl }); } const shareInjection = !embed - ? injectPublicFileShareButton(nextHtml) + ? injectPublicFileShareButton(nextHtml, { isOwner }) : { html: nextHtml, scriptHashes: [] }; nextHtml = shareInjection.html; + // Retries transient asset-download failures client-side before a page's own onerror + // permanently swaps the for a placeholder. See mindspace-public-image-retry.mjs. + if (!embed) { + nextHtml = injectPublicImageRetryScript(nextHtml).html; + } const scriptHashes = collectInlineScriptHashes(nextHtml); return { html: nextHtml, diff --git a/mindspace-public-delivery.test.mjs b/mindspace-public-delivery.test.mjs index 2b7a57e..1182711 100644 --- a/mindspace-public-delivery.test.mjs +++ b/mindspace-public-delivery.test.mjs @@ -97,6 +97,7 @@ test('handleMindSpaceLongImageDownload falls back to local html path without pag test('decorateMindSpacePublishedHtml returns decorated html and csp', () => { const pageScript = "document.body.dataset.ready='1'"; const shareScript = "document.body.dataset.share='1'"; + let sharedIsOwner; const result = decorateMindSpacePublishedHtml({ html: `demo`, embed: false, @@ -110,10 +111,13 @@ test('decorateMindSpacePublishedHtml returns decorated html and csp', () => { preparePublicationHtmlForEmbed: (value) => value, injectOgTags: (value, meta) => `${value}`, injectWechatShareBridge: (value, meta) => `${value}`, - injectPublicFileShareButton: (value) => ({ - html: value.replace('', ``), - scriptHashes: ['ignored'], - }), + injectPublicFileShareButton: (value, options) => { + sharedIsOwner = options?.isOwner; + return { + html: value.replace('', ``), + scriptHashes: ['ignored'], + }; + }, publishedPageCsp: (_value, options) => JSON.stringify(options), isWechatUserAgent: () => true, }); @@ -121,12 +125,34 @@ test('decorateMindSpacePublishedHtml returns decorated html and csp', () => { assert.match(result.html, /og:/); assert.match(result.html, /wechat:/); assert.match(result.html, /dataset\.share/); + assert.match(result.html, /data-mindspace-image-retry="1"/); assert.equal(result.allowEmbedFrame, false); + assert.equal(sharedIsOwner, true); const options = JSON.parse(result.csp); assert.equal(options.embed, false); assert.equal(options.wechatShare, true); assert.deepEqual(options.scriptHashes, collectInlineScriptHashes(result.html)); - assert.equal(options.scriptHashes.length, 2); + assert.equal(options.scriptHashes.length, 3); +}); + +test('decorateMindSpacePublishedHtml forwards isOwner=false to the share button injector', () => { + let sharedIsOwner; + decorateMindSpacePublishedHtml({ + html: 'demo', + embed: false, + isOwner: false, + context: { origin: '', pageUrl: '', pageDirUrl: '', fallbackImageUrl: '' }, + preparePublicationHtmlForEmbed: (value) => value, + injectOgTags: (value) => value, + injectWechatShareBridge: (value) => value, + injectPublicFileShareButton: (value, options) => { + sharedIsOwner = options?.isOwner; + return { html: value, scriptHashes: [] }; + }, + publishedPageCsp: (_value, options) => JSON.stringify(options), + isWechatUserAgent: () => false, + }); + assert.equal(sharedIsOwner, false); }); test('decorateMindSpacePublishedHtml supports embed mode without share injection', () => { diff --git a/mindspace-public-image-retry.mjs b/mindspace-public-image-retry.mjs new file mode 100644 index 0000000..ff11587 --- /dev/null +++ b/mindspace-public-image-retry.mjs @@ -0,0 +1,67 @@ +import crypto from 'node:crypto'; + +/** + * Public MindSpace pages (workspace `/MindSpace//public/*.html`) reference images via + * `/api/mindspace/v1/assets/:id/download`, which is authorized per-request (owner cookie, signed + * `public_token`, or `/public/` referrer). Right after an agent generates a page, the very first + * image request can occasionally race asset materialization and return a transient 401/404/5xx. + * + * Generated pages usually ship a page-specific `onerror` fallback that permanently swaps the + * `` for a static placeholder — so a single transient failure becomes a permanent "broken + * image" until the visitor manually reloads. This script intercepts image load failures for + * MindSpace asset URLs (any page, any author) and retries a few times with backoff BEFORE the + * page's own `onerror` gets a chance to run; only once retries are exhausted does it let the + * original fallback take over. + */ +const IMAGE_RETRY_SCRIPT = `(function(){ +var MAX_RETRIES=3; +var BASE_DELAY_MS=900; +var ASSET_PATTERN=/\\/api\\/mindspace\\/v1\\/assets\\/[^/]+\\/download/; +var retries=new WeakMap(); +document.addEventListener('error',function(event){ +var img=event.target; +if(!img||img.tagName!=='IMG')return; +var src=img.getAttribute('src')||''; +if(!ASSET_PATTERN.test(src))return; +var count=retries.get(img)||0; +if(count>=MAX_RETRIES)return; +event.stopImmediatePropagation(); +retries.set(img,count+1); +setTimeout(function(){ +try{ +var url=new URL(src,location.href); +url.searchParams.set('_retry',String(count+1)); +img.src=url.toString(); +}catch(e){ +img.src=src; +} +},BASE_DELAY_MS*(count+1)); +},true); +})();`; + +const IMAGE_RETRY_SCRIPT_HASH = crypto.createHash('sha256').update(IMAGE_RETRY_SCRIPT).digest('base64'); + +const MARKER_ATTR = 'data-mindspace-image-retry'; + +export function injectPublicImageRetryScript(html) { + const source = String(html ?? ''); + if (!source || source.includes(MARKER_ATTR)) { + return { html: source, scriptHashes: [] }; + } + const markup = ``; + if (/<\/body>/i.test(source)) { + return { + html: source.replace(/<\/body>/i, `${markup}`), + scriptHashes: [IMAGE_RETRY_SCRIPT_HASH], + }; + } + return { + html: `${source}${markup}`, + scriptHashes: [IMAGE_RETRY_SCRIPT_HASH], + }; +} + +export const publicImageRetryInternals = { + IMAGE_RETRY_SCRIPT_HASH, + MARKER_ATTR, +}; diff --git a/mindspace-public-image-retry.test.mjs b/mindspace-public-image-retry.test.mjs new file mode 100644 index 0000000..c4e5495 --- /dev/null +++ b/mindspace-public-image-retry.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + injectPublicImageRetryScript, + publicImageRetryInternals, +} from './mindspace-public-image-retry.mjs'; + +test('injectPublicImageRetryScript injects retry script before ', () => { + const result = injectPublicImageRetryScript('

Hi

'); + assert.match(result.html, /data-mindspace-image-retry="1"/); + assert.match(result.html, /addEventListener\('error'/); + assert.match(result.html, /mindspace\\\/v1\\\/assets/); + assert.match(result.html, /stopImmediatePropagation/); + assert.equal(result.html.indexOf('data-mindspace-image-retry'), result.html.lastIndexOf('data-mindspace-image-retry')); + assert.match(result.html, /