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:
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user