Files
memind/mindspace-public-delivery.mjs
john ac520d8ae5 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>
2026-07-07 10:56:26 +08:00

113 lines
3.3 KiB
JavaScript

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;
export function collectInlineScriptHashes(html) {
const hashes = [];
const seen = new Set();
for (const match of String(html ?? '').matchAll(INLINE_SCRIPT_PATTERN)) {
const hash = crypto.createHash('sha256').update(match[1] ?? '').digest('base64');
if (seen.has(hash)) continue;
seen.add(hash);
hashes.push(hash);
}
return hashes;
}
export async function handleMindSpaceLongImageDownload({
query,
filePath,
pageUrl = null,
res,
isLongImageDownloadRequest,
longImagePathForHtml,
renderLongImage,
} = {}) {
if (!isLongImageDownloadRequest?.(query)) return false;
const longImagePath = longImagePathForHtml(filePath);
try {
await renderLongImage(pageUrl
? { url: pageUrl, outputPath: longImagePath }
: { htmlPath: filePath, outputPath: longImagePath });
res.set('Cache-Control', 'no-store');
res.download(longImagePath, path.basename(longImagePath), (err) => {
if (err && !res.headersSent) res.status(404).json({ message: '长图文件不存在' });
});
} catch (error) {
res
.status(500)
.type('text/plain; charset=utf-8')
.send(`长图生成失败:${error?.message || '未知错误'}`);
}
return true;
}
export function decorateMindSpacePublishedHtml({
html,
embed = false,
isOwner = true,
context,
htmlFilePath = '',
fileExists = fs.existsSync,
userAgent = '',
preparePublicationHtmlForEmbed,
injectOgTags,
injectWechatShareBridge,
injectPublicFileShareButton,
publishedPageCsp,
isWechatUserAgent,
} = {}) {
let nextHtml = html;
let allowEmbedFrame = false;
if (embed) {
nextHtml = preparePublicationHtmlForEmbed(nextHtml);
allowEmbedFrame = true;
}
try {
nextHtml = preparePublishedPlatformBrand(nextHtml);
nextHtml = injectOgTags(nextHtml, {
origin: context.origin,
pageUrl: context.pageUrl,
pageDirUrl: context.pageDirUrl,
fallbackImageUrl: context.fallbackImageUrl,
htmlFilePath,
fileExists,
});
const wechatShare = !embed && isWechatUserAgent(userAgent || '');
if (wechatShare) {
nextHtml = injectWechatShareBridge(nextHtml, { pageUrl: context.pageUrl });
}
const shareInjection = !embed
? 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,
csp: publishedPageCsp(nextHtml, {
embed,
wechatShare,
scriptHashes,
}),
allowEmbedFrame,
};
} catch {
return {
html: nextHtml,
csp: publishedPageCsp(nextHtml, { embed }),
allowEmbedFrame,
};
}
}