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:
+134
-43
@@ -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 未启用');
|
||||
|
||||
Reference in New Issue
Block a user