feat(mindspace): 公开页分享组件、workspace 路径与 Plaza 发布增强

- 新增 public share widget 与 workspace relative path 解析
- 增强 publications/chat-plaza 发布链路与缩略图 demo
- 补充 schema、cover 检查脚本与回归测试

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-05 23:14:41 +08:00
parent 69be1e5293
commit e2ad3bf62b
39 changed files with 2184 additions and 256 deletions
+134 -43
View File
@@ -78,6 +78,8 @@ import { createPageEditSessionService } from './mindspace-page-edit-session.mjs'
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
import {
publicationInternals,
rewriteBrokenMindSpacePublicImageUrls,
rewritePublicationCanonicalAssetUrls,
rewriteWorkspacePublicAssetReferences,
} from './mindspace-publications.mjs';
import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs';
@@ -122,7 +124,9 @@ import {
normalizePublicHtmlRelativePath,
syncPublicHtmlAfterFinish,
} from './mindspace-public-finish-sync.mjs';
import { quickPlazaFromChat } from './mindspace-chat-plaza.mjs';
import { quickPlazaFromChat, quickPlazaFromPublicHtml, getQuickPlazaFromPublicHtmlStatus } from './mindspace-chat-plaza.mjs';
import { injectPublicFileShareButton } from './mindspace-public-share-widget.mjs';
import { resolvePlazaPostPath, resolvePlazaPublicBase } from './src/utils/public-site-bases.mjs';
import { extractCoverSignals, generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
import {
extractSharePreviewMeta,
@@ -2143,6 +2147,16 @@ function ensurePlazaInteractions(res, req) {
return true;
}
function resolvePlazaPostUrlForRequest(postId, req) {
const host = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(':')[0];
const base = resolvePlazaPublicBase({
configuredBase: process.env.PLAZA_PUBLIC_BASE ?? '',
dev: process.env.NODE_ENV !== 'production',
hostname: host,
});
return resolvePlazaPostPath(base, postId);
}
function plazaRouteError(res, req, error) {
const status = mapPlazaError(error);
const code = error?.code ?? 'internal_error';
@@ -2191,6 +2205,7 @@ function mindSpaceError(res, req, error) {
invalid_source_message: 409,
version_conflict: 409,
slug_conflict: 409,
page_already_online: 409,
invalid_state_transition: 409,
invalid_publish_input: 400,
publication_not_found: 404,
@@ -3369,6 +3384,76 @@ api.post('/mindspace/v1/pages/quick-plaza-from-chat', async (req, res) => {
}
});
api.get('/mindspace/v1/pages/quick-plaza-from-public-html/status', async (req, res) => {
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 或 MindSpace 未启用');
}
try {
const relativePath = String(req.query?.relative_path ?? req.query?.relativePath ?? '').trim();
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
const result = await getQuickPlazaFromPublicHtmlStatus({
user: req.currentUser,
relativePath,
mindSpacePages,
mindSpacePublications,
plazaPosts,
});
const postId = result.post?.id;
return sendData(res, req, {
...result,
plaza_url: postId ? resolvePlazaPostUrlForRequest(postId, req) : null,
});
} catch (error) {
if (error?.code && mapPlazaError(error) !== 500) {
return plazaRouteError(res, req, error);
}
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/quick-plaza-from-public-html', async (req, res) => {
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 或 MindSpace 未启用');
}
const startedAt = Date.now();
try {
const relativePath = String(req.body?.relative_path ?? req.body?.relativePath ?? '').trim();
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
const result = await quickPlazaFromPublicHtml({
user: req.currentUser,
relativePath,
mindSpacePages,
mindSpacePublications,
plazaPosts,
publishDir,
});
const plazaUrl = resolvePlazaPostUrlForRequest(result.post.id, req);
console.info('[quick-plaza-public-html] ok', {
ms: Date.now() - startedAt,
pageId: result.pageId,
publicationId: result.publicationId,
postId: result.post?.id,
relativePath,
});
return sendData(res, req, { ...result, plaza_url: plazaUrl }, 201);
} catch (error) {
console.error('[quick-plaza-public-html] error:', { ms: Date.now() - startedAt, error });
if (error?.code === 'ALREADY_PUBLISHED') {
const postId = error?.details?.post_id ?? error?.details?.postId;
if (postId) {
error.details = {
...(error.details ?? {}),
plaza_url: resolvePlazaPostUrlForRequest(postId, req),
};
}
}
if (error?.code && mapPlazaError(error) !== 500) {
return plazaRouteError(res, req, error);
}
return mindSpaceError(res, req, error);
}
});
async function handleChatSaveDocx(req, res) {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
@@ -4822,49 +4907,12 @@ function publishedPageCsp(html, { embed = false, raw = false, wechatShare = fals
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline' https://res.wx.qq.com";
}
if (raw && isFullHtml) {
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline'";
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline'";
}
if (isFullHtml) {
return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrcDirective({ hashes: scriptHashes })}`;
return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrcDirective({ hashes: scriptHashes })}`;
}
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'";
}
const PUBLIC_FILE_SHARE_SCRIPT = `(function(){var root=document.querySelector('[data-mindspace-public-share]');if(!root)return;var shareButton=root.querySelector('[data-action="share"]');var captureButton=root.querySelector('[data-action="capture"]');var status=root.querySelector('small');var timer=null;function setStatus(text,err){if(!status)return;status.textContent=text||'';status.classList.toggle('is-error',!!err);if(timer)clearTimeout(timer);if(text)timer=setTimeout(function(){status.textContent='';status.classList.remove('is-error');},2200);}function cleanUrl(){var url=new URL(location.href);url.hash='';url.searchParams.delete('download');url.searchParams.delete('export');return url.toString();}function withParam(url,key,value){var next=new URL(url);next.searchParams.set(key,value);return next.toString();}function fallbackCopy(text){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();var ok=document.execCommand('copy');ta.remove();if(!ok)throw new Error('复制失败');}async function copyText(text){if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(text);return;}catch(e){}}fallbackCopy(text);}shareButton&&shareButton.addEventListener('click',async function(){var url=cleanUrl();var title=document.title||'MindSpace';try{if(navigator.share){await navigator.share({title:title,url:url});setStatus('已打开分享');return;}await copyText(url);setStatus('链接已复制');}catch(e){setStatus(e&&e.message?e.message:'分享失败',true);}});captureButton&&captureButton.addEventListener('click',function(){setStatus('正在生成长图,请稍候');var link=document.createElement('a');link.href=withParam(cleanUrl(),'download','long-image');link.download='';document.body.appendChild(link);link.click();link.remove();});})();`;
const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto
.createHash('sha256')
.update(PUBLIC_FILE_SHARE_SCRIPT)
.digest('base64');
function injectPublicFileShareButton(html) {
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{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:active{transform:translateY(1px)}
[data-mindspace-public-share] small{min-height:18px;max-width:180px;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}
@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><small aria-live="polite"></small><div><button type="button" data-action="capture">保存长图</button><button type="button" data-action="share">公开分享</button></div></div>
<script>${PUBLIC_FILE_SHARE_SCRIPT}</script>`;
if (/<\/body>/i.test(source)) {
return {
html: source.replace(/<\/body>/i, `${markup}</body>`),
scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH],
};
}
return {
html: `${source}${markup}`,
scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH],
};
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'";
}
function extractOgImageUrl(html) {
@@ -5232,8 +5280,11 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title, longImageUrl }) {
</html>`;
}
async function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) {
let html = result.html;
async function sendPublishedPage(req, res, result, { embed = false, raw = false, ownerSlug = null } = {}) {
let html = rewriteBrokenMindSpacePublicImageUrls(result.html);
if (ownerSlug) {
html = rewritePublicationCanonicalAssetUrls(html, ownerSlug);
}
const origin = resolveRequestOrigin(req);
const originalPath = req.originalUrl || req.url || '';
const sharePath = removeQueryParam(removeQueryParam(originalPath, 'download'), 'export');
@@ -5458,6 +5509,7 @@ async function resolvePublishedRoute(req, res, password = null) {
return await sendPublishedPage(req, res, result, {
embed: isPlazaEmbedRequest(req.query),
raw: String(req.query.view ?? '').toLowerCase() === 'raw',
ownerSlug: req.params.ownerSlug,
});
} catch (error) {
if (error?.code === 'publication_password_required') {
@@ -5505,6 +5557,45 @@ app.get('/u/:ownerSlug/pages/:urlSlug', async (req, res) => {
return resolvePublishedRoute(req, res);
});
app.use('/u/:ownerSlug/public', async (req, res) => {
await userAuthReady;
const ownerSlug = String(req.params.ownerSlug ?? '').trim().toLowerCase();
if (!ownerSlug) return res.status(404).json({ message: '用户不存在' });
if (!authPool) return res.status(503).send('MindSpace 未启用');
try {
const [rows] = await authPool.query(
`SELECT id FROM h5_users WHERE LOWER(COALESCE(slug, username)) = ? LIMIT 1`,
[ownerSlug],
);
const userId = rows[0]?.id ? String(rows[0].id) : null;
if (!userId) return res.status(404).json({ message: '用户不存在' });
const relativePath = String(req.path ?? '')
.replace(/^\/+/, '')
.split('/')
.map((segment) => decodeURIComponent(segment))
.filter(Boolean);
if (relativePath.length === 0) return res.status(404).json({ message: '文件不存在' });
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId });
const resolvedRoot = path.resolve(publishDir);
const filePath = path.resolve(publishDir, PUBLIC_ZONE_DIR, ...relativePath);
if (filePath !== resolvedRoot && !filePath.startsWith(`${resolvedRoot}${path.sep}`)) {
return res.status(403).json({ message: '禁止访问' });
}
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
return res.status(404).json({ message: '文件不存在' });
}
return res.sendFile(filePath, (err) => {
if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' });
});
} catch {
return res.status(500).send('文件加载失败');
}
});
app.get('/u/:ownerSlug', async (req, res) => {
await userAuthReady;
if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用');