fix(mindspace-public): 公开页区分作者与访客视图 + 图片加载自动重试

工作区直链 /MindSpace/<userId>/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 <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-07 10:55:49 +08:00
parent b4171ca268
commit ac520d8ae5
7 changed files with 227 additions and 40 deletions
+8 -1
View File
@@ -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 = /<script\b(?![^>]*\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 <img> for a placeholder. See mindspace-public-image-retry.mjs.
if (!embed) {
nextHtml = injectPublicImageRetryScript(nextHtml).html;
}
const scriptHashes = collectInlineScriptHashes(nextHtml);
return {
html: nextHtml,
+31 -5
View File
@@ -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: `<html><body><script>${pageScript}</script>demo</body></html>`,
embed: false,
@@ -110,10 +111,13 @@ test('decorateMindSpacePublishedHtml returns decorated html and csp', () => {
preparePublicationHtmlForEmbed: (value) => value,
injectOgTags: (value, meta) => `${value}<!--og:${meta.pageUrl}-->`,
injectWechatShareBridge: (value, meta) => `${value}<!--wechat:${meta.pageUrl}-->`,
injectPublicFileShareButton: (value) => ({
html: value.replace('</body>', `<script>${shareScript}</script></body>`),
scriptHashes: ['ignored'],
}),
injectPublicFileShareButton: (value, options) => {
sharedIsOwner = options?.isOwner;
return {
html: value.replace('</body>', `<script>${shareScript}</script></body>`),
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: '<html><body>demo</body></html>',
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', () => {
+67
View File
@@ -0,0 +1,67 @@
import crypto from 'node:crypto';
/**
* Public MindSpace pages (workspace `/MindSpace/<userId>/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
* `<img>` 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 = `<script ${MARKER_ATTR}="1">${IMAGE_RETRY_SCRIPT}</script>`;
if (/<\/body>/i.test(source)) {
return {
html: source.replace(/<\/body>/i, `${markup}</body>`),
scriptHashes: [IMAGE_RETRY_SCRIPT_HASH],
};
}
return {
html: `${source}${markup}`,
scriptHashes: [IMAGE_RETRY_SCRIPT_HASH],
};
}
export const publicImageRetryInternals = {
IMAGE_RETRY_SCRIPT_HASH,
MARKER_ATTR,
};
+37
View File
@@ -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 </body>', () => {
const result = injectPublicImageRetryScript('<!doctype html><html><body><h1>Hi</h1></body></html>');
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, /<script data-mindspace-image-retry="1">[\s\S]*<\/script><\/body>/);
assert.equal(result.scriptHashes.length, 1);
assert.equal(result.scriptHashes[0], publicImageRetryInternals.IMAGE_RETRY_SCRIPT_HASH);
});
test('injectPublicImageRetryScript appends when no closing body tag exists', () => {
const result = injectPublicImageRetryScript('<div>fragment</div>');
assert.match(result.html, /^<div>fragment<\/div><script data-mindspace-image-retry="1">/);
assert.equal(result.scriptHashes.length, 1);
});
test('injectPublicImageRetryScript is idempotent (skips if already injected)', () => {
const first = injectPublicImageRetryScript('<html><body>x</body></html>');
const second = injectPublicImageRetryScript(first.html);
assert.equal(second.html, first.html);
assert.equal(second.scriptHashes.length, 0);
});
test('injectPublicImageRetryScript handles empty input safely', () => {
const result = injectPublicImageRetryScript('');
assert.equal(result.html, '');
assert.equal(result.scriptHashes.length, 0);
});
+41 -32
View File
@@ -109,39 +109,17 @@ const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto
.update(PUBLIC_FILE_SHARE_SCRIPT)
.digest('base64');
export function injectPublicFileShareButton(html) {
export function injectPublicFileShareButton(html, { isOwner = true } = {}) {
const source = String(html ?? '');
if (!source || source.includes('data-mindspace-public-share')) {
return { html: source, scriptHashes: [] };
}
const markup = `
<style id="mindspace-public-share-style">
[data-mindspace-public-share]{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;gap:6px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share] div[data-mindspace-public-share-actions]{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
[data-mindspace-public-share] button{border:0;border-radius:999px;padding:10px 15px;background:#2f6f57;color:#fff;font-size:14px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.18);cursor:pointer}
[data-mindspace-public-share] button[data-action="capture"]{background:#8f6b2f}
[data-mindspace-public-share] button[data-action="plaza"]{background:#5a4a7b}
[data-mindspace-public-share] button:active{transform:translateY(1px)}
[data-mindspace-public-share] button:disabled{opacity:.65;cursor:wait}
[data-mindspace-public-share] small{min-height:18px;max-width:220px;border-radius:999px;padding:4px 9px;background:rgba(255,255,255,.92);color:#245845;font-size:12px;text-align:right;box-shadow:0 4px 14px rgba(0,0,0,.1)}
[data-mindspace-public-share] small:empty{display:none}
[data-mindspace-public-share] small.is-error{color:#8b2d20}
[data-mindspace-public-share-dialog]{position:fixed;inset:0;z-index:2147483646;display:grid;place-items:center;padding:20px;background:rgba(7,12,10,.72);backdrop-filter:blur(8px)}
[data-mindspace-public-share-dialog][hidden]{display:none!important}
[data-mindspace-public-share-dialog-panel]{width:min(360px,100%);padding:24px;border-radius:22px;background:rgba(18,24,32,.96) !important;border:1px solid rgba(255,255,255,.12);box-shadow:0 24px 80px rgba(0,0,0,.38);text-align:center;color:#fff !important;color-scheme:dark;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share-dialog-panel] h3{margin:0 0 10px;font-size:18px;font-weight:600;color:#fff !important}
[data-mindspace-public-share-dialog-panel] p{margin:0 0 16px;font-size:14px;line-height:1.5;color:rgba(255,255,255,.86) !important}
[data-mindspace-public-share-dialog-panel] [data-plaza-view][hidden]{display:none!important}
[data-mindspace-public-share-dialog-actions]{display:flex;justify-content:center;gap:10px;flex-wrap:wrap}
[data-mindspace-public-share-dialog-actions] button{border-radius:999px;padding:8px 18px;font:inherit;font-size:14px;cursor:pointer;box-shadow:none}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-cancel"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-already-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-success-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-error-close"]{border:1px solid rgba(255,255,255,.18) !important;background:rgba(255,255,255,.08) !important;color:#fff !important}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-confirm"]{border:1px solid rgba(47,111,87,.35) !important;background:#2f6f57 !important;color:#fff !important;font-weight:600}
[data-mindspace-public-share-dialog-link]{display:inline-block;margin:0 0 14px;font-size:14px;color:#8fd4b4 !important;text-decoration:underline}
[data-mindspace-public-share-dialog-link][hidden]{display:none!important}
@media(max-width:640px){[data-mindspace-public-share]{right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px))}[data-mindspace-public-share] button{padding:9px 13px;font-size:13px}}
</style>
<div data-mindspace-public-share>
<div data-mindspace-public-share-dialog hidden>
// REGRESSION GUARD: mindspace-public-owner-vs-visitor — the "发布 Plaza" entry mutates content
// ownership (publishes the author's page under the visitor's own Plaza account), so it must only
// render for the page owner. Anyone else opening this URL (share link, forwarded chat link) is a
// visitor and must NOT see it. Everything else (长图 / 分享) stays available to all viewers.
const plazaDialogMarkup = isOwner
? ` <div data-mindspace-public-share-dialog hidden>
<div data-mindspace-public-share-dialog-panel role="dialog" aria-modal="true" aria-labelledby="mindspace-public-plaza-title">
<div data-plaza-view="confirm">
<h3 id="mindspace-public-plaza-title">发布到 Plaza</h3>
@@ -180,10 +158,41 @@ export function injectPublicFileShareButton(html) {
</div>
</div>
</div>
<small aria-live="polite"></small>
`
: '';
const plazaButtonMarkup = isOwner
? ' <button type="button" data-action="plaza">发布 Plaza</button>\n'
: '';
const markup = `
<style id="mindspace-public-share-style">
[data-mindspace-public-share]{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;gap:6px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share] div[data-mindspace-public-share-actions]{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
[data-mindspace-public-share] button{border:0;border-radius:999px;padding:10px 15px;background:#2f6f57;color:#fff;font-size:14px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.18);cursor:pointer}
[data-mindspace-public-share] button[data-action="capture"]{background:#8f6b2f}
[data-mindspace-public-share] button[data-action="plaza"]{background:#5a4a7b}
[data-mindspace-public-share] button:active{transform:translateY(1px)}
[data-mindspace-public-share] button:disabled{opacity:.65;cursor:wait}
[data-mindspace-public-share] small{min-height:18px;max-width:220px;border-radius:999px;padding:4px 9px;background:rgba(255,255,255,.92);color:#245845;font-size:12px;text-align:right;box-shadow:0 4px 14px rgba(0,0,0,.1)}
[data-mindspace-public-share] small:empty{display:none}
[data-mindspace-public-share] small.is-error{color:#8b2d20}
[data-mindspace-public-share-dialog]{position:fixed;inset:0;z-index:2147483646;display:grid;place-items:center;padding:20px;background:rgba(7,12,10,.72);backdrop-filter:blur(8px)}
[data-mindspace-public-share-dialog][hidden]{display:none!important}
[data-mindspace-public-share-dialog-panel]{width:min(360px,100%);padding:24px;border-radius:22px;background:rgba(18,24,32,.96) !important;border:1px solid rgba(255,255,255,.12);box-shadow:0 24px 80px rgba(0,0,0,.38);text-align:center;color:#fff !important;color-scheme:dark;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share-dialog-panel] h3{margin:0 0 10px;font-size:18px;font-weight:600;color:#fff !important}
[data-mindspace-public-share-dialog-panel] p{margin:0 0 16px;font-size:14px;line-height:1.5;color:rgba(255,255,255,.86) !important}
[data-mindspace-public-share-dialog-panel] [data-plaza-view][hidden]{display:none!important}
[data-mindspace-public-share-dialog-actions]{display:flex;justify-content:center;gap:10px;flex-wrap:wrap}
[data-mindspace-public-share-dialog-actions] button{border-radius:999px;padding:8px 18px;font:inherit;font-size:14px;cursor:pointer;box-shadow:none}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-cancel"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-already-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-success-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-error-close"]{border:1px solid rgba(255,255,255,.18) !important;background:rgba(255,255,255,.08) !important;color:#fff !important}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-confirm"]{border:1px solid rgba(47,111,87,.35) !important;background:#2f6f57 !important;color:#fff !important;font-weight:600}
[data-mindspace-public-share-dialog-link]{display:inline-block;margin:0 0 14px;font-size:14px;color:#8fd4b4 !important;text-decoration:underline}
[data-mindspace-public-share-dialog-link][hidden]{display:none!important}
@media(max-width:640px){[data-mindspace-public-share]{right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px))}[data-mindspace-public-share] button{padding:9px 13px;font-size:13px}}
</style>
<div data-mindspace-public-share>
${plazaDialogMarkup} <small aria-live="polite"></small>
<div data-mindspace-public-share-actions>
<button type="button" data-action="plaza">发布 Plaza</button>
<button type="button" data-action="capture">保存长图</button>
${plazaButtonMarkup} <button type="button" data-action="capture">保存长图</button>
<button type="button" data-action="share">公开分享</button>
</div>
</div>
+26
View File
@@ -18,3 +18,29 @@ test('injectPublicFileShareButton adds plaza entry and confirm dialog', () => {
assert.match(result.html, /ALREADY_PUBLISHED/);
assert.equal(result.scriptHashes.length, 1);
});
test('injectPublicFileShareButton hides Plaza entry and dialog for non-owner viewers', () => {
const result = injectPublicFileShareButton(
'<!doctype html><html><body><h1>Hi</h1></body></html>',
{ isOwner: false },
);
assert.doesNotMatch(result.html, /<button type="button" data-action="plaza">/);
assert.doesNotMatch(result.html, /发布到 Plaza/);
assert.doesNotMatch(result.html, /<button type="button" data-action="plaza-confirm">/);
assert.doesNotMatch(result.html, /<div data-mindspace-public-share-dialog/);
assert.doesNotMatch(result.html, /data-mindspace-public-share-dialog-panel role="dialog"/);
// Non-Plaza actions must remain available to visitors.
assert.match(result.html, /data-action="capture"/);
assert.match(result.html, /data-action="share"/);
assert.match(result.html, /公开分享/);
assert.equal(result.scriptHashes.length, 1);
});
test('injectPublicFileShareButton keeps Plaza entry when isOwner explicitly true', () => {
const result = injectPublicFileShareButton(
'<!doctype html><html><body><h1>Hi</h1></body></html>',
{ isOwner: true },
);
assert.match(result.html, /data-action="plaza"/);
assert.match(result.html, /data-mindspace-public-share-dialog/);
});
+17 -2
View File
@@ -5756,7 +5756,7 @@ async function ensurePublicHtmlPrivateAssetsMaterialized(filePath, html) {
return result.changed ? result.html : html;
}
async function sendPublishFile(req, res, filePath) {
async function sendPublishFile(req, res, filePath, { isOwner = true } = {}) {
if (!filePath.toLowerCase().endsWith('.html')) {
res.sendFile(filePath, (err) => {
if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' });
@@ -5788,6 +5788,7 @@ async function sendPublishFile(req, res, filePath) {
const decorated = decorateMindSpacePublishedHtml({
html,
embed,
isOwner,
context,
htmlFilePath: filePath,
userAgent: req.get('user-agent') || '',
@@ -5804,6 +5805,10 @@ async function sendPublishFile(req, res, filePath) {
}
res.set('Content-Security-Policy', decorated.csp);
res.set('Content-Type', 'text/html; charset=utf-8');
// REGRESSION GUARD: mindspace-public-owner-vs-visitor — this HTML now varies by viewer
// (owner sees the Plaza entry, visitors don't), so it must never be cached/shared across
// sessions. There is no CDN/proxy_cache in front of this route today; keep it that way.
res.set('Cache-Control', 'private, no-store');
res.send(html);
}
@@ -5861,7 +5866,17 @@ async function serveUserPublishFile(req, res, next) {
}
}
await sendPublishFile(req, res, resolvedPath);
// REGRESSION GUARD: mindspace-public-owner-vs-visitor — this workspace URL is the one the
// agent hands back in chat and is reachable by anyone (no login required). Only the logged-in
// author (viewer.id === ownerKey) may see the "发布 Plaza" entry; everyone else is a visitor.
const viewer = req.userSession && userAuth
? await userAuth.getMe(req.userToken).catch(() => null)
: null;
const isOwner = Boolean(
viewer?.id && result.ownerKey && String(viewer.id).toLowerCase() === String(result.ownerKey).toLowerCase(),
);
await sendPublishFile(req, res, resolvedPath, { isOwner });
}
app.use('/temp', (req, res) => {