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:
+720
-13
@@ -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();
|
||||
// 无状态前端节点(如 105,MindSpace 经 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`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user