Files
memind/server.mjs
T
Your Name b0f5d6a51c Extract memind_adm admin server, add local dev tooling, and remove image-generation.
Split platform admin and ops APIs into standalone admin-server.mjs with network guards; simplify billing to RMB token pricing, refactor user auth, and add rsync deploy plus local-test scripts and docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 16:39:39 -07:00

3155 lines
110 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 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 { 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 { scanContent } from './mindspace-content-scan.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 { 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_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;
// 无状态前端节点(如 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 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 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,
});
plazaPosts = createPlazaPostService(pool, {
loadViewerReactions: (viewerId, postIds) =>
plazaInteractions.loadViewerReactions(viewerId, postIds),
plazaRedis,
algorithmConfig: plazaAlgorithmConfig,
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),
});
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,
apiSecret: API_SECRET,
userAuth,
llmProviderService,
});
mindSpacePageEditSession = createPageEditSessionService({
apiTarget: API_TARGET,
apiSecret: API_SECRET,
userAuth,
pageService: mindSpacePages,
pageLiveEdit: mindSpacePageLiveEdit,
llmProviderService,
});
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/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 });
});
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',
});
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 });
}
});
// ============ 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 (method !== 'GET' || !path.startsWith('/plaza/v1/')) 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)
);
}
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';
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 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,
});
return sendData(res, req, feed);
} 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,
);
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 ?? {});
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(() => {});
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) {
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 llmProviderService.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 upstream = await tkmindProxy.apiFetch(`/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 sendPublishedPage(res, result, { embed = false } = {}) {
let html = result.html;
if (embed) {
html = preparePublicationHtmlForEmbed(html);
}
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(res, result, { embed: isPlazaEmbedRequest(req.query) });
} 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(
res,
await mindSpacePublications.resolvePrivateLink(req.params.token, viewer?.id, {
userAgent: req.get('user-agent'),
referrer: req.get('referer'),
}),
{ embed: isPlazaEmbedRequest(req.query) },
);
} 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;
}
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;
}
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()
) {
res.sendFile(publicFallback);
return;
}
}
res.status(404).json({ message: '文件不存在' });
return;
}
if (fs.statSync(resolvedPath).isDirectory()) {
const indexPath = path.join(resolvedPath, 'index.html');
if (fs.existsSync(indexPath)) {
res.sendFile(indexPath);
return;
}
res.status(404).json({ message: '目录中没有 index.html' });
return;
}
res.sendFile(resolvedPath, (err) => {
if (err) res.status(404).json({ message: '文件不存在' });
});
}
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_TARGET}`);
console.log(`Auth -> ${enabled ? 'multi-user (MySQL)' : legacyAuth ? 'legacy password' : 'disabled'}`);
console.log(`Wiki @ http://127.0.0.1:${PORT}/${PUBLISH_ROOT_DIR}/wiki`);
});
});