Improve WeChat MP replies and ship MindSpace/H5 production updates.

Add WeChat service account routing with sync acks, connectivity tests, and context isolation; document deploy runbooks; and bundle related MindSpace, voice, Plaza, and server gateway changes for production rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-19 23:06:43 +08:00
parent b0f5d6a51c
commit 229805a070
241 changed files with 13190 additions and 902 deletions
+720 -13
View File
@@ -1,4 +1,5 @@
import express from 'express';
import crypto from 'node:crypto';
import fs from 'node:fs';
import { createProxyMiddleware } from 'http-proxy-middleware';
import path from 'node:path';
@@ -36,6 +37,8 @@ import { createPageEditSessionService } from './mindspace-page-edit-session.mjs'
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
import { createPublicationService } from './mindspace-publications.mjs';
import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs';
import { createPlazaEventService } from './plaza-events.mjs';
import { createPlazaRecommendService } from './plaza-recommend.mjs';
import { createPlazaInteractionService } from './plaza-interactions.mjs';
import {
ensureAlgorithmConfig,
@@ -66,7 +69,10 @@ import {
resolveStaticHtmlContent,
} from './mindspace-chat-save.mjs';
import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
import { injectOgTags } from './mindspace-og-tags.mjs';
import { ensureThumbnailPng, thumbnailPngPathForSvg } from './mindspace-thumbnail-png.mjs';
import { scanContent } from './mindspace-content-scan.mjs';
import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs';
import { createRechargeService } from './billing-recharge.mjs';
import {
createWechatPayClient,
@@ -74,6 +80,9 @@ import {
WECHAT_NOTIFY_SUCCESS_V2,
} from './wechat-pay.mjs';
import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs';
import { createWechatMpService, loadWechatMpConfig } from './wechat-mp.mjs';
import { createScheduleService } from './schedule-service.mjs';
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
import { attachAsrRoutes } from './asr-proxy.mjs';
import { isNativeH5ApiPath } from './policies.mjs';
@@ -98,9 +107,14 @@ loadEnvFile(path.join(__dirname, '.env'));
const PORT = Number(process.env.H5_PORT ?? 8081);
const API_TARGET = process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006';
const API_TARGETS = [
API_TARGET,
...(process.env.TKMIND_API_TARGET_1 ? [process.env.TKMIND_API_TARGET_1] : []),
];
const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET;
const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD;
const WECHAT_MP_CONFIG = loadWechatMpConfig();
// 无状态前端节点(如 105MindSpace 经 rclone 挂载)需设 MEMIND_WORKSPACE_MAINTENANCE=0
// 否则启动时对挂载树做 readdir/递归 fs.watch 会占满 libuv 线程池导致 boot 卡死。
const WORKSPACE_MAINTENANCE_ENABLED = process.env.MEMIND_WORKSPACE_MAINTENANCE !== '0';
@@ -179,6 +193,8 @@ let mindSpacePageLiveEdit = null;
let mindSpacePageEditSession = null;
let mindSpacePublications = null;
let plazaPosts = null;
let plazaEvents = null;
let plazaRecommend = null;
let plazaInteractions = null;
let plazaSeo = null;
let plazaOps = null;
@@ -189,6 +205,9 @@ let mindSpaceAgentRunner = null;
let rechargeService = null;
let wechatPayClient = null;
let wechatOAuthService = null;
let wechatMpService = null;
let scheduleService = null;
let scheduleReminderWorker = null;
let llmProviderService = null;
let authPool = null;
@@ -241,11 +260,20 @@ async function bootstrapUserAuth() {
formatPostRow,
plazaRedis,
});
plazaEvents = createPlazaEventService(pool);
plazaRecommend = createPlazaRecommendService(pool, {
eventService: plazaEvents,
formatPostRow,
loadViewerReactions: (viewerId, postIds) =>
plazaInteractions.loadViewerReactions(viewerId, postIds),
algorithmConfig: plazaAlgorithmConfig,
});
plazaPosts = createPlazaPostService(pool, {
loadViewerReactions: (viewerId, postIds) =>
plazaInteractions.loadViewerReactions(viewerId, postIds),
plazaRedis,
algorithmConfig: plazaAlgorithmConfig,
recommendService: plazaRecommend,
onPostPublished: (postId) => plazaSeo?.notifyPostPublished(postId),
loadFeaturedPosts: async (viewerId) => {
if (!plazaOps) return { homepage_banner: [], trending: [], category_top: {} };
@@ -273,6 +301,9 @@ async function bootstrapUserAuth() {
h5Root: __dirname,
defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 500),
});
scheduleService = createScheduleService(pool, {
defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
});
wechatPayClient = createWechatPayClient(loadWechatPayConfig());
wechatOAuthService = createWechatOAuthService(pool, loadWechatOAuthConfig(), { userAuth });
rechargeService = createRechargeService(pool, {
@@ -341,10 +372,32 @@ async function bootstrapUserAuth() {
});
tkmindProxy = createTkmindProxy({
apiTarget: API_TARGET,
apiTargets: API_TARGETS,
apiSecret: API_SECRET,
userAuth,
llmProviderService,
});
wechatMpService = createWechatMpService({
config: WECHAT_MP_CONFIG,
userAuth,
apiFetch: tkmindProxy.apiFetch,
sessionApiFetch: async (sessionId, pathname, init) => {
const target = await tkmindProxy.resolveTarget(sessionId);
return tkmindProxy.apiFetchTo(target, pathname, init);
},
scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null,
});
if (
process.env.H5_REMINDER_WORKER_ENABLED === '1' &&
wechatMpService?.enabled &&
scheduleService
) {
scheduleReminderWorker = startScheduleReminderWorker({
scheduleService,
sendWechatTextToUser: (userId, text) => wechatMpService.sendTextToUser(userId, text),
});
console.log('Schedule reminder worker enabled');
}
mindSpacePageEditSession = createPageEditSessionService({
apiTarget: API_TARGET,
apiSecret: API_SECRET,
@@ -353,6 +406,9 @@ async function bootstrapUserAuth() {
pageLiveEdit: mindSpacePageLiveEdit,
llmProviderService,
});
if (wechatMpService?.enabled) {
console.log('WeChat MP webhook enabled');
}
console.log(`User auth enabled (MySQL), workspace root: ${USERS_ROOT}`);
return true;
} catch (err) {
@@ -514,6 +570,36 @@ app.get('/auth/wechat/config', async (req, res) => {
return res.json(wechatOAuthService.publicConfig(req));
});
app.get('/auth/wechat/js-sdk-signature', async (req, res) => {
await userAuthReady;
if (!userAuth || !wechatMpService?.enabled) {
return res.status(503).json({ message: '微信 JS-SDK 未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const pageUrl = String(req.query?.url ?? '').split('#')[0];
if (!pageUrl) {
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);
return res.json(payload);
} catch (err) {
const message = err instanceof Error ? err.message : '微信 JS-SDK 签名失败';
console.warn('WeChat 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) {
@@ -526,6 +612,36 @@ app.get('/auth/wechat/status', async (req, res) => {
return res.json({ enabled: true, ...status });
});
if (WECHAT_MP_CONFIG.enabled) {
app.get('/auth/wechat/agent-route', async (req, res) => {
await userAuthReady;
if (!userAuth || !wechatMpService?.enabled) {
return res.status(503).json({ message: '公众号 Agent 未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
return res.json(await wechatMpService.getRouteStatusForUser(me.id));
});
app.post('/auth/wechat/agent-route/reset', async (req, res) => {
await userAuthReady;
if (!userAuth || !wechatMpService?.enabled) {
return res.status(503).json({ message: '公众号 Agent 未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
try {
const result = await wechatMpService.recreateRouteForUser(me.id);
if (!result.ok) return res.status(400).json({ message: result.message });
return res.json(result);
} catch (err) {
return res.status(500).json({
message: err instanceof Error ? err.message : '重建公众号 Agent 路由失败',
});
}
});
}
app.get('/auth/wechat/pending/:token', async (req, res) => {
await userAuthReady;
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
@@ -817,6 +933,10 @@ const wechatNotifyBody = express.raw({
type: ['application/json', 'text/xml', 'application/xml'],
limit: '64kb',
});
const wechatMpBody = express.text({
type: ['text/xml', 'application/xml'],
limit: '128kb',
});
app.post('/webhooks/wechat-pay/notify', wechatNotifyBody, async (req, res) => {
await userAuthReady;
const isV2 = wechatPayClient?.apiVersion === 'v2';
@@ -853,6 +973,53 @@ app.post('/webhooks/wechat-pay/notify', wechatNotifyBody, async (req, res) => {
}
});
if (WECHAT_MP_CONFIG.enabled) {
app.get('/webhooks/wechat-mp/messages', async (req, res) => {
await userAuthReady;
if (!wechatMpService?.enabled) {
return res.status(503).send('wechat mp disabled');
}
if (!wechatMpService.verifyRequest(req.query)) {
console.warn('WeChat MP verify failed:', {
timestamp: req.query.timestamp ?? null,
nonce: req.query.nonce ?? null,
});
return res.status(403).send('invalid signature');
}
console.log('WeChat MP verify ok:', {
timestamp: req.query.timestamp ?? null,
nonce: req.query.nonce ?? null,
});
return res.type('text/plain').send(String(req.query.echostr ?? ''));
});
app.post('/webhooks/wechat-mp/messages', wechatMpBody, async (req, res) => {
await userAuthReady;
if (!wechatMpService?.enabled) {
return res.status(503).send('wechat mp disabled');
}
try {
const bodyText = String(req.body ?? '');
const fromUser = bodyText.match(/<FromUserName><!\[CDATA\[([\s\S]*?)\]\]><\/FromUserName>/)?.[1] ?? null;
const msgType = bodyText.match(/<MsgType><!\[CDATA\[([\s\S]*?)\]\]><\/MsgType>/)?.[1] ?? null;
const content = bodyText.match(/<Content><!\[CDATA\[([\s\S]*?)\]\]><\/Content>/)?.[1] ?? null;
console.log('WeChat MP message received:', {
at: new Date().toISOString(),
fromUser: fromUser ? `${fromUser.slice(0, 8)}...` : null,
msgType,
contentPreview: content ? `${String(content).slice(0, 24)}` : null,
});
const result = await wechatMpService.handleInboundMessage(req.body, req.query);
if (result.task) void result.task;
if (result.contentType) res.type(result.contentType);
return res.status(result.status ?? 200).send(result.body ?? 'success');
} catch (err) {
console.error('WeChat MP message failed:', err);
return res.status(500).send('internal error');
}
});
}
// ============ Wiki API ============
@@ -1050,7 +1217,9 @@ api.post('/mindspace/v1/space/cleanup', async (req, res) => {
});
function isPlazaPublicRead(path, method) {
if (method !== 'GET' || !path.startsWith('/plaza/v1/')) return false;
if (!path.startsWith('/plaza/v1/')) return false;
if (method === 'POST' && path === '/plaza/v1/events') return true;
if (method !== 'GET') return false;
return (
path === '/plaza/v1/feed' ||
path === '/plaza/v1/categories' ||
@@ -1062,6 +1231,38 @@ function isPlazaPublicRead(path, method) {
);
}
const PLAZA_SID_COOKIE = 'plaza_sid';
function resolvePlazaSessionId(req, res) {
const cookies = parseCookies(req.get('cookie'));
let sessionId = cookies[PLAZA_SID_COOKIE];
if (!sessionId) {
sessionId = crypto.randomUUID();
res.append(
'Set-Cookie',
`${PLAZA_SID_COOKIE}=${sessionId}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`,
);
}
return sessionId;
}
function recordPlazaEventsAsync(req, res, events) {
if (!plazaEvents || !Array.isArray(events) || events.length === 0) return;
const sessionId = resolvePlazaSessionId(req, res);
void plazaEvents
.recordEvents({
userId: req.currentUser?.id ?? null,
sessionId,
events,
})
.catch(() => {});
}
function reactionEventType(type) {
if (type === 'like' || type === 'collect' || type === 'share') return type;
return null;
}
function ensurePlazaInteractions(res, req) {
if (!plazaInteractions) {
sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
@@ -1442,6 +1643,14 @@ api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => {
req.query.inline === '1' ||
req.query.disposition === 'inline' ||
req.get('sec-fetch-dest') === 'iframe';
if (inline && asset.mimeType.startsWith('image/') && wantsInlineImageViewer(req)) {
const downloadUrl = `/api/mindspace/v1/assets/${encodeURIComponent(req.params.assetId)}/download?inline=1`;
const html = renderImageAssetViewerHtml({ asset, downloadUrl });
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Cache-Control', 'private, no-store');
res.setHeader('X-Request-Id', req.requestId);
return res.send(html);
}
res.set(
'Content-Disposition',
inline
@@ -2412,12 +2621,14 @@ api.post('/plaza/v1/comments/:id/reports', async (req, res) => {
api.get('/plaza/v1/feed', async (req, res) => {
if (!ensurePlazaEnabled(res, req)) return;
try {
const sessionId = resolvePlazaSessionId(req, res);
const feed = await plazaPosts.listFeed({
sort: req.query.sort,
categorySlug: req.query.category ?? null,
cursor: req.query.cursor ?? null,
limit: req.query.limit,
viewerId: req.currentUser?.id ?? null,
sessionId,
});
return sendData(res, req, feed);
} catch (error) {
@@ -2425,6 +2636,23 @@ api.get('/plaza/v1/feed', async (req, res) => {
}
});
api.post('/plaza/v1/events', async (req, res) => {
if (!ensurePlazaEnabled(res, req)) return;
if (!plazaEvents) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
try {
const sessionId =
String(req.body?.session_id ?? '').trim() || resolvePlazaSessionId(req, res);
const result = await plazaEvents.recordEvents({
userId: req.currentUser?.id ?? null,
sessionId,
events: req.body?.events ?? [],
});
return sendData(res, req, { ...result, session_id: sessionId }, 201);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.post('/plaza/v1/posts/:id/reactions', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
@@ -2434,6 +2662,10 @@ api.post('/plaza/v1/posts/:id/reactions', async (req, res) => {
req.params.id,
req.body?.type,
);
const eventType = reactionEventType(result.type);
if (eventType) {
recordPlazaEventsAsync(req, res, [{ event_type: eventType, post_id: req.params.id }]);
}
return sendData(res, req, result);
} catch (error) {
return plazaRouteError(res, req, error);
@@ -2475,6 +2707,7 @@ api.post('/plaza/v1/posts/:id/comments', async (req, res) => {
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const comment = await plazaInteractions.createComment(req.currentUser.id, req.params.id, req.body ?? {});
recordPlazaEventsAsync(req, res, [{ event_type: 'comment', post_id: req.params.id }]);
return sendData(res, req, { comment }, 201);
} catch (error) {
return plazaRouteError(res, req, error);
@@ -2560,6 +2793,7 @@ api.get('/plaza/v1/posts/:id', async (req, res) => {
viewerId: req.currentUser?.id ?? null,
});
void plazaRedis.recordView(req.params.id, plazaClientIp(req)).catch(() => {});
recordPlazaEventsAsync(req, res, [{ event_type: 'view', post_id: req.params.id }]);
return sendData(res, req, { post });
} catch (error) {
return plazaRouteError(res, req, error);
@@ -2619,7 +2853,7 @@ function runHandlerChain(chain, req, res, next) {
api.post('/llm/apply-local-fallback', async (req, res) => {
// Client calls after creditsExhausted or relay 500 (see useTKMindChat).
await userAuthReady;
if (!userAuth || !llmProviderService) {
if (!userAuth || !llmProviderService || !tkmindProxy) {
return res.status(503).json({ message: '未启用 LLM 配置' });
}
const me = await userAuth.getMe(userToken(req));
@@ -2629,7 +2863,7 @@ api.post('/llm/apply-local-fallback', async (req, res) => {
const owns = await userAuth.ownsSession(me.id, sessionId);
if (!owns) return res.status(403).json({ message: '无权访问该会话' });
try {
const result = await llmProviderService.applyLocalFallbackForSession(sessionId);
const result = await tkmindProxy.applyLocalFallbackForSession(sessionId);
if (!result.ok) return res.status(503).json(result);
res.json(result);
} catch (err) {
@@ -2666,7 +2900,8 @@ api.delete('/sessions/:sessionId', async (req, res, next) => {
return res.status(403).json({ message: '无权访问该会话' });
}
try {
const upstream = await tkmindProxy.apiFetch(`/sessions/${encodeURIComponent(sessionId)}`, {
const deleteTarget = await tkmindProxy.resolveTarget(sessionId);
const upstream = await tkmindProxy.apiFetchTo(deleteTarget, `/sessions/${encodeURIComponent(sessionId)}`, {
method: 'DELETE',
});
if (!upstream.ok && upstream.status !== 404) {
@@ -2778,11 +3013,408 @@ function publishedPageCsp(html, { embed = false } = {}) {
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'";
}
function sendPublishedPage(res, result, { embed = false } = {}) {
function appendQueryParam(url, key, value) {
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}
function resolveRequestOrigin(req) {
const host = (req.headers['x-forwarded-host'] || req.headers.host || '').toString().split(',')[0].trim();
if (!host) return '';
const isLocalHost = /^(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|100\.)/i.test(host);
const fwdProto = (req.headers['x-forwarded-proto'] || '').toString().split(',')[0].trim();
const proto = isLocalHost ? fwdProto || req.protocol || 'http' : 'https';
return `${proto}://${host}`;
}
function detectPublishedPageTitle(html) {
const match = String(html ?? '').match(/<title[^>]*>([^<]+)<\/title>/i);
return match?.[1]?.replace(/\s+/g, ' ').trim() || 'MindSpace 页面';
}
function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
const iframeSrc = escapePublicHtml(iframeUrl);
const safeShareUrl = escapePublicHtml(shareUrl);
const safeTitle = escapePublicHtml(title);
const serializedShareUrl = JSON.stringify(shareUrl).replace(/</g, '\\u003c');
const serializedTitle = JSON.stringify(title).replace(/</g, '\\u003c');
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta http-equiv="Content-Security-Policy" content="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'">
<title>${safeTitle}</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: #f6f1e7; }
body { font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif; color: #18211d; }
.publication-shell { position: relative; min-height: 100vh; }
.publication-frame { display: block; width: 100%; min-height: 100vh; border: 0; background: #fff; }
.publication-share-fab {
position: fixed;
right: 18px;
bottom: calc(18px + env(safe-area-inset-bottom, 0px));
z-index: 50;
border: 0;
border-radius: 999px;
padding: 14px 18px;
background: linear-gradient(135deg, #2f6f57, #1f4c3c);
color: #fffdf7;
font: 700 14px/1 ui-sans-serif, sans-serif;
letter-spacing: .04em;
box-shadow: 0 20px 48px rgba(24, 33, 29, .24);
cursor: pointer;
}
.publication-share-sheet[hidden] { display: none; }
.publication-share-sheet {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: end center;
padding: 16px;
background: rgba(7, 12, 10, 0.56);
}
.publication-share-panel {
width: min(480px, 100%);
padding: 18px 18px 22px;
border-radius: 24px 24px 20px 20px;
background: #fffaf2;
box-shadow: 0 24px 80px rgba(24, 33, 29, 0.24);
}
.publication-share-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.publication-share-header p {
margin: 0;
color: #9b6518;
font-size: 11px;
font-weight: 800;
letter-spacing: .16em;
text-transform: uppercase;
}
.publication-share-header h2 {
margin: 6px 0 0;
font: 500 28px/1.1 Georgia, 'Times New Roman', serif;
color: #18211d;
}
.publication-share-header strong {
display: block;
margin-top: 6px;
color: #68716c;
font-size: 13px;
font-weight: 500;
}
.publication-share-close {
width: 36px;
height: 36px;
border: 0;
border-radius: 999px;
background: rgba(24, 33, 29, 0.08);
color: #18211d;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.publication-share-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.publication-share-option {
display: grid;
gap: 8px;
justify-items: center;
padding: 14px 10px;
border: 1px solid rgba(24, 33, 29, 0.08);
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
color: #18211d;
font: inherit;
cursor: pointer;
}
.publication-share-option:disabled {
opacity: .6;
cursor: wait;
}
.publication-share-option span {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 14px;
color: #fff;
font-size: 18px;
font-weight: 700;
}
.publication-share-option[data-action="wechat"] span { background: linear-gradient(145deg, #07c160, #06ad56); }
.publication-share-option[data-action="copy"] span { background: linear-gradient(145deg, #2f6f57, #245845); }
.publication-share-option[data-action="capture"] span { background: linear-gradient(145deg, #8f6b2f, #6f5121); }
.publication-share-link, .publication-share-message {
margin-top: 16px;
padding: 12px 14px;
border-radius: 14px;
font-size: 13px;
}
.publication-share-link {
background: rgba(24, 33, 29, 0.05);
}
.publication-share-link code {
display: block;
margin-top: 6px;
word-break: break-all;
color: #445049;
font-size: 12px;
}
.publication-share-message {
display: none;
color: #245845;
background: rgba(47, 111, 87, 0.12);
}
.publication-share-message.is-visible {
display: block;
}
@media (max-width: 480px) {
.publication-share-sheet {
padding: 0;
place-items: end stretch;
}
.publication-share-panel {
width: 100%;
border-radius: 24px 24px 0 0;
}
.publication-share-fab {
right: 14px;
bottom: calc(14px + env(safe-area-inset-bottom, 0px));
}
}
</style>
</head>
<body>
<div class="publication-shell">
<iframe class="publication-frame" title="${safeTitle}" src="${iframeSrc}"></iframe>
</div>
<button type="button" class="publication-share-fab" id="publication-share-fab">分享</button>
<div class="publication-share-sheet" id="publication-share-sheet" hidden>
<section class="publication-share-panel" role="dialog" aria-modal="true" aria-labelledby="publication-share-title">
<header class="publication-share-header">
<div>
<p>Share</p>
<h2 id="publication-share-title">分享页面</h2>
<strong>${safeTitle}</strong>
</div>
<button type="button" class="publication-share-close" id="publication-share-close" aria-label="关闭">×</button>
</header>
<div class="publication-share-grid">
<button type="button" class="publication-share-option" data-action="wechat"><span>微</span>微信</button>
<button type="button" class="publication-share-option" data-action="copy"><span>链</span>复制链接</button>
<button type="button" class="publication-share-option" data-action="capture"><span>图</span>保存长图</button>
</div>
<div class="publication-share-link">
分享链接
<code>${safeShareUrl}</code>
</div>
<div class="publication-share-message" id="publication-share-message" role="status"></div>
</section>
</div>
<script>
(function () {
var shareUrl = ${serializedShareUrl};
var shareTitle = ${serializedTitle};
var fab = document.getElementById('publication-share-fab');
var sheet = document.getElementById('publication-share-sheet');
var close = document.getElementById('publication-share-close');
var message = document.getElementById('publication-share-message');
var frame = document.querySelector('.publication-frame');
var actionButtons = sheet ? Array.prototype.slice.call(sheet.querySelectorAll('.publication-share-option')) : [];
var html2canvasLoader = null;
function setBusy(busy) {
actionButtons.forEach(function (button) {
button.disabled = !!busy;
button.setAttribute('aria-busy', busy ? 'true' : 'false');
});
}
function setMessage(text, isError) {
if (!message) return;
message.textContent = text || '';
message.classList.toggle('is-visible', Boolean(text));
message.style.color = isError ? '#8b2d20' : '#245845';
message.style.background = isError ? 'rgba(139, 45, 32, 0.12)' : 'rgba(47, 111, 87, 0.12)';
}
function openSheet() {
if (sheet) sheet.hidden = false;
setMessage('');
}
function closeSheet() {
if (sheet) sheet.hidden = true;
}
async function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Fall back to execCommand for browsers where clipboard API exists but is blocked.
}
}
var textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
var copied = document.execCommand('copy');
textarea.remove();
if (!copied) throw new Error('复制失败,请手动复制链接');
}
function loadHtml2Canvas() {
if (window.html2canvas) return Promise.resolve(window.html2canvas);
if (html2canvasLoader) return html2canvasLoader;
html2canvasLoader = new Promise(function (resolve, reject) {
var script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
script.async = true;
script.onload = function () {
if (!window.html2canvas) {
reject(new Error('无法加载页面截图能力'));
return;
}
resolve(window.html2canvas);
};
script.onerror = function () { reject(new Error('无法加载页面截图能力')); };
document.body.appendChild(script);
}).catch(function (error) {
html2canvasLoader = null;
throw error;
});
return html2canvasLoader;
}
async function saveLongImage() {
if (!frame || !frame.contentDocument || !frame.contentDocument.body) {
throw new Error('页面内容暂时不可访问');
}
var renderer = await loadHtml2Canvas();
var doc = frame.contentDocument;
var body = doc.body;
var width = Math.max(doc.documentElement ? doc.documentElement.scrollWidth : 0, body.scrollWidth, 320);
var height = Math.max(doc.documentElement ? doc.documentElement.scrollHeight : 0, body.scrollHeight, 320);
var canvas = await renderer(body, {
useCORS: true,
scale: 2,
backgroundColor: '#ffffff',
width: width,
height: height,
windowWidth: width,
windowHeight: height,
x: 0,
y: 0,
scrollX: 0,
scrollY: 0
});
var link = document.createElement('a');
var timestamp = new Date().toISOString().replace(/[T:]/g, '-').replace(/\..+/, '');
link.href = canvas.toDataURL('image/png');
link.download = 'mindspace-public-page-' + timestamp + '.png';
document.body.appendChild(link);
link.click();
link.remove();
}
if (fab) fab.addEventListener('click', openSheet);
if (close) close.addEventListener('click', closeSheet);
if (sheet) {
sheet.addEventListener('click', function (event) {
if (event.target === sheet) closeSheet();
});
actionButtons.forEach(function (button) {
button.addEventListener('click', async function () {
var action = button.getAttribute('data-action');
try {
setBusy(true);
setMessage('');
if (action === 'wechat') {
await copyText([shareTitle, shareUrl].filter(Boolean).join('\\n'));
setMessage('文案已复制,请打开微信粘贴分享');
return;
}
if (action === 'copy') {
await copyText(shareUrl);
setMessage('链接已复制');
return;
}
if (action === 'capture') {
setMessage('正在生成长图,请稍候');
await saveLongImage();
setMessage('长图已保存');
}
} catch (error) {
setMessage(error && error.message ? error.message : '操作失败,请稍后再试', true);
} finally {
setBusy(false);
}
});
});
}
function syncFrameHeight() {
if (!frame || !frame.contentDocument || !frame.contentDocument.body) return;
var doc = frame.contentDocument;
var height = Math.max(
doc.documentElement ? doc.documentElement.scrollHeight : 0,
doc.body.scrollHeight,
window.innerHeight
);
frame.style.height = height + 'px';
}
if (frame) {
frame.addEventListener('load', function () {
syncFrameHeight();
window.setTimeout(syncFrameHeight, 300);
});
}
window.addEventListener('message', function (event) {
if (!event || !event.data || event.data.type !== 'plaza:embed-section' || !frame) return;
if (event.data.height) frame.style.height = Math.max(Number(event.data.height) || 0, window.innerHeight) + 'px';
});
window.addEventListener('resize', syncFrameHeight);
})();
</script>
</body>
</html>`;
}
function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) {
let html = result.html;
if (embed) {
html = preparePublicationHtmlForEmbed(html);
}
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');
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'",
);
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,
}),
);
}
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Content-Security-Policy', publishedPageCsp(html, { embed }));
res.set(
@@ -2923,7 +3555,10 @@ async function resolvePublishedRoute(req, res, password = null) {
referrer: req.get('referer'),
},
);
return sendPublishedPage(res, result, { embed: isPlazaEmbedRequest(req.query) });
return sendPublishedPage(req, res, result, {
embed: isPlazaEmbedRequest(req.query),
raw: String(req.query.view ?? '').toLowerCase() === 'raw',
});
} catch (error) {
if (error?.code === 'publication_password_required') {
return res.status(password ? 403 : 200).send(passwordGateHtml(req.originalUrl));
@@ -2970,12 +3605,16 @@ app.get('/s/:token', async (req, res) => {
try {
const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null;
return sendPublishedPage(
req,
res,
await mindSpacePublications.resolvePrivateLink(req.params.token, viewer?.id, {
userAgent: req.get('user-agent'),
referrer: req.get('referer'),
}),
{ embed: isPlazaEmbedRequest(req.query) },
{
embed: isPlazaEmbedRequest(req.query),
raw: String(req.query.view ?? '').toLowerCase() === 'raw',
},
);
} catch (error) {
if (error?.code === 'publication_not_found') return res.status(404).send('页面不存在或已下线');
@@ -2999,6 +3638,62 @@ async function resolvePublishDirKey(segment) {
return null;
}
/**
* Send a file, injecting Open Graph tags for .html so forwarded links unfurl with a cover.
* Non-HTML files (assets, etc.) are streamed unchanged via res.sendFile.
*/
function sendPublishFile(req, res, filePath) {
if (!filePath.toLowerCase().endsWith('.html')) {
res.sendFile(filePath, (err) => {
if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' });
});
return;
}
let html;
try {
html = fs.readFileSync(filePath, 'utf8');
} catch {
res.status(404).json({ message: '文件不存在' });
return;
}
const host = (req.headers['x-forwarded-host'] || req.headers.host || '').toString().split(',')[0].trim();
// Share cards (esp. WeChat) require https og:image. The edge only serves public domains
// over https, but the proxy chain forwards X-Forwarded-Proto: http to the node — so for a
// public host we force https and ignore the (wrong) forwarded scheme. Loopback/LAN stays http.
const isLocalHost = /^(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|100\.)/i.test(host);
const fwdProto = (req.headers['x-forwarded-proto'] || '').toString().split(',')[0].trim();
const proto = isLocalHost ? fwdProto || req.protocol || 'http' : 'https';
const origin = host ? `${proto}://${host}` : '';
const cleanPath = req.originalUrl.split('?')[0].split('#')[0];
// Distinguish an explicit file URL (.../space.html) from a directory that resolves to
// index.html (.../<uuid> or .../<uuid>/) so relative covers and the thumbnail resolve correctly.
const servedName = path.basename(filePath);
const urlLast = decodeURIComponent(cleanPath.split('/').filter(Boolean).pop() ?? '');
const isImplicitIndex = urlLast.toLowerCase() !== servedName.toLowerCase();
const pageUrl = origin
? `${origin}${isImplicitIndex && !cleanPath.endsWith('/') ? `${cleanPath}/` : cleanPath}`
: '';
const pageDirUrl = !origin
? ''
: isImplicitIndex
? `${origin}${cleanPath.endsWith('/') ? cleanPath : `${cleanPath}/`}`
: `${origin}${cleanPath.slice(0, cleanPath.lastIndexOf('/') + 1)}`;
// Guaranteed fallback cover: the page's feed thumbnail, served on demand as PNG.
let fallbackImageUrl = '';
const svgSibling = filePath.replace(/\.[^./]+$/, '.thumbnail.svg');
if (pageDirUrl && fs.existsSync(svgSibling)) {
const pngName = path.basename(thumbnailPngPathForSvg(svgSibling));
fallbackImageUrl = `${pageDirUrl}${pngName}`;
}
try {
html = injectOgTags(html, { origin, pageUrl, pageDirUrl, fallbackImageUrl });
} catch {
// On any parse failure, fall back to the original HTML — never break page delivery.
}
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(html);
}
async function serveUserPublishFile(req, res, next) {
const parts = req.path.split('/').filter(Boolean);
if (parts.length < 1) {
@@ -3035,6 +3730,20 @@ async function serveUserPublishFile(req, res, next) {
return;
}
// On-demand cover: rasterize <base>.thumbnail.svg → .thumbnail.png the first time a
// forwarded link's og:image is fetched (and refresh it when the SVG changes).
if (/\.thumbnail\.png$/i.test(resolvedPath)) {
const svgSibling = resolvedPath.replace(/\.png$/i, '.svg');
if (fs.existsSync(svgSibling)) {
const pngPath = ensureThumbnailPng(svgSibling);
if (pngPath && fs.existsSync(pngPath)) {
res.set('Cache-Control', 'public, max-age=300');
res.sendFile(pngPath);
return;
}
}
}
if (!fs.existsSync(resolvedPath)) {
if (rest.length === 1 && rest[0].toLowerCase().endsWith('.html')) {
const publicFallback = path.resolve(targetDir, PUBLIC_ZONE_DIR, rest[0]);
@@ -3043,7 +3752,7 @@ async function serveUserPublishFile(req, res, next) {
fs.existsSync(publicFallback) &&
fs.statSync(publicFallback).isFile()
) {
res.sendFile(publicFallback);
sendPublishFile(req, res, publicFallback);
return;
}
}
@@ -3054,16 +3763,14 @@ async function serveUserPublishFile(req, res, next) {
if (fs.statSync(resolvedPath).isDirectory()) {
const indexPath = path.join(resolvedPath, 'index.html');
if (fs.existsSync(indexPath)) {
res.sendFile(indexPath);
sendPublishFile(req, res, indexPath);
return;
}
res.status(404).json({ message: '目录中没有 index.html' });
return;
}
res.sendFile(resolvedPath, (err) => {
if (err) res.status(404).json({ message: '文件不存在' });
});
sendPublishFile(req, res, resolvedPath);
}
app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => {
@@ -3147,7 +3854,7 @@ app.get('*', (_req, res) => {
userAuthReady.then((enabled) => {
app.listen(PORT, '127.0.0.1', () => {
console.log(`TKMind H5 @ http://127.0.0.1:${PORT}`);
console.log(`Proxy -> ${API_TARGET}`);
console.log(`Proxy -> ${API_TARGETS.join(', ')}`);
console.log(`Auth -> ${enabled ? 'multi-user (MySQL)' : legacyAuth ? 'legacy password' : 'disabled'}`);
console.log(`Wiki @ http://127.0.0.1:${PORT}/${PUBLISH_ROOT_DIR}/wiki`);
});