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, USER_COOKIE, userSessionCookie, } from './user-auth.mjs'; import { createWikiAuth } from './wiki-auth.mjs'; import { PUBLISH_ROOT_DIR, resolvePublishDir } from './user-publish.mjs'; import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher } 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 { 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, hasOpsRole } from './plaza-ops.mjs'; import { createCleanupService } from './mindspace-cleanup.mjs'; import { createAgentJobService } from './mindspace-agent-jobs.mjs'; import { createMindSpaceAgentRunner } from './mindspace-agent-runner.mjs'; import { analyzeChatMessageForSave, resolveStaticHtmlContent, } from './mindspace-chat-save.mjs'; import { scanContent } from './mindspace-content-scan.mjs'; import { createRechargeService } from './billing-recharge.mjs'; import { createWechatPayClient, loadWechatPayConfig } from './wechat-pay.mjs'; import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.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; 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=(), geolocation=()'); res.setHeader('X-Frame-Options', 'SAMEORIGIN'); if (isSecureRequest(req)) { res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } next(); }); function isLoopbackHostname(hostname) { return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; } 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 ( isLoopbackHostname(requestHostname) && isLoopbackHostname(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 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 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 llmProviderService = null; async function bootstrapUserAuth() { try { if (!isDatabaseConfigured()) return false; const pool = createDbPool(); 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'), }); 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 ?? 1000), }); wechatPayClient = createWechatPayClient(loadWechatPayConfig()); rechargeService = createRechargeService(pool, { userAuth, wechatPay: wechatPayClient, }); if (wechatPayClient.enabled) { console.log('WeChat Pay recharge 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); startWorkspaceThumbnailWatcher(path.join(__dirname, PUBLISH_ROOT_DIR)); startWorkspaceAssetSyncWatcher({ publishRoot: path.join(__dirname, PUBLISH_ROOT_DIR), syncUserWorkspaceByUsername: async (username, options) => { const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [username]); const 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?.(); 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 }); 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]; } 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 }); } res.set('Set-Cookie', userSessionCookie(result.token, secure)); 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/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)); res.set('Set-Cookie', clearUserSessionCookie(secure)); } if (legacyAuth) { legacyAuth.revoke(legacySessionToken(req)); res.set('Set-Cookie', clearSessionCookie(secure)); } res.status(204).end(); }); // ============ Admin API ============ function requireAdmin(req, res, next) { if (!req.currentUser || req.currentUser.role !== 'admin') { res.status(403).json({ message: '需要管理员权限' }); return; } next(); } const adminApi = express.Router(); adminApi.use(jsonBody); adminApi.use(async (req, res, next) => { 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: '未登录' }); req.currentUser = me; next(); }); adminApi.get('/users', requireAdmin, async (_req, res) => { const users = await userAuth.listUsers(); res.json({ users }); }); adminApi.get('/summary', requireAdmin, async (_req, res) => { const summary = await userAuth.getAdminSummary(); let llm = null; if (llmProviderService) { const keys = await llmProviderService.listKeys(); const global = await llmProviderService.getGlobalSettings(); const selected = keys.find((key) => key.isSelected); llm = { keyCount: keys.length, selectedKeyName: selected?.name ?? null, globalModel: global?.model ?? null, }; } res.json({ summary: { ...summary, llm } }); }); adminApi.post('/users', requireAdmin, async (req, res) => { const result = await userAuth.createUser(req.body ?? {}); if (!result.ok) return res.status(400).json({ message: result.message }); res.status(201).json({ user: result.user }); }); adminApi.patch('/users/:userId', requireAdmin, async (req, res) => { const result = await userAuth.updateUser(req.params.userId, req.body ?? {}); if (!result.ok) return res.status(400).json({ message: result.message }); res.json({ user: result.user }); }); adminApi.post('/users/:userId/recharge', requireAdmin, async (req, res) => { const amountCents = Number(req.body?.amountCents); const note = typeof req.body?.note === 'string' ? req.body.note : ''; const result = await userAuth.recharge( req.params.userId, amountCents, req.currentUser.id, note, ); if (!result.ok) return res.status(400).json({ message: result.message }); res.json({ user: result.user }); }); adminApi.get('/usage', requireAdmin, async (req, res) => { const userId = typeof req.query.userId === 'string' ? req.query.userId : null; const limit = Number(req.query.limit ?? 50); const records = await userAuth.listUsageRecords({ userId, limit }); res.json({ records }); }); adminApi.get('/capabilities/catalog', requireAdmin, (_req, res) => { res.json({ catalog: userAuth.capabilityCatalog }); }); adminApi.get('/capabilities/role/:role', requireAdmin, async (req, res) => { const role = req.params.role === 'admin' ? 'admin' : 'user'; if (role === 'admin') { return res.json({ role, capabilities: Object.fromEntries( userAuth.capabilityCatalog.map((item) => [item.key, true]), ), unrestricted: true, }); } res.json(await userAuth.getRoleCapabilities('user')); }); adminApi.put('/capabilities/role/:role', requireAdmin, async (req, res) => { const role = req.params.role === 'admin' ? 'admin' : 'user'; const result = await userAuth.setRoleCapabilities(role, req.body?.capabilities ?? {}); if (!result.ok) return res.status(400).json({ message: result.message }); res.json(result); }); adminApi.get('/users/:userId/capabilities', requireAdmin, async (req, res) => { const result = await userAuth.getUserCapabilities(req.params.userId); if (!result.ok) return res.status(404).json({ message: result.message }); res.json(result); }); adminApi.put('/users/:userId/capabilities', requireAdmin, async (req, res) => { const result = await userAuth.setUserCapabilities( req.params.userId, req.body?.capabilities ?? {}, ); if (!result.ok) return res.status(400).json({ message: result.message }); res.json(result); }); adminApi.delete('/users/:userId/capabilities', requireAdmin, async (req, res) => { const result = await userAuth.clearUserCapabilityOverrides(req.params.userId); if (!result.ok) return res.status(404).json({ message: result.message }); res.json(result); }); adminApi.get('/policies/catalog', requireAdmin, (_req, res) => { res.json({ catalog: userAuth.policyCatalog }); }); adminApi.get('/policies/role/:role', requireAdmin, async (req, res) => { const role = req.params.role === 'admin' ? 'admin' : 'user'; if (role === 'admin') { return res.json({ role, policies: {}, unrestricted: true }); } res.json(await userAuth.getRolePolicies('user')); }); adminApi.put('/policies/role/:role', requireAdmin, async (req, res) => { const role = req.params.role === 'admin' ? 'admin' : 'user'; const result = await userAuth.setRolePolicies(role, req.body?.policies ?? {}); if (!result.ok) return res.status(400).json({ message: result.message }); res.json(result); }); adminApi.get('/users/:userId/policies', requireAdmin, async (req, res) => { const result = await userAuth.getUserPolicies(req.params.userId); if (!result.ok) return res.status(404).json({ message: result.message }); res.json(result); }); adminApi.put('/users/:userId/policies', requireAdmin, async (req, res) => { const result = await userAuth.setUserPolicies(req.params.userId, req.body?.policies ?? {}); if (!result.ok) return res.status(400).json({ message: result.message }); res.json(result); }); adminApi.delete('/users/:userId/policies', requireAdmin, async (req, res) => { const result = await userAuth.clearUserPolicyOverrides(req.params.userId); if (!result.ok) return res.status(404).json({ message: result.message }); res.json(result); }); adminApi.get('/skills/catalog', requireAdmin, (_req, res) => { res.json({ catalog: userAuth.skillCatalog }); }); adminApi.get('/skills/role/:role', requireAdmin, async (req, res) => { const role = req.params.role === 'admin' ? 'admin' : 'user'; if (role === 'admin') { return res.json({ role, skills: Object.fromEntries(userAuth.skillCatalog.map((item) => [item.name, true])), }); } res.json(await userAuth.getRoleSkills('user')); }); adminApi.put('/skills/role/:role', requireAdmin, async (req, res) => { const role = req.params.role === 'admin' ? 'admin' : 'user'; const result = await userAuth.setRoleSkills(role, req.body?.skills ?? {}); if (!result.ok) return res.status(400).json({ message: result.message }); res.json(result); }); adminApi.get('/users/:userId/skills', requireAdmin, async (req, res) => { const result = await userAuth.getUserSkills(req.params.userId); if (!result.ok) return res.status(404).json({ message: result.message }); res.json(result); }); adminApi.put('/users/:userId/skills', requireAdmin, async (req, res) => { const result = await userAuth.setUserSkills(req.params.userId, req.body?.skills ?? {}); if (!result.ok) return res.status(400).json({ message: result.message }); res.json(result); }); adminApi.delete('/users/:userId/skills', requireAdmin, async (req, res) => { const result = await userAuth.clearUserSkillOverrides(req.params.userId); if (!result.ok) return res.status(404).json({ message: result.message }); res.json(result); }); adminApi.get('/ledger', requireAdmin, async (req, res) => { const userId = typeof req.query.userId === 'string' ? req.query.userId : null; const limit = Number(req.query.limit ?? 50); const entries = await userAuth.listBillingLedger({ userId, limit }); res.json({ entries }); }); adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); res.json({ catalog: llmProviderService.catalog }); }); adminApi.get('/llm-providers/keys', requireAdmin, async (_req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); const keys = await llmProviderService.listKeys(); res.json({ keys }); }); adminApi.post('/llm-providers/keys', requireAdmin, async (req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); const result = await llmProviderService.createKey(req.body ?? {}); if (!result.ok) return res.status(400).json({ message: result.message }); res.status(201).json({ key: result.key }); }); adminApi.patch('/llm-providers/keys/:keyId', requireAdmin, async (req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); const result = await llmProviderService.updateKey(req.params.keyId, req.body ?? {}); if (!result.ok) return res.status(400).json({ message: result.message }); res.json({ key: result.key }); }); adminApi.post('/llm-providers/keys/:keyId/select', requireAdmin, async (req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); const result = await llmProviderService.selectKey(req.params.keyId); if (!result.ok) return res.status(400).json({ message: result.message }); res.json({ key: result.key }); }); adminApi.delete('/llm-providers/keys/:keyId', requireAdmin, async (req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); const result = await llmProviderService.deleteKey(req.params.keyId); if (!result.ok) return res.status(400).json({ message: result.message }); res.json({ ok: true }); }); adminApi.post('/llm-providers/sync', requireAdmin, async (_req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); try { const result = await llmProviderService.syncSelectedToGoosed(); res.json(result); } catch (err) { res.status(500).json({ message: err instanceof Error ? err.message : '同步失败', }); } }); adminApi.get('/llm-providers/global', requireAdmin, async (_req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); const global = await llmProviderService.getGlobalSettings(); res.json({ global }); }); adminApi.put('/llm-providers/global', requireAdmin, async (req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); const result = await llmProviderService.setGlobalModel(req.body?.model); if (!result.ok) return res.status(400).json({ message: result.message }); res.json(result); }); adminApi.post('/llm-providers/test', requireAdmin, async (req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); try { const result = await llmProviderService.testDraft(req.body ?? {}); res.json(result); } catch (err) { res.status(500).json({ ok: false, message: err instanceof Error ? err.message : '联通测试失败', }); } }); adminApi.post('/llm-providers/keys/:keyId/test', requireAdmin, async (req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); try { const result = await llmProviderService.testKey(req.params.keyId, req.body?.model); res.json(result); } catch (err) { res.status(500).json({ ok: false, message: err instanceof Error ? err.message : '联通测试失败', }); } }); adminApi.get('/plaza/pending', requireAdmin, async (_req, res) => { if (!plazaPosts) return res.status(503).json({ message: 'Plaza 未启用' }); const posts = await plazaPosts.listPendingPosts(); res.json({ data: { posts } }); }); adminApi.post('/plaza/posts/:id/review', requireAdmin, async (req, res) => { if (!plazaPosts) return res.status(503).json({ message: 'Plaza 未启用' }); try { const action = String(req.body?.action ?? 'approve'); const reason = req.body?.reason ?? null; const result = plazaOps && req.currentUser?.id ? await plazaOps.reviewPostAsOps(req.currentUser.id, req.params.id, action, { reason }) : await plazaPosts.reviewPost(req.params.id, action, { reason }); res.json({ data: result }); } catch (error) { const status = mapPlazaError(error); res.status(status).json({ error: { code: error?.code ?? 'review_failed', message: error.message }, }); } }); 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/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 = req.body?.payScene === 'h5' ? 'h5' : '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', limit: '64kb' }); app.post('/webhooks/wechat-pay/notify', wechatNotifyBody, async (req, res) => { await userAuthReady; if (!rechargeService || !wechatPayClient?.enabled) { 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 }); return res.json({ code: 'SUCCESS', message: '成功' }); } catch (err) { console.error('WeChat notify failed:', err); return res.status(500).json({ code: 'FAIL', message: err instanceof Error ? err.message : '处理失败' }); } }); app.use('/admin-api', adminApi); // ============ 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(jsonBody); api.use(async (req, res, next) => { await userAuthReady; if (req.path === '/status') return next(); if (req.path.startsWith('/internal/agent/')) 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: '未授权,请重新登录' }); }); 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); 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); } 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 makeRequireOps(minRole = 'reviewer') { return async (req, res, next) => { if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); const role = await plazaOps.loadOperatorRole(req.currentUser.id); if (!hasOpsRole(role, minRole)) { return sendError(res, req, 403, 'OPS_PERMISSION_DENIED', '无运营权限'); } req.opsRole = role; return next(); }; } const opsApi = express.Router(); opsApi.use(jsonBody); opsApi.use(makeRequireOps('reviewer')); opsApi.get('/review/queue', async (req, res) => { try { const queue = await plazaOps.listReviewQueue({ status: req.query.status ?? 'pending_review', cursor: req.query.cursor ?? null, limit: req.query.limit, keyword: req.query.keyword ?? null, }); return sendData(res, req, queue); } catch (error) { return plazaRouteError(res, req, error); } }); opsApi.post('/review/posts/:id', async (req, res) => { try { const result = await plazaOps.reviewPostAsOps(req.currentUser.id, req.params.id, req.body?.action, { reason: req.body?.reason ?? null, }); return sendData(res, req, { post: result }); } catch (error) { return plazaRouteError(res, req, error); } }); opsApi.post('/review/batch', makeRequireOps('editor'), async (req, res) => { try { const result = await plazaOps.batchReviewPosts(req.currentUser.id, req.body ?? {}); return sendData(res, req, result); } catch (error) { return plazaRouteError(res, req, error); } }); opsApi.get('/reports', async (req, res) => { try { return sendData(res, req, await plazaOps.listReports({ status: req.query.status ?? 'pending' })); } catch (error) { return plazaRouteError(res, req, error); } }); opsApi.post('/reports/:id/process', async (req, res) => { try { const result = await plazaOps.processReport(req.currentUser.id, req.params.id, req.body ?? {}); return sendData(res, req, { report: result }); } catch (error) { return plazaRouteError(res, req, error); } }); opsApi.get('/featured', makeRequireOps('editor'), async (req, res) => { try { return sendData(res, req, await plazaOps.listFeatured()); } catch (error) { return plazaRouteError(res, req, error); } }); opsApi.post('/featured', makeRequireOps('editor'), async (req, res) => { try { const result = await plazaOps.setFeatured(req.currentUser.id, req.body ?? {}); return sendData(res, req, { featured: result }, 201); } catch (error) { return plazaRouteError(res, req, error); } }); opsApi.delete('/featured/:id', makeRequireOps('editor'), async (req, res) => { try { const result = await plazaOps.removeFeatured(req.currentUser.id, req.params.id); return sendData(res, req, { featured: result }); } catch (error) { return plazaRouteError(res, req, error); } }); opsApi.get('/analytics/overview', makeRequireOps('ops_admin'), async (req, res) => { try { return sendData(res, req, await plazaOps.getAnalyticsOverview()); } catch (error) { return plazaRouteError(res, req, error); } }); opsApi.get('/creators', async (req, res) => { try { return sendData(res, req, await plazaOps.listCreators({ keyword: req.query.keyword ?? null })); } catch (error) { return plazaRouteError(res, req, error); } }); opsApi.patch('/creators/:userId', async (req, res) => { try { const result = await plazaOps.updateCreator(req.currentUser.id, req.params.userId, req.body ?? {}); return sendData(res, req, { creator: result }); } catch (error) { return plazaRouteError(res, req, error); } }); 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, }; const code = error?.code ?? 'internal_error'; const status = statusByCode[code] ?? 500; const message = status === 500 ? 'MindSpace 服务异常' : error.message; 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 { asset, path: assetPath } = await mindSpaceAssets.readAsset( req.currentUser.id, req.body?.asset_id, ); 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 }, }); } } api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const source = await resolveOwnedAssistantMessage( req.currentUser.id, req.body?.session_id, req.body?.message_id, ); const analysis = analyzeChatMessageForSave({ content: source.content, username: req.currentUser.username, h5Root: __dirname, selectedLinkIndex: Number(req.body?.selected_link_index ?? 0), }); let resolvedHtml = null; if (analysis.contentMode === 'static_html') { try { resolvedHtml = await resolveStaticHtmlContent(analysis); } catch { resolvedHtml = null; } } 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, relativePath: analysis.relativePath, 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, 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); 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); } 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.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.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/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/redacted-copy', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const result = await mindSpacePages.createRedactedCopy(req.currentUser.id, req.params.pageId, { pageVersionId: req.body?.page_version_id, }); await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'page.redacted_copy', objectType: 'page', objectId: result.page.id, ip: req.ip, riskLevel: result.originalScan.riskLevel, }); return sendData(res, req, result, 201); } 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('/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.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(); 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 (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('/ops/v1', opsApi); 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) { const isFullHtml = /^\s*]/i.test(html); 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) { res.set('Content-Type', 'text/html; charset=utf-8'); res.set('Content-Security-Policy', publishedPageCsp(result.html)); res.set( 'Cache-Control', result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store', ); return res.send(result.html); } function passwordGateHtml(action) { return ` 受保护页面

此页面受密码保护

请输入发布者提供的访问密码。密码不会写入链接或浏览器日志。

`; } function escapePublicHtml(value) { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function publicHomepageHtml(data) { const cards = data.pages .map( (page) => `\
${escapePublicHtml(page.templateId)} ${Number(page.viewCount)} 次浏览

${escapePublicHtml(page.title)}

${escapePublicHtml(page.summary || '暂无摘要')}

${new Date(page.publishedAt).toLocaleDateString('zh-CN')} 打开页面
`, ) .join(''); return ` ${escapePublicHtml(data.owner.displayName)} · MindSpace

MindSpace Public

${escapePublicHtml(data.owner.displayName)}

这里展示 ${escapePublicHtml(data.owner.displayName)} 当前公开发布且在线的 MindSpace 页面。

${Number(data.pageCount)} 个公开页面 ${Number(data.totalViews)} 次累计浏览 /u/${escapePublicHtml(data.owner.slug)}
${ data.pages.length ? `
${cards}
` : '
这个主页还没有公开页面。稍后再来看看,或者直接访问作者分享给你的专属链接。
' }
`; } 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); } 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'), }), ); } 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}$/; function serveUserPublishFile(req, res, next) { const parts = req.path.split('/').filter(Boolean); if (parts.length < 1) { res.status(404).json({ message: '未找到页面' }); return; } const [username, ...rest] = parts; if (!USERNAME_SLUG.test(username)) { res.status(404).json({ message: '未找到页面' }); return; } 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)) { 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}`, serveUserPublishFile); app.use('/temp', (req, res) => { res.redirect(301, `/${PUBLISH_ROOT_DIR}${req.url}`); }); // Serve user public pages: /user//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// 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/{*splat}`, (_req, res) => { res.sendFile(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki', 'index.html')); }); app.get('/temp/wiki/{*splat}', (req, res) => { res.redirect(301, `/${PUBLISH_ROOT_DIR}/wiki${req.url.slice('/temp/wiki'.length)}`); }); app.use(express.static(path.join(__dirname, 'dist'), { index: 'index.html' })); app.get('/{*splat}', (_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`); }); });