release: prepare 0629001 portal updates

This commit is contained in:
john
2026-06-29 22:20:04 +08:00
parent 18ea4f82fd
commit a40e340a41
84 changed files with 17516 additions and 2475 deletions
+151 -27
View File
@@ -13,7 +13,7 @@ import {
sessionCookie,
} from './auth.mjs';
import { createDbPool, initSchema, isDatabaseConfigured } from './db.mjs';
import { createTkmindProxy } from './tkmind-proxy.mjs';
import { createTkmindProxy, sanitizeSessionConversationPublicHtmlLinks } from './tkmind-proxy.mjs';
import {
clearUserSessionCookie,
createUserAuth,
@@ -37,7 +37,7 @@ import { createPageService, pageInternals, inlinePrivateAssetsInHtml } from './m
import { createPageLiveEditService } from './mindspace-page-live-edit.mjs';
import { createPageEditSessionService } from './mindspace-page-edit-session.mjs';
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
import { createPublicationService } from './mindspace-publications.mjs';
import { createPublicationService, rewriteWorkspacePublicAssetReferences } from './mindspace-publications.mjs';
import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs';
import { createPlazaEventService } from './plaza-events.mjs';
import { createPlazaRecommendService } from './plaza-recommend.mjs';
@@ -80,7 +80,7 @@ import {
import { syncPublicHtmlAfterFinish } from './mindspace-public-finish-sync.mjs';
import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs';
import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
import { injectOgTags } from './mindspace-og-tags.mjs';
import { injectOgTags, injectWechatShareBridge } from './mindspace-og-tags.mjs';
import {
ensureThumbnailPng,
rasterizeThumbnailSvgToPng,
@@ -97,6 +97,7 @@ import {
} from './wechat-pay.mjs';
import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs';
import { createWechatMpService, loadWechatMpConfig } from './wechat-mp.mjs';
import { validateWechatShareSignatureUrl } from './wechat-share.mjs';
import { createScheduleService } from './schedule-service.mjs';
import { createFeedbackService } from './user-feedback.mjs';
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
@@ -790,15 +791,11 @@ app.get('/auth/wechat/js-sdk-signature', async (req, res) => {
return res.status(400).json({ message: '缺少 url' });
}
try {
const publicHost = WECHAT_MP_CONFIG.publicBaseUrl
? new URL(WECHAT_MP_CONFIG.publicBaseUrl).host
: null;
const target = new URL(pageUrl);
const requestHost = req.get('x-forwarded-host') || req.get('host') || '';
if (publicHost && target.host !== publicHost && target.host !== requestHost) {
return res.status(400).json({ message: 'url 不属于当前 H5 域名' });
}
const payload = await wechatMpService.createJsSdkSignature(pageUrl);
const normalizedUrl = validateWechatShareSignatureUrl(pageUrl, {
publicBaseUrl: WECHAT_MP_CONFIG.publicBaseUrl,
requestHost: req.get('x-forwarded-host') || req.get('host') || '',
});
const payload = await wechatMpService.createJsSdkSignature(normalizedUrl);
return res.json(payload);
} catch (err) {
const message = err instanceof Error ? err.message : '微信 JS-SDK 签名失败';
@@ -807,6 +804,29 @@ app.get('/auth/wechat/js-sdk-signature', async (req, res) => {
}
});
app.get('/auth/wechat/public-js-sdk-signature', async (req, res) => {
await userAuthReady;
if (!wechatMpService?.enabled) {
return res.status(503).json({ message: '微信 JS-SDK 未启用' });
}
const pageUrl = String(req.query?.url ?? '').split('#')[0];
if (!pageUrl) {
return res.status(400).json({ message: '缺少 url' });
}
try {
const normalizedUrl = validateWechatShareSignatureUrl(pageUrl, {
publicBaseUrl: WECHAT_MP_CONFIG.publicBaseUrl,
requestHost: req.get('x-forwarded-host') || req.get('host') || '',
});
const payload = await wechatMpService.createJsSdkSignature(normalizedUrl);
return res.json(payload);
} catch (err) {
const message = err instanceof Error ? err.message : '微信 JS-SDK 签名失败';
console.warn('WeChat public JS-SDK signature failed:', message);
return res.status(502).json({ message });
}
});
app.get('/auth/wechat/status', async (req, res) => {
await userAuthReady;
if (!userAuth || !wechatOAuthService?.enabled) {
@@ -2884,11 +2904,13 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
const basename = bundle.resolvedHtml.filename.replace(/\.html$/i, '');
const filename = `${basename}-${crypto.randomUUID().slice(0, 8)}.html`;
const sharedRelativePath = `${PUBLIC_ZONE_DIR}/shared/${filename}`;
const sharedHtml = rewriteWorkspacePublicAssetReferences(localizedHtml, sharedRelativePath);
const destPath = path.join(sharedDir, filename);
await fsPromises.writeFile(destPath, localizedHtml, 'utf8');
await fsPromises.writeFile(destPath, sharedHtml, 'utf8');
const publishKey = req.currentUser.id;
const publicUrl = `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}/${PUBLIC_ZONE_DIR}/shared/${encodeURIComponent(filename)}`;
const publicUrl = `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}/${sharedRelativePath.split('/').map((part) => encodeURIComponent(part)).join('/')}`;
return res.status(201).json({ data: { publicUrl, filename } });
} catch (error) {
@@ -3899,11 +3921,15 @@ api.get('/sessions/:sessionId', async (req, res, next) => {
const mcMatch = hintMc == null || snapshot.meta.synced_msg_count === hintMc;
const uaMatch = hintUa == null || snapshot.meta.source_updated_at === hintUa;
if (mcMatch && uaMatch) {
const sanitizedMessages = sanitizeSessionConversationPublicHtmlLinks(
snapshot.messages,
req.currentUser,
);
// Cache hit — reconstruct a Goose-compatible session response.
const cachedGooseSession = {
...snapshot.session,
// Embed only userVisible messages so getSession callers still work.
conversation: snapshot.messages,
conversation: sanitizedMessages,
};
return res.json(cachedGooseSession);
}
@@ -3926,6 +3952,12 @@ api.get('/sessions/:sessionId', async (req, res, next) => {
return res.status(upstream.status).send(text);
}
const gooseSession = await upstream.json();
if (Array.isArray(gooseSession.conversation)) {
gooseSession.conversation = sanitizeSessionConversationPublicHtmlLinks(
gooseSession.conversation,
req.currentUser,
);
}
// Write-through: persist snapshot async, don't block the response.
if (sessionSnapshotService?.isEnabled()) {
const messages = (gooseSession.conversation ?? [])
@@ -4105,20 +4137,74 @@ api.use(
app.use('/api', api);
function publishedPageCsp(html, { embed = false, raw = false } = {}) {
function scriptSrcDirective({ inline = false, urls = [], hashes = [] } = {}) {
const parts = [];
if (inline) parts.push("'unsafe-inline'");
for (const hash of hashes) parts.push(`'sha256-${hash}'`);
for (const url of urls) parts.push(url);
return parts.length ? `script-src ${parts.join(' ')}` : "script-src 'none'";
}
function publishedPageCsp(html, { embed = false, raw = false, wechatShare = false, scriptHashes = [] } = {}) {
const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
if (embed && isFullHtml) {
return publishedPageCspForEmbed(true);
}
if (wechatShare && isFullHtml) {
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 data: https:; font-src 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:; 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 data: https:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'none'";
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'; 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 button=root.querySelector('button');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 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);}button&&button.addEventListener('click',async function(){var url=location.href.split('#')[0];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);}});})();`;
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] 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: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><button type="button">公开分享</button></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],
};
}
function extractOgImageUrl(html) {
return (
String(html ?? '').match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)?.[1] ||
String(html ?? '').match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i)?.[1] ||
''
);
}
function appendQueryParam(url, key, value) {
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
@@ -4504,36 +4590,60 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) {
let html = result.html;
const origin = resolveRequestOrigin(req);
const pageUrl = req.originalUrl ? new URL(req.originalUrl, origin || 'http://localhost').toString().split('#')[0] : '';
const pageDirUrl = pageUrl ? `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}` : '';
const wechatShare = !embed && isWechatUserAgent(req.get('user-agent') || '');
if (embed) {
html = preparePublicationHtmlForEmbed(html);
allowPlazaEmbedFrame(res);
} else if (raw) {
html = stripPublicationHtmlCspMeta(html);
}
if (!embed) {
try {
html = injectOgTags(html, { origin, pageUrl, pageDirUrl });
if (wechatShare) html = injectWechatShareBridge(html, { pageUrl });
} catch {
// Never block public page delivery on share metadata injection.
}
}
const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
const canWrapWithShell = !embed && !raw && isFullHtml && result.publication?.accessMode !== 'password';
if (canWrapWithShell) {
const title = detectPublishedPageTitle(html);
const rawUrl = appendQueryParam(req.originalUrl || req.url || '', 'view', 'raw');
let shellHtml = publishedPageShellHtml({
iframeUrl: rawUrl,
shareUrl: pageUrl,
title,
});
try {
shellHtml = injectOgTags(shellHtml, {
origin,
pageUrl,
pageDirUrl,
fallbackImageUrl: extractOgImageUrl(html),
});
if (wechatShare) shellHtml = injectWechatShareBridge(shellHtml, { pageUrl });
} catch {
// Keep the share shell usable even if metadata injection fails.
}
res.set('Content-Type', 'text/html; charset=utf-8');
res.set(
'Content-Security-Policy',
"default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
wechatShare
? "default-src 'none'; style-src 'unsafe-inline'; img-src data: https:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net https://res.wx.qq.com; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"
: "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
);
res.set(
'Cache-Control',
result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store',
);
return res.send(
publishedPageShellHtml({
iframeUrl: rawUrl,
shareUrl: req.originalUrl ? new URL(req.originalUrl, resolveRequestOrigin(req) || 'http://localhost').toString() : '',
title,
}),
);
return res.send(shellHtml);
}
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Content-Security-Policy', publishedPageCsp(html, { embed, raw }));
res.set('Content-Security-Policy', publishedPageCsp(html, { embed, raw, wechatShare }));
res.set(
'Cache-Control',
result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store',
@@ -4840,8 +4950,22 @@ function sendPublishFile(req, res, filePath) {
}
try {
html = injectOgTags(html, { origin, pageUrl, pageDirUrl, fallbackImageUrl });
const wechatShare = !embed && isWechatUserAgent(req.get('user-agent') || '');
if (wechatShare) {
html = injectWechatShareBridge(html, { pageUrl });
}
const shareInjection = !embed
? injectPublicFileShareButton(html)
: { html, scriptHashes: [] };
html = shareInjection.html;
res.set('Content-Security-Policy', publishedPageCsp(html, {
embed,
wechatShare,
scriptHashes: shareInjection.scriptHashes,
}));
} catch {
// On any parse failure, fall back to the original HTML — never break page delivery.
res.set('Content-Security-Policy', publishedPageCsp(html, { embed }));
}
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(html);