Files
memind/server.mjs
T
john 229805a070 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>
2026-06-19 23:06:43 +08:00

3862 lines
137 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { fileURLToPath } from 'node:url';
import {
AUTH_COOKIE,
clearSessionCookie,
createAuthManager,
parseCookies,
sessionCookie,
} from './auth.mjs';
import { createDbPool, initSchema, isDatabaseConfigured } from './db.mjs';
import { createTkmindProxy } from './tkmind-proxy.mjs';
import {
clearUserSessionCookie,
createUserAuth,
resolveCookieDomainForRequest,
USER_COOKIE,
userLoginCookies,
userSessionCookie,
} from './user-auth.mjs';
import { createWikiAuth } from './wiki-auth.mjs';
import { isLocalDevHostname } from './scripts/local-test-config.mjs';
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir } from './user-publish.mjs';
import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs';
import { attachRequestId, sendData, sendError } from './api-response.mjs';
import { createMindSpaceAuditWriter } from './mindspace-audit.mjs';
import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs';
import { createMindSpaceService } from './mindspace.mjs';
import { createAssetService } from './mindspace-assets.mjs';
import { createPageService, pageInternals } from './mindspace-pages.mjs';
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 { 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,
loadAlgorithmConfig,
recalculateHotScores,
} from './plaza-algorithm.mjs';
import { createPlazaRedis, createNoopPlazaRedis } from './plaza-redis.mjs';
import { startPlazaTasks, writebackPublications } from './plaza-tasks.mjs';
import { createPlazaSeoService } from './plaza-seo.mjs';
import { createPlazaOpsService } from './plaza-ops.mjs';
import {
preparePublicationHtmlForEmbed,
isPlazaEmbedRequest,
publishedPageCspForEmbed,
} from './plaza-embed.mjs';
import { createCleanupService } from './mindspace-cleanup.mjs';
import { createAgentJobService } from './mindspace-agent-jobs.mjs';
import { createMindSpaceAgentRunner } from './mindspace-agent-runner.mjs';
import {
analyzeChatMessageForSave,
buildChatSavePreviewFrameUrl,
buildChatSaveThumbnailUrl,
buildWorkspaceAssetUrl,
buildWorkspaceBaseHref,
buildWorkspaceThumbnailUrl,
injectHtmlBaseHref,
resolveChatSaveAnalysis,
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,
loadWechatPayConfig,
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';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(__dirname, '../../.env.local'));
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';
const USERS_ROOT = process.env.H5_USERS_ROOT ?? path.join(__dirname, 'users');
const app = express();
app.set('trust proxy', 1);
const isSecureRequest = (req) =>
req.secure || req.get('x-forwarded-proto')?.split(',')[0]?.trim() === 'https';
const msFlags = mindspaceFlags();
app.use(attachRequestId);
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(self), geolocation=()');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
if (isSecureRequest(req)) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
next();
});
function csrfOriginCheck(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const host = req.get('host');
const origin = req.get('origin');
const referer = req.get('referer');
if (!origin && !referer) return next();
const requestHostname = (host ?? '').split(':')[0];
const allowed = [origin, referer].some((value) => {
if (!value) return false;
try {
const source = new URL(value);
if (source.host === host) return true;
return (
isLocalDevHostname(requestHostname) && isLocalDevHostname(source.hostname)
);
} catch {
return false;
}
});
if (!allowed) {
return sendError(res, req, 403, 'csrf_failed', '来源校验失败');
}
return next();
}
app.use('/api', csrfOriginCheck);
app.use('/auth', csrfOriginCheck);
const jsonBody = express.json({ limit: '1mb' });
const jsonUnlessMultipart = (req, res, next) => {
if ((req.headers['content-type'] ?? '').includes('multipart/form-data')) return next();
return jsonBody(req, res, next);
};
const rawUploadBody = express.raw({
type: 'application/octet-stream',
limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? 2 * 1024 * 1024),
});
const wikiAuth = createWikiAuth(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki-db'));
let legacyAuth = null;
if (ACCESS_PASSWORD) {
legacyAuth = createAuthManager({ password: ACCESS_PASSWORD });
}
let userAuth = null;
let tkmindProxy = null;
let mindSpace = null;
let mindSpaceAssets = null;
let mindSpaceAudit = null;
let mindSpacePages = null;
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;
let plazaRedis = createNoopPlazaRedis();
let mindSpaceCleanup = null;
let mindSpaceAgentJobs = null;
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;
async function bootstrapUserAuth() {
try {
if (!isDatabaseConfigured()) return false;
const pool = createDbPool();
authPool = pool;
await initSchema(pool);
mindSpace = createMindSpaceService(pool, {
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? 2 * 1024 * 1024),
aiDailyLimit: Number(process.env.MINDSPACE_FREE_AI_DAILY_LIMIT ?? 10),
publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5),
monthlyViewLimit: Number(process.env.MINDSPACE_FREE_MONTHLY_VIEW_LIMIT ?? 1000),
});
mindSpaceAssets = createAssetService(pool, {
h5Root: __dirname,
storageRoot:
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? 2 * 1024 * 1024),
});
mindSpacePages = createPageService(pool, {
h5Root: __dirname,
storageRoot:
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
});
mindSpacePageLiveEdit = createPageLiveEditService({
pageService: mindSpacePages,
resolveUserIdForAgentSession: async (sessionId) => {
const [rows] = await pool.query(
`SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`,
[sessionId],
);
return rows[0]?.user_id ?? null;
},
});
mindSpacePublications = createPublicationService(pool, {
storageRoot:
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5),
});
await ensureAlgorithmConfig(pool);
const plazaAlgorithmConfig = await loadAlgorithmConfig(pool);
plazaRedis = await createPlazaRedis(process.env.PLAZA_REDIS_URL, pool);
if (plazaRedis.enabled) {
console.log('Plaza Redis enabled');
}
plazaSeo = createPlazaSeoService(pool);
plazaInteractions = createPlazaInteractionService(pool, {
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: {} };
return plazaOps.loadActiveFeaturedPosts(viewerId);
},
});
plazaOps = createPlazaOpsService(pool, {
formatPostRow,
reviewPost: (...args) => plazaPosts.reviewPost(...args),
invalidateFeedCaches: () => plazaRedis?.invalidateFeedCaches?.(),
});
startPlazaTasks({
pool,
plazaRedis,
recalculateHotScores,
writebackPublications,
});
mindSpaceCleanup = createCleanupService(pool, {
storageRoot:
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
h5Root: __dirname,
});
userAuth = createUserAuth(pool, {
usersRoot: USERS_ROOT,
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, {
userAuth,
wechatPay: wechatPayClient,
});
if (wechatPayClient.enabled) {
console.log(`WeChat Pay recharge enabled (${wechatPayClient.apiVersion ?? 'unknown'})`);
}
if (wechatOAuthService.enabled) {
console.log('WeChat OAuth login enabled');
}
mindSpaceAgentJobs = createAgentJobService(pool, {
pageService: mindSpacePages,
storageRoot:
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
maxOutputBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? 2 * 1024 * 1024),
});
mindSpaceAgentRunner = createMindSpaceAgentRunner({
apiTarget: API_TARGET,
apiSecret: API_SECRET,
userAuth,
agentJobService: mindSpaceAgentJobs,
});
mindSpaceAudit = createMindSpaceAuditWriter(pool);
if (WORKSPACE_MAINTENANCE_ENABLED) {
startWorkspaceThumbnailWatcher(path.join(__dirname, PUBLISH_ROOT_DIR));
startWorkspaceAssetSyncWatcher({
publishRoot: path.join(__dirname, PUBLISH_ROOT_DIR),
syncUserWorkspaceByDirKey: async (dirKey, options) => {
let userId = dirKey;
if (!PUBLISH_KEY_UUID.test(dirKey)) {
const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [
dirKey,
]);
userId = rows[0]?.id;
}
if (!userId) return;
await mindSpaceAssets.syncWorkspaceAssets(userId, options);
},
});
void mindSpaceAssets.expireStaleUploads().catch(() => {});
setInterval(() => {
void mindSpaceAssets?.expireStaleUploads().catch(() => {});
}, 5 * 60 * 1000).unref?.();
} else {
console.log('Workspace maintenance daemons disabled (MEMIND_WORKSPACE_MAINTENANCE=0)');
}
await userAuth.ensureAdminUser();
llmProviderService = createLlmProviderService(pool, {
apiTarget: API_TARGET,
apiSecret: API_SECRET,
});
void llmProviderService
.ensureBootstrapRelay()
.then((result) => {
if (result.created) {
console.log(`LLM relay bootstrap created: ${RELAY_BOOTSTRAP.name}`);
}
})
.catch((err) => {
console.warn('LLM relay bootstrap skipped:', err instanceof Error ? err.message : err);
});
void llmProviderService.syncSelectedToGoosed().catch((err) => {
console.warn('LLM provider boot sync skipped:', err instanceof Error ? err.message : err);
});
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,
userAuth,
pageService: mindSpacePages,
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) {
console.error('User auth bootstrap failed:', err);
return false;
}
}
const userAuthReady = bootstrapUserAuth();
function legacySessionToken(req) {
return parseCookies(req.get('cookie'))[AUTH_COOKIE];
}
function userToken(req) {
return parseCookies(req.get('cookie'))[USER_COOKIE];
}
function setUserLoginCookies(res, req, token) {
res.set(
'Set-Cookie',
userLoginCookies(token, isSecureRequest(req), resolveCookieDomainForRequest(req)),
);
}
function clearUserLoginCookies(res, req) {
res.set(
'Set-Cookie',
clearUserSessionCookie(isSecureRequest(req), resolveCookieDomainForRequest(req)),
);
}
async function attachUserSession(req, _res, next) {
if (!userAuth) return next();
const token = userToken(req);
req.userToken = token;
req.userSession = token ? await userAuth.verify(token) : null;
next();
}
app.use(attachUserSession);
// ============ Legacy password auth ============
app.get('/auth/status', async (req, res) => {
await userAuthReady;
if (userAuth) {
const me = await userAuth.getMe(userToken(req));
if (!me) return res.json({ authenticated: false, mode: 'user' });
const row = await userAuth.getUserById(me.id);
const capabilityState = await userAuth.resolveUserCapabilities(row);
return res.json({
authenticated: true,
user: me,
mode: 'user',
capabilities: capabilityState.capabilities,
grantedSkills: capabilityState.grantedSkills ?? [],
unrestricted: capabilityState.unrestricted,
});
}
if (legacyAuth) {
return res.json({
authenticated: legacyAuth.verify(legacySessionToken(req)),
mode: 'legacy',
});
}
return res.json({ authenticated: false, mode: 'none' });
});
app.post('/auth/login', jsonBody, async (req, res) => {
await userAuthReady;
const secure = isSecureRequest(req);
if (userAuth) {
const { username, password } = req.body ?? {};
if (!username || !password) {
return res.status(400).json({ message: '用户名和密码不能为空' });
}
const result = await userAuth.login({ username, password, ip: req.ip });
if (!result.ok) {
if (result.retryAfterMs > 0) {
res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
return res.status(429).json({ message: result.message });
}
return res.status(401).json({ message: result.message });
}
setUserLoginCookies(res, req, result.token);
return res.json({ authenticated: true, user: result.user, mode: 'user' });
}
if (!legacyAuth) {
return res.status(503).json({ message: '未配置用户数据库或访问密码' });
}
const password = typeof req.body?.password === 'string' ? req.body.password : '';
const result = legacyAuth.login(password, req.ip);
if (!result.ok) {
if (result.retryAfterMs > 0) {
res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
return res.status(429).json({ message: '尝试次数过多,请稍后再试' });
}
return res.status(401).json({ message: '密码错误,请重试' });
}
res.set('Set-Cookie', sessionCookie(result.token, secure));
return res.json({ authenticated: true, mode: 'legacy' });
});
app.post('/auth/register', jsonBody, async (req, res) => {
await userAuthReady;
if (!userAuth) {
return res.status(503).json({ message: '未启用用户注册' });
}
const { username, password, displayName, email } = req.body ?? {};
const result = await userAuth.register({ username, password, displayName, email });
if (!result.ok) {
const status = result.message.includes('已存在') ? 409 : 400;
return res.status(status).json({ message: result.message });
}
if (plazaSeo && req.body?.utm_source) {
void plazaSeo
.recordAttribution(
{
event_type: 'signup',
utm_source: req.body.utm_source,
utm_medium: req.body.utm_medium,
utm_campaign: req.body.utm_campaign,
ref_id: req.body.ref ?? req.body.ref_id,
user_id: result.user?.id ?? null,
},
plazaClientIp(req),
)
.catch(() => {});
}
return res.json({ ok: true, user: result.user });
});
app.post('/auth/reset-password', jsonBody, async (req, res) => {
await userAuthReady;
if (!userAuth) {
return res.status(503).json({ message: '未启用用户系统' });
}
const { username, email, password } = req.body ?? {};
const result = await userAuth.resetPassword({ username, email, password });
if (!result.ok) {
return res.status(400).json({ message: result.message });
}
return res.json({ ok: true });
});
app.get('/auth/wechat/config', async (req, res) => {
await userAuthReady;
if (!wechatOAuthService?.enabled) {
return res.json({
enabled: false,
inWechat: isWechatUserAgent(req.get('user-agent') || ''),
scanEnabled: false,
});
}
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) {
return res.json({ enabled: false, bound: false });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const config = loadWechatOAuthConfig();
const status = await userAuth.getWechatBindingStatus(me.id, config.appId);
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: '未启用用户系统' });
const pending = await userAuth.getWechatPendingBind(req.params.token);
if (!pending) return res.status(404).json({ message: '绑定会话已过期,请重新微信登录' });
return res.json({
nickname: pending.nickname,
avatarUrl: pending.avatar_url,
returnTo: pending.return_to || '/',
});
});
app.post('/auth/wechat/register', jsonBody, async (req, res) => {
await userAuthReady;
const secure = isSecureRequest(req);
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : '';
if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' });
const result = await userAuth.completeWechatRegister({ pendingToken });
if (!result.ok) return res.status(400).json({ message: result.message });
if (result.isNewUser && plazaSeo) {
void plazaSeo
.recordAttribution(
{
event_type: 'signup',
utm_source: result.utmSource || 'wechat',
utm_medium: result.utmMedium,
utm_campaign: result.utmCampaign,
user_id: result.user?.id ?? null,
},
plazaClientIp(req),
)
.catch(() => {});
}
setUserLoginCookies(res, req, result.token);
return res.json({
authenticated: true,
user: result.user,
returnTo: result.returnTo || '/',
});
});
app.post('/auth/wechat/bind', jsonBody, async (req, res) => {
await userAuthReady;
const secure = isSecureRequest(req);
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : '';
const username = typeof req.body?.username === 'string' ? req.body.username : '';
const password = typeof req.body?.password === 'string' ? req.body.password : '';
if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' });
if (!username || !password) {
return res.status(400).json({ message: '用户名和密码不能为空' });
}
const result = await userAuth.completeWechatBindAccount({
pendingToken,
username,
password,
ip: req.ip,
});
if (!result.ok) {
const status = result.retryAfterMs > 0 ? 429 : 401;
if (result.retryAfterMs > 0) {
res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
}
return res.status(status).json({ message: result.message });
}
setUserLoginCookies(res, req, result.token);
return res.json({
authenticated: true,
user: result.user,
bound: true,
returnTo: result.returnTo || '/',
});
});
app.post('/auth/wechat/scan/start', async (req, res) => {
await userAuthReady;
if (!wechatOAuthService?.enabled) {
return res.status(503).json({ message: '微信登录未启用' });
}
try {
const payload = await wechatOAuthService.startScanLogin(req);
return res.json(payload);
} catch (err) {
return res.status(503).json({
message: err instanceof Error ? err.message : '微信扫码登录不可用',
});
}
});
app.get('/auth/wechat/scan/poll', async (req, res) => {
await userAuthReady;
if (!wechatOAuthService?.enabled) {
return res.status(503).json({ message: '微信登录未启用' });
}
const state = typeof req.query?.state === 'string' ? req.query.state : '';
if (!state) return res.status(400).json({ message: '缺少扫码状态' });
const result = await wechatOAuthService.pollScanLogin(state);
if (result.status === 'complete' && result.token) {
setUserLoginCookies(res, req, result.token);
}
return res.json(result);
});
app.get('/auth/wechat/authorize', async (req, res) => {
await userAuthReady;
if (!wechatOAuthService?.enabled) {
return res.status(503).json({ message: '微信登录未启用' });
}
try {
let bindUserId = null;
const intent = typeof req.query?.intent === 'string' ? req.query.intent.trim().toLowerCase() : 'login';
if (intent === 'bind' && userAuth) {
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '请先登录后再绑定微信' });
bindUserId = me.id;
}
const redirectUrl = await wechatOAuthService.buildAuthorizeRedirect(req, { bindUserId });
return res.redirect(302, redirectUrl);
} catch (err) {
console.error('WeChat authorize failed:', err);
return res.status(500).json({ message: err instanceof Error ? err.message : '微信授权失败' });
}
});
app.get('/auth/wechat/callback', async (req, res) => {
await userAuthReady;
const secure = isSecureRequest(req);
if (!wechatOAuthService?.enabled || !userAuth) {
return res.redirect(302, '/?wechat_error=unavailable');
}
try {
const result = await wechatOAuthService.handleCallback({
code: typeof req.query?.code === 'string' ? req.query.code : '',
state: typeof req.query?.state === 'string' ? req.query.state : '',
ip: req.ip,
});
if (result.action === 'binding_gate') {
const params = new URLSearchParams();
params.set('wechat_pending', result.pendingToken);
if (result.returnTo && result.returnTo !== '/') {
params.set('return_to', result.returnTo);
}
return res.redirect(302, `/?${params.toString()}`);
}
if (result.action === 'poll_error') {
return res.redirect(
302,
`/?wechat_error=${encodeURIComponent(result.message || '微信登录失败')}`,
);
}
if (result.isNewUser && plazaSeo) {
void plazaSeo
.recordAttribution(
{
event_type: 'signup',
utm_source: result.utmSource || 'wechat',
utm_medium: result.utmMedium,
utm_campaign: result.utmCampaign,
user_id: result.user?.id ?? null,
},
plazaClientIp(req),
)
.catch(() => {});
}
if (result.authMode === 'open' || result.authMode === 'scan') {
return res.send(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>微信登录</title></head><body><p>扫码登录成功,请返回电脑继续操作。</p></body></html>`);
}
setUserLoginCookies(res, req, result.token);
return res.redirect(302, result.returnTo || '/');
} catch (err) {
console.error('WeChat callback failed:', err);
const message = encodeURIComponent(err instanceof Error ? err.message : '微信登录失败');
return res.redirect(302, `/?wechat_error=${message}`);
}
});
app.get('/auth/me', async (req, res) => {
await userAuthReady;
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const paths = await userAuth.listPathGrants(me.id);
const capabilityState = await userAuth.resolveUserCapabilities(
await userAuth.getUserById(me.id),
);
return res.json({
user: me,
paths,
capabilities: capabilityState.capabilities,
grantedSkills: capabilityState.grantedSkills ?? [],
unrestricted: capabilityState.unrestricted,
});
});
app.post('/auth/logout', async (req, res) => {
await userAuthReady;
const secure = isSecureRequest(req);
if (userAuth) {
await userAuth.revoke(userToken(req));
clearUserLoginCookies(res, req);
}
if (legacyAuth) {
legacyAuth.revoke(legacySessionToken(req));
res.set('Set-Cookie', clearSessionCookie(secure));
}
res.status(204).end();
});
app.get('/auth/usage', async (req, res) => {
await userAuthReady;
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const records = await userAuth.listUsageRecords({ userId: me.id, limit: 30 });
res.json({ records });
});
app.get('/auth/billing/ledger', async (req, res) => {
await userAuthReady;
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const limit = Math.min(Math.max(Number(req.query?.limit) || 30, 1), 100);
const entries = await userAuth.listBillingLedger({
userId: me.id,
limit,
types: ['recharge', 'adjust', 'refund'],
});
res.json({ entries });
});
app.get('/auth/billing/config', async (req, res) => {
await userAuthReady;
if (!userAuth || !rechargeService) {
return res.status(503).json({ message: '未启用计费系统' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const config = await rechargeService.getBillingConfig(me.id);
return res.json(config);
});
app.post('/auth/billing/recharge-orders', jsonBody, async (req, res) => {
await userAuthReady;
if (!userAuth || !rechargeService) {
return res.status(503).json({ message: '未启用计费系统' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const amountCents = Number(req.body?.amountCents);
const payScene = ['native', 'h5', 'jsapi'].includes(req.body?.payScene)
? req.body.payScene
: 'native';
const result = await rechargeService.createOrder({
userId: me.id,
amountCents,
payScene,
clientIp: req.ip,
});
if (!result.ok) return res.status(400).json({ message: result.message });
return res.status(201).json({ order: result.order });
});
app.get('/auth/billing/recharge-orders/:orderId', async (req, res) => {
await userAuthReady;
if (!userAuth || !rechargeService) {
return res.status(503).json({ message: '未启用计费系统' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const order = await rechargeService.getOrderForUser(me.id, req.params.orderId);
if (!order) return res.status(404).json({ message: '订单不存在' });
let balanceCents = null;
if (order.status === 'paid') {
const user = await userAuth.getUserById(me.id);
balanceCents = user ? Number(user.balance_cents ?? 0) : null;
}
return res.json({ order, balanceCents });
});
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';
if (!rechargeService || !wechatPayClient?.enabled) {
if (isV2) {
return res
.status(503)
.type('text/xml')
.send(
'<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[支付未启用]]></return_msg></xml>',
);
}
return res.status(503).json({ code: 'FAIL', message: '支付未启用' });
}
try {
const bodyText = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : String(req.body ?? '');
await rechargeService.handleWechatNotify({ headers: req.headers, body: bodyText });
if (isV2) {
return res.type('text/xml').send(WECHAT_NOTIFY_SUCCESS_V2);
}
return res.json({ code: 'SUCCESS', message: '成功' });
} catch (err) {
console.error('WeChat notify failed:', err);
const message = err instanceof Error ? err.message : '处理失败';
if (isV2) {
return res
.status(500)
.type('text/xml')
.send(
`<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[${message}]]></return_msg></xml>`,
);
}
return res.status(500).json({ code: 'FAIL', message });
}
});
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 ============
function wikiCookie(token, secure) {
const parts = [
`${wikiAuth.COOKIE_NAME}=${encodeURIComponent(token)}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
'Max-Age=604800',
];
if (secure) parts.push('Secure');
return parts.join('; ');
}
function clearWikiCookie(secure) {
const parts = [`${wikiAuth.COOKIE_NAME}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
if (secure) parts.push('Secure');
return parts.join('; ');
}
const wikiApi = express.Router();
wikiApi.use(jsonBody);
wikiApi.post('/auth/register', (req, res) => {
const { username, password, displayName } = req.body || {};
if (!username || !password) {
return res.status(400).json({ message: '用户名和密码不能为空' });
}
const result = wikiAuth.register(username, password, displayName);
if (!result.ok) return res.status(409).json({ message: result.message });
return res.json({ ok: true, user: result.user });
});
wikiApi.post('/auth/login', (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
return res.status(400).json({ message: '用户名和密码不能为空' });
}
const result = wikiAuth.login(username, password);
if (!result.ok) return res.status(401).json({ message: result.message });
const secure = isSecureRequest(req);
res.set('Set-Cookie', wikiCookie(result.token, secure));
return res.json({ ok: true, user: result.user });
});
wikiApi.post('/auth/logout', (req, res) => {
const cookies = parseCookies(req.get('cookie'));
wikiAuth.revoke(cookies[wikiAuth.COOKIE_NAME]);
res.set('Set-Cookie', clearWikiCookie(isSecureRequest(req)));
return res.json({ ok: true });
});
wikiApi.get('/auth/me', (req, res) => {
const cookies = parseCookies(req.get('cookie'));
const session = wikiAuth.verify(cookies[wikiAuth.COOKIE_NAME]);
if (!session) return res.json({ authenticated: false, user: null });
const user = wikiAuth.getUser(session.username);
return res.json({ authenticated: true, user });
});
function requireWikiAuth(req, res, next) {
const cookies = parseCookies(req.get('cookie'));
const session = wikiAuth.verify(cookies[wikiAuth.COOKIE_NAME]);
if (!session) return res.status(401).json({ message: '未登录' });
req.wikiUser = session;
next();
}
wikiApi.get('/pages', requireWikiAuth, (req, res) => {
const { q } = req.query;
if (q) return res.json(wikiAuth.searchPages(req.wikiUser.username, q));
res.json(wikiAuth.listPages(req.wikiUser.username));
});
wikiApi.get('/pages/:slug', requireWikiAuth, (req, res) => {
const page = wikiAuth.getPage(req.wikiUser.username, req.params.slug);
if (!page) return res.status(404).json({ message: '页面不存在' });
res.json(page);
});
wikiApi.post('/pages/:slug', requireWikiAuth, (req, res) => {
const { title, content, tags } = req.body || {};
const page = wikiAuth.savePage(req.wikiUser.username, req.params.slug, title, content, tags);
res.json(page);
});
wikiApi.delete('/pages/:slug', requireWikiAuth, (req, res) => {
wikiAuth.deletePage(req.wikiUser.username, req.params.slug);
res.json({ ok: true });
});
app.use('/wiki-api', wikiApi);
// ============ TKMind API proxy ============
const api = express.Router();
api.use(jsonUnlessMultipart);
api.use(async (req, res, next) => {
await userAuthReady;
if (req.path === '/status') return next();
if (req.path.startsWith('/internal/agent/')) return next();
if (req.path === '/agent/mindspace_page_patch') return next();
const plazaPublic = isPlazaPublicRead(req.path, req.method);
if (userAuth && tkmindProxy) {
if (req.userSession) {
const me = await userAuth.getMe(req.userToken);
if (me) req.currentUser = me;
}
if (plazaPublic) return next();
if (!req.userSession) {
return res.status(401).json({ message: '未授权,请重新登录' });
}
const me = await userAuth.getMe(req.userToken);
if (!me) return res.status(401).json({ message: '登录已过期' });
req.currentUser = me;
return next();
}
if (legacyAuth?.verify(legacySessionToken(req))) return next();
return res.status(401).json({ message: '未授权,请重新登录' });
});
attachAsrRoutes(api, { sendError, sendData });
api.get('/status', async (_req, res, next) => {
await userAuthReady;
if (userAuth && tkmindProxy) {
try {
const upstream = await tkmindProxy.apiFetch('/status', { method: 'GET' });
const text = await upstream.text();
return res.status(upstream.status).send(text);
} catch (err) {
return res.status(502).json({ message: err instanceof Error ? err.message : '代理失败' });
}
}
return next();
});
api.get('/mindspace/v1/space', async (req, res) => {
if (!mindSpace || !ensureMindSpaceEnabled(res, req)) return;
const space = await mindSpace.getSpace(req.currentUser.id);
if (!space) return sendError(res, req, 404, 'resource_not_found', '用户空间不存在');
return sendData(res, req, space);
});
api.get('/mindspace/v1/space/quota', async (req, res) => {
if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' });
const quota = await mindSpace.getQuota(req.currentUser.id);
if (!quota) return res.status(404).json({ message: '用户空间不存在' });
return res.json({ data: quota });
});
api.get('/mindspace/v1/space/categories', async (req, res) => {
if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' });
const categories = await mindSpace.listCategories(req.currentUser.id);
if (!categories) return res.status(404).json({ message: '用户空间不存在' });
return res.json({ data: categories });
});
api.get('/mindspace/v1/space/cleanup', async (req, res) => {
if (!mindSpaceCleanup || !ensureMindSpaceEnabled(res, req)) return;
try {
const username = req.currentUser.username ?? req.currentUser.slug;
const items = await mindSpaceCleanup.listCandidates(req.currentUser.id, username);
const totalBytes = items.reduce((sum, item) => sum + item.sizeBytes, 0);
return sendData(res, req, { items, totalBytes });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/space/cleanup', async (req, res) => {
if (!mindSpaceCleanup || !ensureMindSpaceEnabled(res, req)) return;
try {
const username = req.currentUser.username ?? req.currentUser.slug;
const itemIds = Array.isArray(req.body?.item_ids) ? req.body.item_ids : [];
const result = await mindSpaceCleanup.runCleanup(req.currentUser.id, username, itemIds);
const quota = await mindSpace.getQuota(req.currentUser.id);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'space.cleanup',
objectType: 'space',
objectId: req.currentUser.id,
ip: req.ip,
detail: { removedCount: result.removedCount, freedBytes: result.freedBytes },
});
return sendData(res, req, { ...result, quota });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
function isPlazaPublicRead(path, method) {
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' ||
path === '/plaza/v1/seo/sitemap' ||
/^\/plaza\/v1\/posts\/[^/]+$/.test(path) ||
/^\/plaza\/v1\/posts\/[^/]+\/comments$/.test(path) ||
/^\/plaza\/v1\/users\/[^/]+$/.test(path) ||
/^\/plaza\/v1\/users\/[^/]+\/posts$/.test(path)
);
}
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 未启用');
return false;
}
return true;
}
function plazaRouteError(res, req, error) {
const status = mapPlazaError(error);
const code = error?.code ?? 'internal_error';
const message = error instanceof Error ? error.message : 'Plaza 请求失败';
return sendError(res, req, status, code, message, error?.details);
}
function ensurePlazaEnabled(res, req) {
if (!plazaPosts) {
sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
return false;
}
return true;
}
function plazaClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string' && forwarded.length > 0) {
return forwarded.split(',')[0].trim();
}
return req.ip;
}
function mindSpaceError(res, req, error) {
const statusByCode = {
invalid_filename: 400,
invalid_file_size: 400,
category_not_uploadable: 400,
file_size_mismatch: 409,
invalid_upload_state: 409,
file_too_large: 413,
unsupported_file_type: 415,
category_not_found: 404,
upload_not_found: 404,
asset_not_found: 404,
asset_in_use: 409,
page_not_found: 404,
upload_expired: 410,
quota_exceeded: 429,
public_page_limit_exceeded: 429,
space_unavailable: 423,
invalid_page_input: 400,
page_content_too_large: 413,
source_message_not_found: 404,
invalid_source_message: 409,
version_conflict: 409,
slug_conflict: 409,
invalid_state_transition: 409,
invalid_publish_input: 400,
publication_not_found: 404,
security_scan_required: 422,
security_ack_required: 422,
security_risk_blocked: 422,
invalid_agent_job_input: 400,
invalid_agent_job_output: 400,
agent_job_not_found: 404,
agent_job_token_invalid: 401,
agent_job_expired: 410,
feature_disabled: 503,
cover_ai_unavailable: 503,
llm_not_configured: 503,
cover_ai_failed: 502,
cover_ai_invalid_output: 422,
thumbnail_not_supported: 422,
thumbnail_image_required: 400,
thumbnail_image_invalid: 400,
category_not_pageable: 400,
redaction_not_needed: 409,
invalid_category_code: 400,
invalid_page_path: 400,
empty_page_content: 400,
static_page_not_found: 404,
preview_not_supported: 422,
};
const code = error?.code ?? 'internal_error';
const status = statusByCode[code] ?? 500;
const message =
status === 500 && (!error?.code || code === 'internal_error')
? 'MindSpace 服务异常'
: error?.message || 'MindSpace 服务异常';
return sendError(res, req, status, code, message, error?.details);
}
function ensureMindSpaceEnabled(res, req, { upload = false, agent = false } = {}) {
try {
assertMindSpaceRoute(msFlags, upload ? 'upload' : agent ? 'agent' : undefined);
return true;
} catch (error) {
mindSpaceError(res, req, error);
return false;
}
}
function bearerToken(req) {
const header = req.get('authorization') ?? '';
const [scheme, value] = header.split(/\s+/, 2);
if (scheme?.toLowerCase() !== 'bearer' || !value) return null;
return value.trim();
}
function requireInternalAgentSecret(req, res) {
if (bearerToken(req) === INTERNAL_AGENT_SECRET) return true;
sendError(res, req, 401, 'agent_job_token_invalid', '内部 Agent 凭据无效');
return false;
}
api.post('/mindspace/v1/agent/jobs', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
const job = await mindSpaceAgentJobs.createJob(req.currentUser.id, {
jobType: req.body?.job_type,
instruction: req.body?.instruction,
allowedAssetIds: req.body?.allowed_asset_ids,
outputCategoryId: req.body?.output_category_id,
outputType: req.body?.output_type,
idempotencyKey: req.body?.idempotency_key,
locale: req.body?.locale,
timezone: req.body?.timezone,
capabilities: req.body?.capabilities,
});
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'agent_access',
objectType: 'agent_job',
objectId: job.id,
ip: req.ip,
detail: {
jobType: job.jobType,
assetIds: job.assets.map((asset) => asset.assetId),
},
});
return sendData(res, req, job, 201);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/agent/jobs/:jobId', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
return sendData(res, req, await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId));
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/agent/jobs', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
const result = await mindSpaceAgentJobs.listJobs(req.currentUser.id, {
limit: Number(req.query.limit ?? 10),
offset: Number(req.query.offset ?? 0),
});
return res.json({
data: result.items,
page: {
total: result.total,
offset: result.offset,
limit: result.limit,
has_more: result.hasMore,
},
request_id: req.requestId,
});
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/agent/jobs/:jobId/cancel', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
return sendData(
res,
req,
await mindSpaceAgentJobs.cancelJob(req.currentUser.id, req.params.jobId),
);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/agent/jobs/:jobId/retry', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
return sendData(
res,
req,
await mindSpaceAgentJobs.retryJob(req.currentUser.id, req.params.jobId),
);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/agent/jobs/:jobId/run', async (req, res) => {
if (
!mindSpaceAgentJobs ||
!mindSpaceAgentRunner ||
!ensureMindSpaceEnabled(res, req, { agent: true })
) {
return;
}
try {
const job = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId);
if (job.status !== 'queued') {
return sendData(res, req, job);
}
void mindSpaceAgentRunner.runJob(req.params.jobId).catch((error) => {
console.error('MindSpace agent job run failed:', error);
});
return sendData(res, req, { started: true, jobId: req.params.jobId }, 202);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/internal/agent/jobs/:jobId/claim', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
if (!requireInternalAgentSecret(req, res)) return;
try {
return sendData(res, req, await mindSpaceAgentJobs.claimJob(req.params.jobId));
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/internal/agent/jobs/:jobId/assets/:assetId', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
const asset = await mindSpaceAgentJobs.getAssetForJob(
req.params.jobId,
bearerToken(req),
req.params.assetId,
);
res.set('Content-Type', asset.mimeType);
res.set('Content-Disposition', `inline; filename="${encodeURIComponent(asset.displayName)}"`);
res.set('Cache-Control', 'private, no-store');
res.setHeader('X-Request-Id', req.requestId);
return res.send(await fs.promises.readFile(asset.path));
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/internal/agent/jobs/:jobId/heartbeat', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
return sendData(
res,
req,
await mindSpaceAgentJobs.heartbeat(req.params.jobId, bearerToken(req), {
stage: req.body?.stage,
message: req.body?.message,
}),
);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/internal/agent/jobs/:jobId/complete', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
const job = await mindSpaceAgentJobs.completeJob(req.params.jobId, bearerToken(req), {
status: req.body?.status,
errorCode: req.body?.error_code,
errorMessage: req.body?.error_message,
outputType: req.body?.output_type,
title: req.body?.title,
summary: req.body?.summary,
content: req.body?.content,
contentFormat: req.body?.content_format,
pageType: req.body?.page_type,
templateId: req.body?.template_id,
sourceAssetIds: req.body?.source_asset_ids,
});
return sendData(res, req, job);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/uploads', async (req, res) => {
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req, { upload: true })) return;
try {
const upload = await mindSpaceAssets.createUpload(req.currentUser.id, {
categoryId: req.body?.category_id,
filename: req.body?.filename,
sizeBytes: req.body?.size_bytes,
declaredMimeType: req.body?.declared_mime_type,
});
return sendData(res, req, upload, 201);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.put('/mindspace/v1/uploads/:uploadId/content', rawUploadBody, async (req, res) => {
if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpaceAssets.writeUploadContent(
req.currentUser.id,
req.params.uploadId,
req.body,
);
return res.json({ data: result });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/uploads/:uploadId/complete', async (req, res) => {
if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const asset = await mindSpaceAssets.completeUpload(
req.currentUser.id,
req.params.uploadId,
);
return res.status(201).json({ data: asset });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.delete('/mindspace/v1/uploads/:uploadId', async (req, res) => {
if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpaceAssets.cancelUpload(
req.currentUser.id,
req.params.uploadId,
);
return res.json({ data: result });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/assets', async (req, res) => {
if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const assets = await mindSpaceAssets.listAssets(req.currentUser.id, {
categoryId: typeof req.query.category_id === 'string' ? req.query.category_id : undefined,
categoryCode:
typeof req.query.category_code === 'string' ? req.query.category_code : undefined,
});
return res.json({ data: assets, page: { next_cursor: null, has_more: false } });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => {
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
try {
const { asset, path: assetPath } = await mindSpaceAssets.readAsset(
req.currentUser.id,
req.params.assetId,
);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'asset.download',
objectType: 'asset',
objectId: req.params.assetId,
ip: req.ip,
riskLevel: asset.riskLevel,
});
res.type(asset.mimeType);
const inline =
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
? `inline; filename*=UTF-8''${encodeURIComponent(asset.filename)}`
: `attachment; filename*=UTF-8''${encodeURIComponent(asset.filename)}`,
);
res.setHeader('X-Request-Id', req.requestId);
return res.sendFile(assetPath);
} catch (error) {
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'asset.download',
objectType: 'asset',
objectId: req.params.assetId,
ip: req.ip,
result: 'denied',
riskLevel: error?.code === 'security_risk_blocked' ? 'high' : null,
});
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/assets/:assetId/thumbnail', async (req, res) => {
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
try {
const svg = await mindSpaceAssets.renderAssetThumbnail(req.currentUser.id, req.params.assetId);
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'private, max-age=300');
res.setHeader('X-Request-Id', req.requestId);
return res.send(svg);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/assets/:assetId/preview', async (req, res) => {
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
try {
const html = await mindSpaceAssets.renderAssetPreview(req.currentUser.id, req.params.assetId);
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);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/from-asset', async (req, res) => {
if (!mindSpacePages || !mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const assetId = req.body?.asset_id;
const existingPage = await mindSpacePages.findPageBySourceAsset(req.currentUser.id, assetId);
if (existingPage) {
return sendData(res, req, { kind: 'page', categoryCode: existingPage.categoryCode ?? 'draft', page: existingPage });
}
const { asset, path: assetPath } = await mindSpaceAssets.readAsset(
req.currentUser.id,
assetId,
);
const content = await fs.promises.readFile(assetPath, 'utf8');
const contentFormat = asset.mimeType === 'text/html' ? 'html' : 'markdown';
const page = await mindSpacePages.createFromChat(
req.currentUser.id,
{
title: req.body?.title || asset.displayName,
summary: req.body?.summary,
content,
contentFormat,
templateId: req.body?.template_id,
categoryCode: 'draft',
},
{
assetId: asset.id,
snapshot: {
source_asset_id: asset.id,
source_category: asset.categoryCode,
content_mode: contentFormat,
},
},
);
return res.status(201).json({ data: { kind: 'page', categoryCode: 'draft', page } });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.delete('/mindspace/v1/assets/:assetId', async (req, res) => {
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
try {
const result = await mindSpaceAssets.deleteAsset(req.currentUser.id, req.params.assetId);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'asset.delete',
objectType: 'asset',
objectId: req.params.assetId,
ip: req.ip,
});
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
function messageText(message) {
return (message?.content ?? [])
.filter((item) => item?.type === 'text' && typeof item.text === 'string')
.map((item) => item.text)
.join('')
.trim();
}
async function resolveOwnedAssistantMessage(userId, sessionId, messageId) {
if (!sessionId || !messageId) {
throw Object.assign(new Error('缺少来源会话或消息'), {
code: 'invalid_page_input',
});
}
if (!(await userAuth.ownsSession(userId, sessionId))) {
throw Object.assign(new Error('来源会话不存在'), { code: 'source_message_not_found' });
}
const upstream = await tkmindProxy.apiFetch(`/sessions/${encodeURIComponent(sessionId)}`, {
method: 'GET',
});
if (!upstream.ok) {
throw Object.assign(new Error('无法读取来源会话'), { code: 'source_message_not_found' });
}
const session = await upstream.json();
const message = (session.conversation ?? []).find((item) => item.id === messageId);
if (!message) {
throw Object.assign(new Error('来源消息不存在'), { code: 'source_message_not_found' });
}
const content = messageText(message);
if (message.role !== 'assistant' || !message.metadata?.userVisible || !content) {
throw Object.assign(new Error('只有可见的 AI 文本消息可以保存为页面'), {
code: 'invalid_source_message',
});
}
return { session, message, content };
}
const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'private', 'public']);
function assertPrivateSaveAllowed(categoryCode, privacyScan, acknowledgedFindingIds) {
if (categoryCode !== 'private') return;
if (!privacyScan.allowed) {
throw Object.assign(new Error('内容含阻断级敏感信息,不能保存到私人区'), {
code: 'security_risk_blocked',
details: { findings: privacyScan.findings },
});
}
if (privacyScan.findings.length === 0) return;
const acknowledged = new Set((acknowledgedFindingIds ?? []).map(String));
const missing = privacyScan.findings.filter((finding) => !acknowledged.has(finding.id));
if (missing.length > 0) {
throw Object.assign(new Error('保存到私人区前需确认敏感信息提示'), {
code: 'private_ack_required',
details: { findings: missing },
});
}
}
async function resolveChatSaveBundle(user, h5Root, input = {}) {
const sessionId = input.sessionId ?? input.session_id;
const messageId = input.messageId ?? input.message_id;
const selectedLinkIndex = Number(input.selectedLinkIndex ?? input.selected_link_index ?? 0);
const previewTitle = String(input.previewTitle ?? input.preview_title ?? '').trim();
const previewSummary = String(input.previewSummary ?? input.preview_summary ?? '').trim();
const source = await resolveOwnedAssistantMessage(user.id, sessionId, messageId);
const { analysis, resolvedHtml } = await resolveChatSaveAnalysis({
content: source.content,
userId: user.id,
username: user.username,
h5Root,
selectedLinkIndex,
});
return {
source,
analysis,
resolvedHtml,
selectedLinkIndex,
previewTitle,
previewSummary,
previewFrameUrl:
resolvedHtml != null
? buildChatSavePreviewFrameUrl({ sessionId, messageId, selectedLinkIndex })
: null,
thumbnailUrl:
resolvedHtml != null
? buildChatSaveThumbnailUrl({
sessionId,
messageId,
selectedLinkIndex,
previewTitle,
previewSummary,
})
: null,
localPreviewUrl:
resolvedHtml?.relativePath != null
? buildWorkspaceAssetUrl(user.id, resolvedHtml.relativePath)
: null,
localThumbnailUrl:
resolvedHtml?.relativePath != null
? buildWorkspaceThumbnailUrl(user.id, resolvedHtml.relativePath)
: null,
};
}
api.get('/mindspace/v1/pages/chat-save-preview', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.query);
if (!bundle.resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
const baseHref = buildWorkspaceBaseHref(req.currentUser.id, bundle.resolvedHtml.relativePath);
const html = injectHtmlBaseHref(bundle.resolvedHtml.content, baseHref);
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);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.query);
if (!bundle.resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
const publishDir = resolvePublishDir(__dirname, req.currentUser);
const thumbRel = workspaceThumbnailRelativePath(bundle.resolvedHtml.relativePath);
const title =
bundle.previewTitle ||
bundle.resolvedHtml.suggestedTitle ||
bundle.analysis.suggestedTitle;
const subtitle =
bundle.previewSummary ||
bundle.resolvedHtml.suggestedSummary ||
bundle.analysis.suggestedSummary;
await ensureWorkspaceHtmlThumbnail(
publishDir,
bundle.resolvedHtml.relativePath,
bundle.resolvedHtml.content,
{ title, subtitle, force: true },
).catch(() => {});
const svg = await generateHtmlThumbnail(publishDir, thumbRel, bundle.resolvedHtml.content, {
title,
subtitle,
contentBaseDir: path.dirname(bundle.resolvedHtml.absolute),
force: true,
});
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'private, no-store');
res.setHeader('X-Request-Id', req.requestId);
return res.send(svg);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
const {
source,
analysis,
resolvedHtml,
previewTitle,
previewSummary,
previewFrameUrl,
thumbnailUrl,
localPreviewUrl,
localThumbnailUrl,
} = bundle;
let thumbnailReady = false;
if (resolvedHtml?.content && resolvedHtml.relativePath) {
const publishDir = resolvePublishDir(__dirname, req.currentUser);
try {
await ensureWorkspaceHtmlThumbnail(publishDir, resolvedHtml.relativePath, resolvedHtml.content, {
title: previewTitle || resolvedHtml.suggestedTitle,
subtitle: previewSummary || resolvedHtml.suggestedSummary,
force: Boolean(previewTitle || previewSummary),
});
thumbnailReady = true;
} catch {
thumbnailReady = false;
}
}
return sendData(res, req, {
contentMode: analysis.contentMode,
links: analysis.links,
selectedLinkIndex: analysis.selectedLink
? analysis.links.findIndex((link) => link.publicUrl === analysis.selectedLink.publicUrl)
: -1,
suggestedTitle: resolvedHtml?.suggestedTitle ?? analysis.suggestedTitle,
suggestedSummary: resolvedHtml?.suggestedSummary ?? analysis.suggestedSummary,
previewUrl: analysis.previewUrl,
previewFrameUrl,
localPreviewUrl,
localThumbnailUrl,
thumbnailUrl: resolvedHtml ? thumbnailUrl : null,
thumbnailReady,
relativePath: resolvedHtml?.relativePath ?? analysis.relativePath,
filename: resolvedHtml?.filename ?? analysis.filename,
hasHtmlContent: Boolean(resolvedHtml),
privacyScan: scanContent(resolvedHtml?.content ?? source.content),
});
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
if (!mindSpacePages || !mindSpaceAssets) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const categoryCode = String(req.body?.category_code ?? 'draft');
if (!SAVE_TARGET_CATEGORIES.has(categoryCode)) {
throw Object.assign(new Error('无效的保存目标'), { code: 'invalid_category_code' });
}
const source = await resolveOwnedAssistantMessage(
req.currentUser.id,
req.body?.session_id,
req.body?.message_id,
);
const analysis = analyzeChatMessageForSave({
content: source.content,
userId: req.currentUser.id,
username: req.currentUser.username,
h5Root: __dirname,
selectedLinkIndex: Number(req.body?.selected_link_index ?? 0),
});
const snapshot = {
session_name: source.session.name,
message_created: source.message.created,
role: source.message.role,
content_mode: analysis.contentMode,
public_url: analysis.previewUrl,
relative_path: analysis.relativePath,
};
let resolvedHtml = null;
if (analysis.contentMode === 'static_html') {
resolvedHtml = await resolveStaticHtmlContent(analysis).catch(() => null);
}
const privacyScan = scanContent(resolvedHtml?.content ?? source.content);
assertPrivateSaveAllowed(categoryCode, privacyScan, req.body?.acknowledged_finding_ids);
if (categoryCode !== 'draft') {
let buffer;
let filename;
let displayName = req.body?.title;
if (analysis.contentMode === 'static_html') {
if (!resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
buffer = Buffer.from(resolvedHtml.content, 'utf8');
filename = resolvedHtml.filename;
displayName = displayName || resolvedHtml.suggestedTitle;
} else {
buffer = Buffer.from(source.content, 'utf8');
filename = `${String(displayName || 'chat-export')
.replace(/[^\w\u4e00-\u9fff-]+/g, '-')
.slice(0, 48) || 'chat-export'}.md`;
}
const asset = await mindSpaceAssets.createChatAsset(req.currentUser.id, {
categoryCode,
buffer,
filename,
displayName,
sourceType: 'chat',
});
return res.status(201).json({
data: {
kind: 'asset',
categoryCode,
asset,
},
});
}
let pageInput = {
title: req.body?.title,
summary: req.body?.summary,
templateId: req.body?.template_id,
pageType: req.body?.page_type,
categoryCode: 'draft',
};
if (analysis.contentMode === 'static_html') {
if (!resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
pageInput = {
...pageInput,
title: req.body?.title || resolvedHtml.suggestedTitle,
summary: req.body?.summary || resolvedHtml.suggestedSummary,
content: resolvedHtml.content,
contentFormat: 'html',
pageType: 'html',
};
} else {
pageInput = {
...pageInput,
content: source.content,
contentFormat: 'markdown',
};
}
if (analysis.contentMode === 'static_html' && resolvedHtml?.content && analysis.relativePath) {
const publishDir = resolvePublishDir(__dirname, req.currentUser);
await ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, {
title: pageInput.title,
subtitle: pageInput.summary,
}).catch(() => {});
}
const page = await mindSpacePages.createFromChat(req.currentUser.id, pageInput, {
sessionId: req.body.session_id,
messageId: req.body.message_id,
snapshot,
});
return res.status(201).json({ data: { kind: 'page', categoryCode: 'draft', page } });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const page = await mindSpacePages.createPage(req.currentUser.id, {
title: req.body?.title,
summary: req.body?.summary,
content: req.body?.content,
contentFormat: req.body?.content_format === 'html' ? 'html' : undefined,
templateId: req.body?.template_id,
pageType: req.body?.page_type,
categoryCode: req.body?.category_code,
});
return res.status(201).json({ data: page });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const pages = await mindSpacePages.listPages(req.currentUser.id, {
status: typeof req.query.status === 'string' ? req.query.status : undefined,
});
return res.json({ data: pages, page: { next_cursor: null, has_more: false } });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const [page, versions, publication] = await Promise.all([
mindSpacePages.getPage(req.currentUser.id, req.params.pageId),
mindSpacePages.listVersions(req.currentUser.id, req.params.pageId),
mindSpacePublications?.getCurrent(req.currentUser.id, req.params.pageId) ?? null,
]);
return res.json({ data: { ...page, versions, publication } });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.delete('/mindspace/v1/pages/:pageId', async (req, res) => {
if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return;
try {
const result = await mindSpacePages.deletePage(req.currentUser.id, req.params.pageId);
const quota = await mindSpace.getQuota(req.currentUser.id);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'page.delete',
objectType: 'page',
objectId: req.params.pageId,
ip: req.ip,
detail: {
offlinedPublicationCount: result.offlinedPublicationCount,
deletedAssetCount: result.deletedAssetCount,
freedBytes: result.freedBytes,
},
});
return sendData(res, req, { ...result, quota });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId/delete-preview', async (req, res) => {
if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return;
try {
return sendData(res, req, await mindSpacePages.getDeletePreview(req.currentUser.id, req.params.pageId));
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.put('/mindspace/v1/pages/:pageId', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const page = await mindSpacePages.updatePage(req.currentUser.id, req.params.pageId, {
expectedVersion: req.body?.expected_version,
title: req.body?.title,
summary: req.body?.summary,
content: req.body?.content,
templateId: req.body?.template_id,
pageType: req.body?.page_type,
changeNote: req.body?.change_note,
});
return res.json({ data: page });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/live-edit/bind', async (req, res) => {
if (!mindSpacePageLiveEdit || !mindSpacePages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const sessionId = String(req.body?.session_id ?? '').trim();
if (!sessionId) {
return sendError(res, req, 400, 'invalid_request', '缺少 session_id');
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return sendError(res, req, 403, 'forbidden', '无权绑定该 Agent 会话');
}
await mindSpacePages.getPage(req.currentUser.id, req.params.pageId);
const parentSessionId = String(req.body?.parent_session_id ?? '').trim() || null;
return sendData(
res,
req,
mindSpacePageLiveEdit.bindSession({
userId: req.currentUser.id,
sessionId,
pageId: req.params.pageId,
parentSessionId,
}),
);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/live-edit/fork-session', async (req, res) => {
if (!mindSpacePageEditSession || !mindSpacePages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const parentSessionId = String(req.body?.parent_session_id ?? '').trim();
if (!parentSessionId) {
return sendError(res, req, 400, 'invalid_request', '缺少 parent_session_id');
}
const gate = await userAuth.canUseChat(req.currentUser.id);
if (!gate.ok) {
return sendError(res, req, 402, gate.code ?? 'insufficient_balance', gate.message);
}
return sendData(
res,
req,
await mindSpacePageEditSession.forkSession({
userId: req.currentUser.id,
pageId: req.params.pageId,
parentSessionId,
h5ApiBase: String(req.body?.h5_api_base ?? req.body?.h5ApiBase ?? '').trim() || null,
}),
);
} catch (error) {
if (error?.code === 'forbidden') {
return sendError(res, req, 403, error.code, error.message);
}
if (error?.code === 'invalid_request') {
return sendError(res, req, 400, error.code, error.message);
}
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/live-edit/close-session', async (req, res) => {
if (!mindSpacePageEditSession || !mindSpacePages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const sessionId = String(req.body?.session_id ?? '').trim();
if (!sessionId) {
return sendError(res, req, 400, 'invalid_request', '缺少 session_id');
}
return sendData(
res,
req,
await mindSpacePageEditSession.closeSession({
userId: req.currentUser.id,
pageId: req.params.pageId,
sessionId,
parentSessionId: String(req.body?.parent_session_id ?? '').trim() || null,
summary: String(req.body?.summary ?? ''),
}),
);
} catch (error) {
if (error?.code === 'forbidden' || error?.code === 'page_binding_mismatch') {
return sendError(res, req, 403, error.code, error.message);
}
if (error?.code === 'invalid_request') {
return sendError(res, req, 400, error.code, error.message);
}
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId/live-edit/revision', async (req, res) => {
if (!mindSpacePageLiveEdit || !mindSpacePages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
return sendData(
res,
req,
await mindSpacePageLiveEdit.getRevisionSnapshot(req.currentUser.id, req.params.pageId),
);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/agent/mindspace_page_patch', async (req, res) => {
if (!mindSpacePageLiveEdit) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const result = await mindSpacePageLiveEdit.applyAgentPatch(req.body ?? {});
return res.json({
data: {
pageId: result.page.id,
title: result.page.title,
summary: result.page.summary,
versionNo: result.page.versionNo,
updatedAt: result.page.updatedAt,
liveRevision: result.liveRevision,
},
});
} catch (error) {
if (error?.code === 'forbidden' || error?.code === 'page_binding_mismatch') {
return sendError(res, req, 403, error.code, error.message);
}
if (error?.code === 'invalid_request') {
return sendError(res, req, 400, error.code, error.message);
}
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId/thumbnail', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const svg = await mindSpacePages.renderThumbnail(req.currentUser.id, req.params.pageId);
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'private, max-age=300');
res.setHeader('X-Request-Id', req.requestId);
return res.send(svg);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/thumbnail/upload', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpacePages.uploadThumbnail(req.currentUser.id, req.params.pageId, {
imageBase64: req.body?.image_base64,
mimeType: req.body?.mime_type,
title: req.body?.title,
summary: req.body?.summary,
html: req.body?.html,
});
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/thumbnail/regenerate', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpacePages.regenerateThumbnail(
req.currentUser.id,
req.params.pageId,
{
html: req.body?.html,
title: req.body?.title,
summary: req.body?.summary,
useAi: Boolean(req.body?.use_ai),
instruction: req.body?.instruction,
},
{
suggestCoverMeta: req.body?.use_ai
? (input) => {
if (!authPool) {
throw Object.assign(new Error('LLM 服务未就绪'), { code: 'llm_not_configured' });
}
return suggestCoverMetaWithAi(authPool, {
...input,
encryptionKey:
process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY,
});
}
: null,
},
);
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId/preview', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const { html, contentFormat } = await mindSpacePages.renderPreview(
req.currentUser.id,
req.params.pageId,
);
res.set('Content-Type', 'text/html; charset=utf-8');
res.set(
'Content-Security-Policy',
pageInternals.previewContentSecurityPolicy(contentFormat),
);
res.set('Cache-Control', 'private, no-store');
res.set('X-Content-Type-Options', 'nosniff');
return res.send(html);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/preview-draft', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const { html, contentFormat } = await mindSpacePages.renderDraftPreview(
req.currentUser.id,
req.params.pageId,
{
title: req.body?.title,
summary: req.body?.summary,
content: req.body?.content,
templateId: req.body?.template_id,
},
);
res.set('Content-Type', 'text/html; charset=utf-8');
res.set(
'Content-Security-Policy',
pageInternals.previewContentSecurityPolicy(contentFormat),
);
res.set('Cache-Control', 'private, no-store');
res.set('X-Content-Type-Options', 'nosniff');
return res.send(html);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/publish-check', async (req, res) => {
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpacePublications.check(req.currentUser.id, req.params.pageId, {
pageVersionId: req.body?.page_version_id,
accessMode: req.body?.access_mode,
urlSlug: req.body?.url_slug,
password: req.body?.password,
expiresAt: req.body?.expires_at,
});
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/redact', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpacePages.redactPage(req.currentUser.id, req.params.pageId, {
pageVersionId: req.body?.page_version_id,
expectedVersion: req.body?.expected_version,
title: req.body?.title,
summary: req.body?.summary,
content: req.body?.content,
});
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'page.redact',
objectType: 'page',
objectId: result.page.id,
ip: req.ip,
riskLevel: result.originalScan.riskLevel,
});
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/redacted-copy', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpacePages.redactPage(req.currentUser.id, req.params.pageId, {
pageVersionId: req.body?.page_version_id,
expectedVersion: req.body?.expected_version,
title: req.body?.title,
summary: req.body?.summary,
content: req.body?.content,
});
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'page.redact',
objectType: 'page',
objectId: result.page.id,
ip: req.ip,
riskLevel: result.originalScan.riskLevel,
});
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/publish', async (req, res) => {
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const publication = await mindSpacePublications.publish(
req.currentUser.id,
req.params.pageId,
{
pageVersionId: req.body?.page_version_id,
accessMode: req.body?.access_mode,
urlSlug: req.body?.url_slug,
password: req.body?.password,
expiresAt: req.body?.expires_at,
acknowledgedFindingIds: req.body?.acknowledged_finding_ids,
},
);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'page.publish',
objectType: 'publication',
objectId: publication.id,
ip: req.ip,
});
return sendData(res, req, publication, 201);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/publications/:publicationId/offline', async (req, res) => {
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const publication = await mindSpacePublications.offline(
req.currentUser.id,
req.params.publicationId,
);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'page.offline',
objectType: 'publication',
objectId: req.params.publicationId,
ip: req.ip,
});
return sendData(res, req, publication);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/publications/:publicationId/stats', async (req, res) => {
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
return sendData(
res,
req,
await mindSpacePublications.getStats(
req.currentUser.id,
req.params.publicationId,
),
);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/plaza/v1/categories', async (req, res) => {
if (!ensurePlazaEnabled(res, req)) return;
try {
return sendData(res, req, { categories: await plazaPosts.listCategories() });
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.get('/plaza/v1/seo/sitemap', async (req, res) => {
if (!ensurePlazaEnabled(res, req)) return;
if (!plazaSeo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza SEO 未启用');
try {
const data = await plazaSeo.listSitemapData({
postLimit: req.query.post_limit,
userLimit: req.query.user_limit,
});
return sendData(res, req, data);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.post('/plaza/v1/attribution/events', async (req, res) => {
if (!plazaSeo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
try {
const result = await plazaSeo.recordAttribution(req.body ?? {}, plazaClientIp(req));
return sendData(res, req, result, 201);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.post('/plaza/v1/posts/:id/reports', async (req, res) => {
if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const report = await plazaOps.createReport(req.currentUser.id, {
target_type: 'post',
target_id: req.params.id,
reason: req.body?.reason,
detail: req.body?.detail,
});
return sendData(res, req, { report }, 201);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.post('/plaza/v1/comments/:id/reports', async (req, res) => {
if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const report = await plazaOps.createReport(req.currentUser.id, {
target_type: 'comment',
target_id: req.params.id,
reason: req.body?.reason,
detail: req.body?.detail,
});
return sendData(res, req, { report }, 201);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
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) {
return plazaRouteError(res, req, error);
}
});
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', '未授权,请重新登录');
try {
const result = await plazaInteractions.addReaction(
req.currentUser.id,
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);
}
});
api.delete('/plaza/v1/posts/:id/reactions/:type', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const result = await plazaInteractions.removeReaction(
req.currentUser.id,
req.params.id,
req.params.type,
);
return sendData(res, req, result);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.get('/plaza/v1/posts/:id/comments', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
try {
const comments = await plazaInteractions.listComments(req.params.id, {
cursor: req.query.cursor ?? null,
limit: req.query.limit,
parentId: req.query.parent_id ?? null,
viewerId: req.currentUser?.id ?? null,
});
return sendData(res, req, comments);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.post('/plaza/v1/posts/:id/comments', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
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);
}
});
api.delete('/plaza/v1/comments/:id', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const comment = await plazaInteractions.deleteComment(req.currentUser.id, req.params.id);
return sendData(res, req, { comment });
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.post('/plaza/v1/comments/:id/reactions', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const liked = req.body?.liked !== false;
const result = await plazaInteractions.toggleCommentLike(req.currentUser.id, req.params.id, liked);
return sendData(res, req, result);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.get('/plaza/v1/users/:slug', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
try {
const profile = await plazaInteractions.getUserProfile(
req.params.slug,
req.currentUser?.id ?? null,
);
return sendData(res, req, profile);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.get('/plaza/v1/users/:slug/posts', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
try {
const feed = await plazaInteractions.listUserPosts(req.params.slug, {
cursor: req.query.cursor ?? null,
limit: req.query.limit,
viewerId: req.currentUser?.id ?? null,
});
return sendData(res, req, feed);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.post('/plaza/v1/users/:slug/follow', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const result = await plazaInteractions.followUser(req.currentUser.id, req.params.slug);
return sendData(res, req, result);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.delete('/plaza/v1/users/:slug/follow', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const result = await plazaInteractions.unfollowUser(req.currentUser.id, req.params.slug);
return sendData(res, req, result);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.get('/plaza/v1/posts/:id', async (req, res) => {
if (!ensurePlazaEnabled(res, req)) return;
try {
const post = await plazaPosts.getPostById(req.params.id, {
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);
}
});
api.post('/plaza/v1/posts', async (req, res) => {
if (!ensurePlazaEnabled(res, req)) return;
if (!req.currentUser) {
return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
}
try {
const post = await plazaPosts.createPost(req.currentUser.id, req.body ?? {});
return sendData(res, req, { post }, 201);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.patch('/plaza/v1/posts/:id', async (req, res) => {
if (!ensurePlazaEnabled(res, req)) return;
if (!req.currentUser) {
return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
}
try {
const post = await plazaPosts.updatePost(req.currentUser.id, req.params.id, req.body ?? {});
return sendData(res, req, { post });
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.delete('/plaza/v1/posts/:id', async (req, res) => {
if (!ensurePlazaEnabled(res, req)) return;
if (!req.currentUser) {
return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
}
try {
const post = await plazaPosts.hidePost(req.currentUser.id, req.params.id);
return sendData(res, req, { post });
} catch (error) {
return plazaRouteError(res, req, error);
}
});
function runHandlerChain(chain, req, res, next) {
let index = 0;
const run = (err) => {
if (err) return next(err);
const layer = chain[index++];
if (!layer) return;
layer(req, res, (error) => run(error));
};
run();
}
api.post('/llm/apply-local-fallback', async (req, res) => {
// Client calls after creditsExhausted or relay 500 (see useTKMindChat).
await userAuthReady;
if (!userAuth || !llmProviderService || !tkmindProxy) {
return res.status(503).json({ message: '未启用 LLM 配置' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const sessionId = String(req.body?.session_id ?? '').trim();
if (!sessionId) return res.status(400).json({ message: '缺少 session_id' });
const owns = await userAuth.ownsSession(me.id, sessionId);
if (!owns) return res.status(403).json({ message: '无权访问该会话' });
try {
const result = await tkmindProxy.applyLocalFallbackForSession(sessionId);
if (!result.ok) return res.status(503).json(result);
res.json(result);
} catch (err) {
res.status(500).json({
message: err instanceof Error ? err.message : '切换本地 LLM 失败',
});
}
});
api.post('/agent/start', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
return runHandlerChain(tkmindProxy.handlers['POST /agent/start'], req, res, next);
});
api.post('/agent/resume', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
return runHandlerChain(tkmindProxy.handlers['POST /agent/resume'], req, res, next);
});
api.get('/sessions', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
return runHandlerChain(tkmindProxy.handlers['GET /sessions'], req, res, next);
});
api.delete('/sessions/:sessionId', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
const sessionId = req.params.sessionId;
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
try {
const deleteTarget = await tkmindProxy.resolveTarget(sessionId);
const upstream = await tkmindProxy.apiFetchTo(deleteTarget, `/sessions/${encodeURIComponent(sessionId)}`, {
method: 'DELETE',
});
if (!upstream.ok && upstream.status !== 404) {
const text = await upstream.text().catch(() => '');
return res.status(upstream.status).send(text || '删除会话失败');
}
await userAuth.unregisterAgentSession(req.currentUser.id, sessionId);
return res.status(204).end();
} catch (err) {
return res.status(500).json({ message: err instanceof Error ? err.message : '删除会话失败' });
}
});
api.get('/sessions/:sessionId/events', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
const sessionId = req.params.sessionId;
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
return tkmindProxy.proxySessionEvents(req, res, sessionId);
});
api.use(async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
if (isNativeH5ApiPath(req.path)) {
return sendError(res, req, 404, 'not_found', `接口不存在:${req.method} ${req.path}`);
}
const sessionMatch = req.path.match(/^\/sessions\/([^/]+)/);
if (sessionMatch) {
const sessionId = sessionMatch[1];
if (req.path.endsWith('/events') && req.method === 'GET') {
return next();
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
if (req.path.endsWith('/reply') && req.method === 'POST') {
const gate = await userAuth.canUseChat(req.currentUser.id);
if (!gate.ok) {
return res.status(402).json({
message: gate.message,
code: gate.code,
balanceCents: gate.balanceCents,
minRechargeCents: gate.minRechargeCents,
suggestedTiers: gate.suggestedTiers,
});
}
if (llmProviderService) {
try {
await tkmindProxy.applySessionLlmProvider(sessionId);
} catch (err) {
console.warn(
'LLM provider sync before reply skipped:',
err instanceof Error ? err.message : err,
);
}
}
}
}
if (req.method === 'POST' && req.path === '/agent/resume' && req.body?.session_id) {
const owns = await userAuth.ownsSession(req.currentUser.id, req.body.session_id);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
}
if (req.body?.working_dir) {
const allowed = await userAuth.isPathAllowed(req.currentUser.id, req.body.working_dir);
if (!allowed) {
return res.status(403).json({ message: '工作目录不在授权范围内' });
}
}
return tkmindProxy.proxyFallback(req, res);
});
api.use(
createProxyMiddleware({
target: API_TARGET,
changeOrigin: true,
secure: false,
pathRewrite: { '^/api': '' },
on: {
proxyReq: (proxyReq) => {
proxyReq.setHeader('X-Secret-Key', API_SECRET);
},
},
}),
);
app.use('/api', api);
function publishedPageCsp(html, { embed = false } = {}) {
const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
if (embed && isFullHtml) {
return publishedPageCspForEmbed(true);
}
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'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'";
}
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(
'Cache-Control',
result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store',
);
return res.send(html);
}
function passwordGateHtml(action) {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'">
<title>受保护页面</title>
<style>
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; color: #17221d; background: radial-gradient(circle at top right, #d9e9df, transparent 42rem), #f5f0e5; font-family: ui-sans-serif, sans-serif; }
form { width: min(420px, 100%); padding: 36px; border: 1px solid #d6d0c3; border-radius: 28px; background: #fffdf7; box-shadow: 0 24px 70px rgba(35, 48, 40, .12); }
p { color: #68716c; line-height: 1.7; }
input, button { width: 100%; margin-top: 14px; padding: 14px 16px; border-radius: 12px; font: inherit; }
input { border: 1px solid #c9c6bc; }
button { border: 0; color: white; background: #2f6f57; cursor: pointer; font-weight: 800; }
</style>
</head>
<body>
<form method="post" action="${action}">
<h1>此页面受密码保护</h1>
<p>请输入发布者提供的访问密码。密码不会写入链接或浏览器日志。</p>
<input type="password" name="password" minlength="8" maxlength="128" required autocomplete="current-password">
<button type="submit">访问页面</button>
</form>
</body>
</html>`;
}
function escapePublicHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function publicHomepageHtml(data) {
const cards = data.pages
.map(
(page) => `\
<a class="card" href="${escapePublicHtml(page.publicUrl)}">
<div class="card-top">
<span>${escapePublicHtml(page.templateId)}</span>
<strong>${Number(page.viewCount)} 次浏览</strong>
</div>
<h2>${escapePublicHtml(page.title)}</h2>
<p>${escapePublicHtml(page.summary || '暂无摘要')}</p>
<div class="card-foot">
<span>${new Date(page.publishedAt).toLocaleDateString('zh-CN')}</span>
<span>打开页面</span>
</div>
</a>`,
)
.join('');
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'">
<title>${escapePublicHtml(data.owner.displayName)} · MindSpace</title>
<style>
:root { color: #17221d; background: #f5f0e5; font-family: 'Iowan Old Style', 'Palatino Linotype', serif; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background:
radial-gradient(circle at top right, rgba(79, 137, 112, .18), transparent 32rem),
linear-gradient(180deg, #f9f6ef 0%, #f1eadc 100%);
color: #17221d;
}
main { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0 72px; }
.hero { padding: 28px 0 32px; border-bottom: 1px solid rgba(23, 34, 29, .12); }
.eyebrow { margin: 0 0 12px; color: #8f6b2f; font: 700 12px/1.4 ui-sans-serif, sans-serif; letter-spacing: .18em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(40px, 8vw, 82px); line-height: .96; letter-spacing: -.05em; }
.lead { max-width: 760px; margin: 18px 0 0; color: #56615b; font: 18px/1.8 ui-sans-serif, sans-serif; }
.stats { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 22px; }
.stats span { border: 1px solid rgba(23, 34, 29, .12); border-radius: 999px; padding: 10px 14px; background: rgba(255,255,255,.6); font: 600 13px/1 ui-sans-serif, sans-serif; color: #405048; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; margin-top: 34px; }
.card { display: flex; flex-direction: column; gap: 16px; min-height: 230px; padding: 22px; border-radius: 24px; text-decoration: none; color: inherit; background: rgba(255,253,247,.92); border: 1px solid rgba(23, 34, 29, .08); box-shadow: 0 20px 60px rgba(45, 53, 47, .08); transition: transform .18s ease, box-shadow .18s ease; }
.card:hover { transform: translateY(-2px); box-shadow: 0 24px 70px rgba(45, 53, 47, .12); }
.card-top, .card-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; font: 600 12px/1.4 ui-sans-serif, sans-serif; color: #6a746f; text-transform: uppercase; letter-spacing: .08em; }
.card h2 { margin: 0; font-size: 28px; line-height: 1.08; letter-spacing: -.03em; }
.card p { margin: 0; color: #56615b; font: 15px/1.75 ui-sans-serif, sans-serif; }
.empty { margin-top: 32px; padding: 28px; border-radius: 24px; background: rgba(255,253,247,.76); border: 1px dashed rgba(23, 34, 29, .18); color: #56615b; font: 16px/1.7 ui-sans-serif, sans-serif; }
@media (max-width: 640px) {
main { width: min(100% - 24px, 1120px); padding: 24px 0 48px; }
.hero { padding-top: 12px; }
.grid { gap: 14px; }
.card { min-height: 0; border-radius: 20px; }
.card h2 { font-size: 24px; }
}
</style>
</head>
<body>
<main>
<section class="hero">
<p class="eyebrow">MindSpace Public</p>
<h1>${escapePublicHtml(data.owner.displayName)}</h1>
<p class="lead">这里展示 ${escapePublicHtml(data.owner.displayName)} 当前公开发布且在线的 MindSpace 页面。</p>
<div class="stats">
<span>${Number(data.pageCount)} 个公开页面</span>
<span>${Number(data.totalViews)} 次累计浏览</span>
<span>/u/${escapePublicHtml(data.owner.slug)}</span>
</div>
</section>
${
data.pages.length
? `<section class="grid">${cards}</section>`
: '<section class="empty">这个主页还没有公开页面。稍后再来看看,或者直接访问作者分享给你的专属链接。</section>'
}
</main>
</body>
</html>`;
}
async function resolvePublishedRoute(req, res, password = null) {
await userAuthReady;
if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用');
try {
const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null;
const result = await mindSpacePublications.resolvePublic(
req.params.ownerSlug,
req.params.urlSlug,
viewer?.id,
password,
{
userAgent: req.get('user-agent'),
referrer: req.get('referer'),
},
);
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));
}
if (error?.code === 'publication_login_required') {
return res.status(401).send('请先登录 TKMind 后再访问此页面');
}
if (error?.code === 'publication_not_found') return res.status(404).send('页面不存在或已下线');
return res.status(500).send('页面加载失败');
}
}
app.get('/u/:ownerSlug/pages/:urlSlug', async (req, res) => {
return resolvePublishedRoute(req, res);
});
app.get('/u/:ownerSlug', async (req, res) => {
await userAuthReady;
if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用');
try {
const data = await mindSpacePublications.getPublicHomepage(req.params.ownerSlug);
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'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
);
res.set('Cache-Control', 'public, max-age=60');
return res.send(publicHomepageHtml(data));
} catch (error) {
if (error?.code === 'publication_not_found') return res.status(404).send('主页不存在');
return res.status(500).send('主页加载失败');
}
});
app.post(
'/u/:ownerSlug/pages/:urlSlug',
express.urlencoded({ extended: false, limit: '2kb' }),
async (req, res) => resolvePublishedRoute(req, res, req.body?.password),
);
app.get('/s/:token', async (req, res) => {
await userAuthReady;
if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用');
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),
raw: String(req.query.view ?? '').toLowerCase() === 'raw',
},
);
} catch (error) {
if (error?.code === 'publication_not_found') return res.status(404).send('页面不存在或已下线');
return res.status(500).send('页面加载失败');
}
});
const USERNAME_SLUG = /^[a-z0-9_]{2,32}$/;
async function resolvePublishDirKey(segment) {
const lower = String(segment ?? '').trim().toLowerCase();
if (!lower) return null;
if (PUBLISH_KEY_UUID.test(lower)) return lower;
if (USERNAME_SLUG.test(lower)) {
if (authPool) {
const [rows] = await authPool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [lower]);
if (rows[0]?.id) return String(rows[0].id).toLowerCase();
}
return lower;
}
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) {
res.status(404).json({ message: '未找到页面' });
return;
}
const dirKey = await resolvePublishDirKey(parts[0]);
if (!dirKey) {
res.status(404).json({ message: '未找到页面' });
return;
}
if (parts[0].toLowerCase() !== dirKey && USERNAME_SLUG.test(parts[0])) {
const rest = parts.slice(1).map(encodeURIComponent).join('/');
const target = `/${PUBLISH_ROOT_DIR}/${dirKey}${rest ? `/${rest}` : '/'}`;
res.redirect(301, target);
return;
}
const [username, ...rest] = [dirKey, ...parts.slice(1)];
const targetDir = path.join(__dirname, PUBLISH_ROOT_DIR, username);
const resolvedRoot = path.resolve(targetDir);
const filePath = path.join(targetDir, ...rest);
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(`${resolvedRoot}${path.sep}`) && resolvedPath !== resolvedRoot) {
res.status(403).json({ message: '禁止访问' });
return;
}
if (!fs.existsSync(targetDir)) {
res.status(404).json({ message: '用户不存在' });
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]);
if (
publicFallback.startsWith(`${resolvedRoot}${path.sep}`) &&
fs.existsSync(publicFallback) &&
fs.statSync(publicFallback).isFile()
) {
sendPublishFile(req, res, publicFallback);
return;
}
}
res.status(404).json({ message: '文件不存在' });
return;
}
if (fs.statSync(resolvedPath).isDirectory()) {
const indexPath = path.join(resolvedPath, 'index.html');
if (fs.existsSync(indexPath)) {
sendPublishFile(req, res, indexPath);
return;
}
res.status(404).json({ message: '目录中没有 index.html' });
return;
}
sendPublishFile(req, res, resolvedPath);
}
app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => {
await userAuthReady;
return serveUserPublishFile(req, res, next);
});
app.use('/temp', (req, res) => {
res.redirect(301, `/${PUBLISH_ROOT_DIR}${req.url}`);
});
// Serve user public pages: /user/<username>/path/to/file
// These are public-facing pages (like travel guides) stored in user directories
// Accessed via URL like https://goo.tkmind.cn/user/john/vietnam-guide/
app.use('/user', async (req, res, next) => {
await userAuthReady;
// Path format: /user/<username>/<rest...>
const parts = req.path.split('/').filter(Boolean);
if (parts.length < 1) return next();
const [username, ...rest] = parts;
const targetDir = path.join(USERS_ROOT, username);
// Security: ensure resolved path is within the user's directory
const filePath = path.join(targetDir, ...rest);
const resolvedRoot = path.resolve(targetDir);
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(resolvedRoot + path.sep) && resolvedPath !== resolvedRoot) {
return res.status(403).json({ message: '禁止访问' });
}
// Check if the user directory exists
if (!fs.existsSync(targetDir)) {
return res.status(404).json({ message: '用户不存在' });
}
if (!fs.existsSync(resolvedPath)) {
return res.status(404).json({ message: '文件不存在' });
}
// If it's a directory, serve index.html
if (fs.statSync(resolvedPath).isDirectory()) {
const indexPath = path.join(resolvedPath, 'index.html');
if (fs.existsSync(indexPath)) {
return res.sendFile(indexPath);
}
return res.status(404).json({ message: '目录中没有 index.html' });
}
res.sendFile(resolvedPath, (err) => {
if (err) res.status(404).json({ message: '文件不存在' });
});
});
app.get(`/${PUBLISH_ROOT_DIR}/wiki/*`, (_req, res) => {
res.sendFile(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki', 'index.html'));
});
app.get('/temp/wiki/*', (req, res) => {
res.redirect(301, `/${PUBLISH_ROOT_DIR}/wiki${req.url.slice('/temp/wiki'.length)}`);
});
app.use(
'/plaza-covers',
express.static(path.join(__dirname, 'public/plaza-covers'), { maxAge: '7d' }),
);
app.get(/^\/MP_verify_[A-Za-z0-9]+\.txt$/, (req, res) => {
const fileName = path.basename(req.path);
const filePath = path.join(__dirname, 'public', fileName);
if (!fs.existsSync(filePath)) return res.status(404).end();
res.type('text/plain').sendFile(filePath);
});
app.use(express.static(path.join(__dirname, 'dist'), { index: 'index.html' }));
app.get('*', (_req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
userAuthReady.then((enabled) => {
app.listen(PORT, '127.0.0.1', () => {
console.log(`TKMind H5 @ http://127.0.0.1:${PORT}`);
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`);
});
});