import express from 'express'; import crypto from 'node:crypto'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; 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 { createAgentRunGateway } from './agent-run-gateway.mjs'; import { createToolGateway } from './tool-gateway.mjs'; import { createAgentRunEventsHandler, createGetAgentRunHandler, createPostAgentRunsHandler, } from './agent-run-routes.mjs'; import { createTkmindProxy, sanitizeSessionConversationPublicHtmlLinks } 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, resolvePublicBaseUrl } 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 { createNotificationDispatcher } from './notification-dispatcher.mjs'; import { createMindSpaceAuditWriter } from './mindspace-audit.mjs'; import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs'; import { createMindSpaceService, DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs'; import { ensureMindSpaceConfig } from './mindspace-config.mjs'; import { createAssetService } from './mindspace-assets.mjs'; import { createPageService, pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; import { createPageLiveEditService } from './mindspace-page-live-edit.mjs'; import { createAssetAgentService } from './mindspace-asset-agent.mjs'; import { createPageEditSessionService } from './mindspace-page-edit-session.mjs'; import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs'; import { createPublicationService, rewriteWorkspacePublicAssetReferences } from './mindspace-publications.mjs'; import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs'; import { createPlazaEventService } from './plaza-events.mjs'; import { createPlazaRecommendService } from './plaza-recommend.mjs'; import { createPlazaInteractionService } from './plaza-interactions.mjs'; import { ensureAlgorithmConfig, loadAlgorithmConfig, recalculateHotScores, } from './plaza-algorithm.mjs'; import { createPlazaRedis, createNoopPlazaRedis } from './plaza-redis.mjs'; import { startPlazaTasks, writebackPublications } from './plaza-tasks.mjs'; import { createPlazaSeoService } from './plaza-seo.mjs'; import { createPlazaOpsService } from './plaza-ops.mjs'; import { createWordFilterService } from './word-filter.mjs'; import { allowPlazaEmbedFrame, preparePublicationHtmlForEmbed, isPlazaEmbedRequest, publishedPageCspForEmbed, stripPublicationHtmlCspMeta, } 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, resolveClosestHtmlRelativePath, createAssetDataUriResolver, materializePrivateAssetsInWorkspaceHtml, repairMissingHtmlAssetReferences, resolveChatSaveAnalysis, resolveStaticHtmlContent, } from './mindspace-chat-save.mjs'; import { materializePublicHtmlWritesFromSessionEvent, syncPublicHtmlAfterFinish, } from './mindspace-public-finish-sync.mjs'; import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs'; import { extractCoverSignals, generateHtmlThumbnail } from './mindspace-thumbnails.mjs'; import { extractSharePreviewMeta, injectOgTags, injectWechatShareBridge, renderWechatSharePreviewHtml, } from './mindspace-og-tags.mjs'; import { ensureThumbnailPng, rasterizeThumbnailSvgToPng, thumbnailPngPathForSvg, } from './mindspace-thumbnail-png.mjs'; import { scanContent } from './mindspace-content-scan.mjs'; import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs'; import { createRechargeService } from './billing-recharge.mjs'; import { createSubscriptionService, createPlanCatalogService, ensurePlanCatalogSchema, PLAN_CATALOG } from './billing-subscription.mjs'; import { createWechatPayClient, loadWechatPayConfig, WECHAT_NOTIFY_SUCCESS_V2, } from './wechat-pay.mjs'; import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs'; import { createWechatMpService, loadWechatMpConfig } from './wechat-mp.mjs'; import { validateWechatShareSignatureUrl } from './wechat-share.mjs'; import { createScheduleService } from './schedule-service.mjs'; import { createFeedbackService } from './user-feedback.mjs'; import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs'; import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs'; import { createSessionSnapshotService } from './session-snapshot.mjs'; import { createConversationMemoryService } from './conversation-memory.mjs'; import { createExperienceService } from './experience-service.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')); function parseApiTargets() { const csvTargets = (process.env.TKMIND_API_TARGETS ?? '') .split(',') .map((value) => value.trim()) .filter(Boolean); const legacyTargets = [ process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006', process.env.TKMIND_API_TARGET_1, ].filter(Boolean); return [...new Set([...csvTargets, ...legacyTargets])]; } const PORT = Number(process.env.H5_PORT ?? 8081); const HOST = String(process.env.H5_HOST ?? '127.0.0.1').trim() || '127.0.0.1'; const API_TARGETS = parseApiTargets(); const API_TARGET = API_TARGETS[0] ?? '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 WECHAT_MP_CONFIG = loadWechatMpConfig(); // 无状态前端节点(如 105,MindSpace 经 rclone 挂载)需设 MEMIND_WORKSPACE_MAINTENANCE=0, // 否则启动时对挂载树做 readdir/递归 fs.watch 会占满 libuv 线程池导致 boot 卡死。 const WORKSPACE_MAINTENANCE_ENABLED = process.env.MEMIND_WORKSPACE_MAINTENANCE !== '0'; 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 ?? DEFAULT_MAX_FILE_BYTES), }); const wikiAuth = createWikiAuth(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki-db')); let legacyAuth = null; if (ACCESS_PASSWORD && !isDatabaseConfigured()) { legacyAuth = createAuthManager({ password: ACCESS_PASSWORD }); } else if (ACCESS_PASSWORD && isDatabaseConfigured()) { console.log('H5_ACCESS_PASSWORD ignored: multi-user database auth is configured'); } let userAuth = null; let tkmindProxy = null; let agentRunGateway = null; let toolGateway = null; let sessionSnapshotService = null; let conversationMemoryService = null; let mindSpace = null; let mindSpaceAssets = null; let mindSpaceAudit = null; let mindSpacePages = null; let mindSpacePageLiveEdit = null; let mindSpaceAssetAgent = null; let mindSpacePageEditSession = null; let mindSpacePublications = null; let plazaPosts = null; let plazaEvents = null; let plazaRecommend = null; let plazaInteractions = null; let plazaSeo = null; let plazaOps = null; let plazaRedis = createNoopPlazaRedis(); let mindSpaceCleanup = null; let mindSpaceAgentJobs = null; let mindSpaceAgentRunner = null; let rechargeService = null; let subscriptionService = null; let wechatPayClient = null; let wechatOAuthService = null; let wechatMpService = null; let notificationDispatcher = null; let scheduleService = null; let feedbackService = null; let scheduleReminderWorker = null; let llmProviderService = null; let wordFilterService = null; let authPool = null; async function bootstrapUserAuth() { try { if (!isDatabaseConfigured()) return false; const pool = createDbPool(); authPool = pool; await initSchema(pool); await ensureMindSpaceConfig(pool, { env: process.env, }); scheduleService = createScheduleService(pool, { defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', }); feedbackService = createFeedbackService(pool); mindSpace = createMindSpaceService(pool, { maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES), 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), scheduleService, }); mindSpaceAssets = createAssetService(pool, { h5Root: __dirname, storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'), maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES), }); mindSpacePages = createPageService(pool, { h5Root: __dirname, storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'), }); const 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; }; mindSpacePageLiveEdit = createPageLiveEditService({ pageService: mindSpacePages, resolveUserIdForAgentSession, }); mindSpaceAssetAgent = createAssetAgentService({ assetService: mindSpaceAssets, resolveUserIdForAgentSession, }); mindSpacePublications = createPublicationService(pool, { h5Root: __dirname, storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'), publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5), }); setInterval(async () => { try { const result = await mindSpacePublications.cleanupExpiredUnconfirmedPublications(); if (result.cleaned > 0) { console.log(`[Publication Cleanup] Auto-privatized ${result.cleaned} expired unconfirmed publications`); } } catch (err) { console.error('[Publication Cleanup Error]', err instanceof Error ? err.message : err); } }, 60 * 1000); await ensureAlgorithmConfig(pool); const plazaAlgorithmConfig = await loadAlgorithmConfig(pool); plazaRedis = await createPlazaRedis(process.env.PLAZA_REDIS_URL, pool); if (plazaRedis.enabled) { console.log('Plaza Redis enabled'); } plazaSeo = createPlazaSeoService(pool); plazaInteractions = createPlazaInteractionService(pool, { formatPostRow, plazaRedis, }); plazaEvents = createPlazaEventService(pool); plazaRecommend = createPlazaRecommendService(pool, { eventService: plazaEvents, formatPostRow, loadViewerReactions: (viewerId, postIds) => plazaInteractions.loadViewerReactions(viewerId, postIds), algorithmConfig: plazaAlgorithmConfig, }); plazaPosts = createPlazaPostService(pool, { loadViewerReactions: (viewerId, postIds) => plazaInteractions.loadViewerReactions(viewerId, postIds), plazaRedis, algorithmConfig: plazaAlgorithmConfig, recommendService: plazaRecommend, onPostPublished: (postId) => plazaSeo?.notifyPostPublished(postId), loadFeaturedPosts: async (viewerId) => { if (!plazaOps) return { homepage_banner: [], trending: [], category_top: {} }; return plazaOps.loadActiveFeaturedPosts(viewerId); }, }); plazaOps = createPlazaOpsService(pool, { formatPostRow, reviewPost: (...args) => plazaPosts.reviewPost(...args), invalidateFeedCaches: () => plazaRedis?.invalidateFeedCaches?.(), }); startPlazaTasks({ pool, plazaRedis, recalculateHotScores, writebackPublications, }); mindSpaceCleanup = createCleanupService(pool, { storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'), h5Root: __dirname, }); await ensurePlanCatalogSchema(pool); const planCatalogService = createPlanCatalogService(pool); subscriptionService = createSubscriptionService(pool, { getPlanAsync: (planType) => planCatalogService.getPlan(planType), }); subscriptionService._planCatalogService = planCatalogService; userAuth = createUserAuth(pool, { usersRoot: USERS_ROOT, h5Root: __dirname, defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 500), subscriptionService, }); 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 ?? DEFAULT_MAX_FILE_BYTES), }); // Shared experience store (etat C): retrieval before / recording after each // agent job, so all instances learn from one another. Gated so it can be // disabled without touching the runner. Polyglot: when EXPERIENCE_PG_URL is // set we use PostgreSQL + pgvector (semantic search) for this workload only; // the MySQL business DB is untouched. Falls back to MySQL keyword store if PG // init fails (e.g. driver missing) so a misconfig never blocks startup. let experienceService = null; if (process.env.MINDSPACE_EXPERIENCE_ENABLED !== 'false') { if (process.env.EXPERIENCE_PG_URL) { try { const { createPgExperienceService } = await import('./experience-service-pg.mjs'); experienceService = await createPgExperienceService({ connectionString: process.env.EXPERIENCE_PG_URL, }); console.log('Experience store: PostgreSQL + pgvector'); } catch (error) { console.error( 'Experience PG init failed, falling back to MySQL store:', error instanceof Error ? error.message : error, ); experienceService = createExperienceService(pool); } } else { experienceService = createExperienceService(pool); } } mindSpaceAgentRunner = createMindSpaceAgentRunner({ apiTarget: API_TARGET, apiSecret: API_SECRET, userAuth, agentJobService: mindSpaceAgentJobs, experienceService, }); mindSpaceAudit = createMindSpaceAuditWriter(pool); // Agent job consumer: a DB-polling worker that atomically claims queued jobs // (claimNextJob uses SELECT ... FOR UPDATE SKIP LOCKED, so multiple instances // can run this loop without double-processing) and runs them via the runner. // Opt-in per instance: must NOT run on the 105 stateless front (see // docs/g2-load-balancing.md) — gate with MINDSPACE_AGENT_WORKER_ENABLED. if (process.env.MINDSPACE_AGENT_WORKER_ENABLED === 'true') { const workerConcurrency = Math.max( 1, Number(process.env.MINDSPACE_AGENT_WORKER_CONCURRENCY ?? 2), ); const workerPollMs = Math.max( 200, Number(process.env.MINDSPACE_AGENT_WORKER_POLL_MS ?? 1000), ); const workerStaleMs = Math.max( 10_000, Number(process.env.MINDSPACE_AGENT_WORKER_STALE_MS ?? 5 * 60 * 1000), ); let inFlight = 0; let draining = false; const drainQueue = async () => { if (draining) return; draining = true; try { while (inFlight < workerConcurrency) { const claim = await mindSpaceAgentJobs.claimNextJob(); if (!claim) break; inFlight += 1; void mindSpaceAgentRunner .runJob(claim.jobId, claim) .catch((error) => { console.error('Agent worker job failed:', error); }) .finally(() => { inFlight -= 1; }); } } catch (error) { console.error('Agent worker drain failed:', error); } finally { draining = false; } }; const workerTimer = setInterval(() => { void drainQueue(); }, workerPollMs); const reaperTimer = setInterval(() => { void mindSpaceAgentJobs .reapStaleJobs(workerStaleMs) .then((reaped) => { if (reaped > 0) { console.warn(`Agent worker reaped ${reaped} stale running job(s)`); } }) .catch((error) => { console.error('Agent worker reaper failed:', error); }); }, Math.min(workerStaleMs, 60_000)); workerTimer.unref?.(); reaperTimer.unref?.(); console.log( `Agent job worker enabled (concurrency=${workerConcurrency}, poll=${workerPollMs}ms)`, ); } 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, apiTargets: API_TARGETS, apiSecret: API_SECRET, }); wordFilterService = createWordFilterService(pool); 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); }); conversationMemoryService = createConversationMemoryService(pool); sessionSnapshotService = createSessionSnapshotService(pool, { conversationMemoryService, }); tkmindProxy = createTkmindProxy({ apiTarget: API_TARGET, apiTargets: API_TARGETS, apiSecret: API_SECRET, userAuth, llmProviderService, subscriptionService, sessionSnapshotService, conversationMemoryService, localFetchAsset: mindSpaceAssets ? async (userId, assetId) => { const { asset, path: assetPath } = await mindSpaceAssets.readAsset(userId, assetId); const buffer = await fs.promises.readFile(assetPath); return { buffer, mimeType: asset.mimeType }; } : null, }); toolGateway = createToolGateway({ llmProviderService }); agentRunGateway = createAgentRunGateway({ pool, userAuth, tkmindProxy, toolGateway, autoDispatch: ['1', 'true', 'yes', 'on'].includes( String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? '1').trim().toLowerCase(), ), maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1), runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000), }); wechatMpService = createWechatMpService({ config: WECHAT_MP_CONFIG, userAuth, apiFetch: tkmindProxy.apiFetch, startAgentSession: ({ userId, workingDir, sessionPolicy }) => tkmindProxy.startSessionForUser(userId, { workingDir, sessionPolicy }), sessionApiFetch: async (sessionId, pathname, init) => { const target = await tkmindProxy.resolveTarget(sessionId); return tkmindProxy.apiFetchTo(target, pathname, init); }, scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null, applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId), refreshSessionSnapshot: sessionSnapshotService?.isEnabled() ? (sessionId, userId) => sessionSnapshotService.refresh(sessionId, userId, async (pathname, init) => { const target = await tkmindProxy.resolveTarget(sessionId); return tkmindProxy.apiFetchTo(target, pathname, init); }) : null, }); notificationDispatcher = createNotificationDispatcher({ sendWechatTextToUser: wechatMpService?.enabled ? (userId, text) => wechatMpService.sendTextToUser(userId, text) : null, }); userAuth.setRechargeNotifier(async ({ userId, title, body, dedupeKey }) => { await notificationDispatcher.sendRechargeSuccess({ userId, title, body, dedupeKey }); }); if ( process.env.H5_REMINDER_WORKER_ENABLED === '1' && wechatMpService?.enabled && scheduleService ) { scheduleReminderWorker = startScheduleReminderWorker({ scheduleService, notificationDispatcher, }); console.log('Schedule reminder worker enabled'); } if (subscriptionService) { const subExpiryTimer = setInterval(async () => { try { const { renewed, failed } = await subscriptionService.processAutoRenewals(); if (renewed > 0) console.log(`Auto-renewed ${renewed} subscription(s)`); if (failed > 0) console.log(`Auto-renew failed for ${failed} subscription(s) (balance insufficient)`); const n = await subscriptionService.expireStaleSubscriptions(); if (n > 0) console.log(`Expired ${n} stale subscription(s)`); } catch (err) { console.warn('Subscription expiry check failed:', err); } }, 60 * 60 * 1000); // hourly subExpiryTimer.unref?.(); } mindSpacePageEditSession = createPageEditSessionService({ apiTarget: API_TARGET, apiSecret: API_SECRET, userAuth, pageService: mindSpacePages, pageLiveEdit: mindSpacePageLiveEdit, llmProviderService, }); if (wechatMpService?.enabled) { console.log('WeChat MP webhook enabled'); } console.log(`User auth enabled (MySQL), workspace root: ${USERS_ROOT}`); return true; } catch (err) { console.error('User auth bootstrap failed:', err); if (isDatabaseConfigured() && process.env.NODE_ENV === 'production') { console.error( 'Fatal: database is configured but user auth bootstrap failed; exiting so launchd can retry', ); process.exit(1); } 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; try { req.userSession = token ? await userAuth.verify(token) : null; req.userSessionError = null; } catch (err) { req.userSession = null; req.userSessionError = err; console.error('[Auth] session verify failed:', err instanceof Error ? err.message : err); } next(); } app.use(attachUserSession); // ============ Legacy password auth ============ app.get('/auth/status', async (req, res) => { await userAuthReady; if (userAuth) { try { 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, }); } catch (err) { console.error('[Auth] status failed:', err instanceof Error ? err.message : err); return res.status(503).json({ authenticated: false, mode: 'unavailable', message: '用户认证服务不可用,请稍后重试', }); } } if (isDatabaseConfigured()) { return res.status(503).json({ authenticated: false, mode: 'unavailable', message: '用户认证服务不可用,请稍后重试', }); } if (legacyAuth) { return res.json({ authenticated: legacyAuth.verify(legacySessionToken(req)), mode: 'legacy', }); } return res.json({ authenticated: false, mode: 'none' }); }); app.post('/auth/login', jsonBody, async (req, res) => { await userAuthReady; const secure = isSecureRequest(req); if (userAuth) { const { username, password } = req.body ?? {}; if (!username || !password) { return res.status(400).json({ message: '用户名和密码不能为空' }); } const result = await userAuth.login({ username, password, ip: req.ip }); if (!result.ok) { if (result.retryAfterMs > 0) { res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000))); return res.status(429).json({ message: result.message }); } return res.status(401).json({ message: result.message }); } setUserLoginCookies(res, req, result.token); return res.json({ authenticated: true, user: result.user, mode: 'user' }); } if (!legacyAuth) { return res.status(503).json({ message: '未配置用户数据库或访问密码' }); } const password = typeof req.body?.password === 'string' ? req.body.password : ''; const result = legacyAuth.login(password, req.ip); if (!result.ok) { if (result.retryAfterMs > 0) { res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000))); return res.status(429).json({ message: '尝试次数过多,请稍后再试' }); } return res.status(401).json({ message: '密码错误,请重试' }); } res.set('Set-Cookie', sessionCookie(result.token, secure)); return res.json({ authenticated: true, mode: 'legacy' }); }); app.post('/auth/register', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth) { return res.status(503).json({ message: '未启用用户注册' }); } const { username, password, displayName, email } = req.body ?? {}; const result = await userAuth.register({ username, password, displayName, email }); if (!result.ok) { const status = result.message.includes('已存在') ? 409 : 400; return res.status(status).json({ message: result.message }); } if (plazaSeo && req.body?.utm_source) { void plazaSeo .recordAttribution( { event_type: 'signup', utm_source: req.body.utm_source, utm_medium: req.body.utm_medium, utm_campaign: req.body.utm_campaign, ref_id: req.body.ref ?? req.body.ref_id, user_id: result.user?.id ?? null, }, plazaClientIp(req), ) .catch(() => {}); } return res.json({ ok: true, user: result.user }); }); app.post('/auth/reset-password', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth) { return res.status(503).json({ message: '未启用用户系统' }); } const { username, email, password } = req.body ?? {}; const result = await userAuth.resetPassword({ username, email, password }); if (!result.ok) { return res.status(400).json({ message: result.message }); } return res.json({ ok: true }); }); app.get('/auth/wechat/config', async (req, res) => { await userAuthReady; if (!wechatOAuthService?.enabled) { return res.json({ enabled: false, inWechat: isWechatUserAgent(req.get('user-agent') || ''), scanEnabled: false, }); } return res.json(wechatOAuthService.publicConfig(req)); }); app.get('/auth/wechat/js-sdk-signature', async (req, res) => { await userAuthReady; if (!userAuth || !wechatMpService?.enabled) { return res.status(503).json({ message: '微信 JS-SDK 未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const pageUrl = String(req.query?.url ?? '').split('#')[0]; if (!pageUrl) { return res.status(400).json({ message: '缺少 url' }); } try { const normalizedUrl = validateWechatShareSignatureUrl(pageUrl, { publicBaseUrl: WECHAT_MP_CONFIG.publicBaseUrl, requestHost: req.get('x-forwarded-host') || req.get('host') || '', }); const payload = await wechatMpService.createJsSdkSignature(normalizedUrl); return res.json(payload); } catch (err) { const message = err instanceof Error ? err.message : '微信 JS-SDK 签名失败'; console.warn('WeChat JS-SDK signature failed:', message); return res.status(502).json({ message }); } }); app.get('/auth/wechat/public-js-sdk-signature', async (req, res) => { await userAuthReady; if (!wechatMpService?.enabled) { return res.status(503).json({ message: '微信 JS-SDK 未启用' }); } const pageUrl = String(req.query?.url ?? '').split('#')[0]; if (!pageUrl) { return res.status(400).json({ message: '缺少 url' }); } try { const normalizedUrl = validateWechatShareSignatureUrl(pageUrl, { publicBaseUrl: WECHAT_MP_CONFIG.publicBaseUrl, requestHost: req.get('x-forwarded-host') || req.get('host') || '', }); const payload = await wechatMpService.createJsSdkSignature(normalizedUrl); return res.json(payload); } catch (err) { const message = err instanceof Error ? err.message : '微信 JS-SDK 签名失败'; console.warn('WeChat public JS-SDK signature failed:', message); return res.status(502).json({ message }); } }); app.get('/auth/wechat/status', async (req, res) => { await userAuthReady; if (!userAuth || !wechatOAuthService?.enabled) { return res.json({ enabled: false, bound: false }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const config = loadWechatOAuthConfig(); const status = await userAuth.getWechatBindingStatus(me.id, config.appId); return res.json({ enabled: true, ...status }); }); if (WECHAT_MP_CONFIG.enabled) { app.get('/auth/wechat/agent-route', async (req, res) => { await userAuthReady; if (!userAuth || !wechatMpService?.enabled) { return res.status(503).json({ message: '公众号 Agent 未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); return res.json(await wechatMpService.getRouteStatusForUser(me.id)); }); app.post('/auth/wechat/agent-route/reset', async (req, res) => { await userAuthReady; if (!userAuth || !wechatMpService?.enabled) { return res.status(503).json({ message: '公众号 Agent 未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); try { const result = await wechatMpService.recreateRouteForUser(me.id); if (!result.ok) return res.status(400).json({ message: result.message }); return res.json(result); } catch (err) { return res.status(500).json({ message: err instanceof Error ? err.message : '重建公众号 Agent 路由失败', }); } }); } app.get('/auth/wechat/pending/:token', async (req, res) => { await userAuthReady; if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); const pending = await userAuth.getWechatPendingBind(req.params.token); if (!pending) return res.status(404).json({ message: '绑定会话已过期,请重新微信登录' }); return res.json({ nickname: pending.nickname, avatarUrl: pending.avatar_url, returnTo: pending.return_to || '/', }); }); app.post('/auth/wechat/register', jsonBody, async (req, res) => { await userAuthReady; const secure = isSecureRequest(req); if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : ''; if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' }); const result = await userAuth.completeWechatRegister({ pendingToken }); if (!result.ok) return res.status(400).json({ message: result.message }); if (result.isNewUser && plazaSeo) { void plazaSeo .recordAttribution( { event_type: 'signup', utm_source: result.utmSource || 'wechat', utm_medium: result.utmMedium, utm_campaign: result.utmCampaign, user_id: result.user?.id ?? null, }, plazaClientIp(req), ) .catch(() => {}); } setUserLoginCookies(res, req, result.token); return res.json({ authenticated: true, user: result.user, returnTo: result.returnTo || '/', }); }); app.post('/auth/wechat/bind', jsonBody, async (req, res) => { await userAuthReady; const secure = isSecureRequest(req); if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : ''; const username = typeof req.body?.username === 'string' ? req.body.username : ''; const password = typeof req.body?.password === 'string' ? req.body.password : ''; if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' }); if (!username || !password) { return res.status(400).json({ message: '用户名和密码不能为空' }); } const result = await userAuth.completeWechatBindAccount({ pendingToken, username, password, ip: req.ip, }); if (!result.ok) { const status = result.retryAfterMs > 0 ? 429 : 401; if (result.retryAfterMs > 0) { res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000))); } return res.status(status).json({ message: result.message }); } setUserLoginCookies(res, req, result.token); return res.json({ authenticated: true, user: result.user, bound: true, returnTo: result.returnTo || '/', }); }); app.post('/auth/wechat/scan/start', async (req, res) => { await userAuthReady; if (!wechatOAuthService?.enabled) { return res.status(503).json({ message: '微信登录未启用' }); } try { const payload = await wechatOAuthService.startScanLogin(req); return res.json(payload); } catch (err) { return res.status(503).json({ message: err instanceof Error ? err.message : '微信扫码登录不可用', }); } }); app.get('/auth/wechat/scan/poll', async (req, res) => { await userAuthReady; if (!wechatOAuthService?.enabled) { return res.status(503).json({ message: '微信登录未启用' }); } const state = typeof req.query?.state === 'string' ? req.query.state : ''; if (!state) return res.status(400).json({ message: '缺少扫码状态' }); const result = await wechatOAuthService.pollScanLogin(state); if (result.status === 'complete' && result.token) { setUserLoginCookies(res, req, result.token); } return res.json(result); }); app.get('/auth/wechat/authorize', async (req, res) => { await userAuthReady; if (!wechatOAuthService?.enabled) { return res.status(503).json({ message: '微信登录未启用' }); } try { let bindUserId = null; const intent = typeof req.query?.intent === 'string' ? req.query.intent.trim().toLowerCase() : 'login'; if (intent === 'bind' && userAuth) { const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '请先登录后再绑定微信' }); bindUserId = me.id; } const redirectUrl = await wechatOAuthService.buildAuthorizeRedirect(req, { bindUserId }); return res.redirect(302, redirectUrl); } catch (err) { console.error('WeChat authorize failed:', err); return res.status(500).json({ message: err instanceof Error ? err.message : '微信授权失败' }); } }); app.get('/auth/wechat/callback', async (req, res) => { await userAuthReady; const secure = isSecureRequest(req); if (!wechatOAuthService?.enabled || !userAuth) { return res.redirect(302, '/?wechat_error=unavailable'); } try { const result = await wechatOAuthService.handleCallback({ code: typeof req.query?.code === 'string' ? req.query.code : '', state: typeof req.query?.state === 'string' ? req.query.state : '', ip: req.ip, }); if (result.action === 'binding_gate') { const params = new URLSearchParams(); params.set('wechat_pending', result.pendingToken); if (result.returnTo && result.returnTo !== '/') { params.set('return_to', result.returnTo); } return res.redirect(302, `/?${params.toString()}`); } if (result.action === 'poll_error') { return res.redirect( 302, `/?wechat_error=${encodeURIComponent(result.message || '微信登录失败')}`, ); } if (result.isNewUser && plazaSeo) { void plazaSeo .recordAttribution( { event_type: 'signup', utm_source: result.utmSource || 'wechat', utm_medium: result.utmMedium, utm_campaign: result.utmCampaign, user_id: result.user?.id ?? null, }, plazaClientIp(req), ) .catch(() => {}); } if (result.authMode === 'open' || result.authMode === 'scan') { return res.send(`微信登录

扫码登录成功,请返回电脑继续操作。

`); } 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, capabilityState, subscription] = await Promise.all([ userAuth.listPathGrants(me.id), userAuth.resolveUserCapabilities(await userAuth.getUserById(me.id)), subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null, ]); return res.json({ user: { ...me, subscription }, 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.post('/auth/feedback', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth || !feedbackService) { return res.status(503).json({ message: '反馈服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); try { const result = await feedbackService.submit(me.id, { type: req.body?.type, title: req.body?.title, description: req.body?.description, contact: req.body?.contact, images: req.body?.images, context: req.body?.context, }); res.status(201).json({ feedback: result }); } catch (err) { const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : ''; if (code === 'invalid_input') { return res.status(400).json({ message: err instanceof Error ? err.message : '提交内容无效' }); } console.warn('Submit feedback failed:', err instanceof Error ? err.message : err); return res.status(500).json({ message: '反馈提交失败,请稍后重试' }); } }); app.get('/auth/feedback', async (req, res) => { await userAuthReady; if (!userAuth || !feedbackService) { 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) || 20, 1), 50); const items = await feedbackService.listForUser(me.id, { limit }); res.json({ items }); }); app.get('/auth/feedback/board', async (req, res) => { await userAuthReady; if (!userAuth || !feedbackService) { return res.status(503).json({ message: '反馈服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const page = Math.max(Number(req.query?.page) || 1, 1); const limit = Math.min(Math.max(Number(req.query?.limit) || 10, 1), 10); try { const result = await feedbackService.listAll({ page, limit }); res.json(result); } catch (err) { console.warn('List feedback board failed:', err instanceof Error ? err.message : err); return res.status(500).json({ message: '反馈列表加载失败' }); } }); app.get('/auth/feedback/:feedbackId', async (req, res) => { await userAuthReady; if (!userAuth || !feedbackService) { return res.status(503).json({ message: '反馈服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); try { const item = await feedbackService.getById(req.params.feedbackId, me.id); res.json({ item, isMine: item.userId === me.id }); } catch (err) { const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : ''; if (code === 'feedback_not_found') { return res.status(404).json({ message: err instanceof Error ? err.message : '反馈不存在' }); } console.warn('Get feedback detail failed:', err instanceof Error ? err.message : err); return res.status(500).json({ message: '反馈详情加载失败' }); } }); app.get('/auth/notifications', async (req, res) => { await userAuthReady; if (!userAuth || !scheduleService) { return res.status(503).json({ message: '通知服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const rawStatus = typeof req.query?.status === 'string' ? req.query.status : 'unread'; const status = ['all', 'unread', 'read'].includes(rawStatus) ? rawStatus : 'all'; const limit = Math.min(Math.max(Number(req.query?.limit) || 20, 1), 100); try { const notifications = await scheduleService.listUserNotifications({ userId: me.id, status, limit }); res.json({ notifications }); } catch (err) { console.warn('List user notifications failed:', err instanceof Error ? err.message : err); res.status(500).json({ message: '通知列表加载失败' }); } }); app.get('/auth/notifications/events', async (req, res) => { await userAuthReady; if (!userAuth || !scheduleService) { return res.status(503).json({ message: '通知服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); res.status(200); res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders?.(); let closed = false; let lastNotificationId = null; const sendEvent = (event, data) => { if (closed || res.destroyed) return; res.write(`event: ${event}\n`); res.write(`data: ${JSON.stringify(data)}\n\n`); }; const checkUnread = async () => { if (closed) return; try { const notifications = await scheduleService.listUserNotifications({ userId: me.id, status: 'unread', limit: 1, }); const latest = notifications[0] ?? null; const nextId = latest?.id ?? null; if (nextId && nextId !== lastNotificationId) { lastNotificationId = nextId; sendEvent('notification', { notification: latest }); } else if (!nextId) { lastNotificationId = null; } } catch { sendEvent('sync', { reason: 'check_failed' }); } }; sendEvent('ready', { ok: true }); await checkUnread(); const checkTimer = setInterval(() => { void checkUnread(); }, 2500); const keepaliveTimer = setInterval(() => { sendEvent('ping', { at: Date.now() }); }, 25000); req.on('close', () => { closed = true; clearInterval(checkTimer); clearInterval(keepaliveTimer); }); }); app.post('/auth/notifications/:id/read', async (req, res) => { await userAuthReady; if (!userAuth || !scheduleService) { return res.status(503).json({ message: '通知服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const ok = await scheduleService.markUserNotificationRead({ userId: me.id, notificationId: req.params.id, }); if (!ok) return res.status(404).json({ message: '通知不存在或已读' }); res.json({ ok: true }); }); app.post('/auth/notifications/read-all', async (req, res) => { await userAuthReady; if (!userAuth || !scheduleService) { return res.status(503).json({ message: '通知服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const updated = await scheduleService.markAllUserNotificationsRead({ userId: me.id }); res.json({ ok: true, updated }); }); app.delete('/auth/notifications/:id', async (req, res) => { await userAuthReady; if (!userAuth || !scheduleService) { return res.status(503).json({ message: '通知服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const ok = await scheduleService.deleteUserNotification({ userId: me.id, notificationId: req.params.id, }); if (!ok) return res.status(404).json({ message: '通知不存在' }); res.status(204).end(); }); app.delete('/auth/notifications', async (req, res) => { await userAuthReady; if (!userAuth || !scheduleService) { return res.status(503).json({ message: '通知服务未启用' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const status = typeof req.query?.status === 'string' ? req.query.status : 'all'; const deleted = await scheduleService.clearUserNotifications({ userId: me.id, status }); res.json({ ok: true, deleted }); }); 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, sub] = await Promise.all([ rechargeService.getBillingConfig(me.id), subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null, ]); return res.json({ ...config, subscription: sub }); }); app.get('/auth/billing/subscription', 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 sub = subscriptionService ? await subscriptionService.getActiveSubscription(me.id) : null; const planCatalog = subscriptionService?._planCatalogService; const plans = (!sub && planCatalog) ? await planCatalog.listPlans({ includeInactive: false }) : sub ? undefined : PLAN_CATALOG; return res.json({ subscription: sub, plans }); }); app.get('/auth/billing/plans', 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 planCatalog = subscriptionService?._planCatalogService; const plans = planCatalog ? (await planCatalog.listPlans({ includeInactive: false })) .filter((p) => p.priceCents > 0) .map((p) => ({ key: p.planType, ...p })) : Object.entries(PLAN_CATALOG) .filter(([, plan]) => plan.priceCents > 0) .map(([key, plan]) => ({ key, ...plan })); const [sub, wallet] = await Promise.all([ subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null, userAuth.getUserById(me.id), ]); return res.json({ plans, subscription: sub, balanceCents: wallet ? Number(wallet.balance_cents ?? 0) : 0, }); }); app.post('/auth/billing/subscribe', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth || !subscriptionService) { return res.status(503).json({ message: '未启用订阅系统' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); if (me.status === 'disabled') return res.status(403).json({ message: '账户已禁用' }); const { planType, autoRenew = false } = req.body ?? {}; if (!planType || typeof planType !== 'string') { return res.status(400).json({ message: '请选择套餐' }); } const result = await subscriptionService.purchaseSubscription(me.id, planType, Boolean(autoRenew)); if (!result.ok) { if (result.code === 'INSUFFICIENT_BALANCE') { return res.status(402).json({ message: result.message, code: result.code, balanceCents: result.balanceCents, requiredCents: result.requiredCents, shortfallCents: result.shortfallCents, }); } if (result.code === 'DOWNGRADE_NOT_ALLOWED') { return res.status(409).json({ message: result.message, code: result.code, currentPlanType: result.currentPlanType, }); } return res.status(400).json({ message: result.message }); } return res.json({ subscription: result.subscription, balanceCents: result.balanceCents }); }); app.post('/auth/billing/space-purchase', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth || !mindSpace) { return res.status(503).json({ message: '未启用空间系统' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const sizeMb = Number(req.body?.sizeMb); const result = await userAuth.purchaseSpaceQuota(me.id, sizeMb); if (!result.ok) { if (result.code === 'INSUFFICIENT_BALANCE') { return res.status(402).json({ message: result.message, code: result.code, details: { code: 'INSUFFICIENT_BALANCE', balanceCents: result.balanceCents, minRechargeCents: result.minRechargeCents, suggestedTiers: result.suggestedTiers, }, }); } return res.status(400).json({ message: result.message }); } const quota = await mindSpace.getQuota(me.id); return res.json({ quota: quota ?? result.quota, balanceCents: result.balanceCents, purchasedMb: sizeMb, costCents: sizeMb * 200, }); }); app.post('/auth/billing/auto-renew', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth || !subscriptionService) { return res.status(503).json({ message: '未启用订阅系统' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const { enabled } = req.body ?? {}; if (typeof enabled !== 'boolean') { return res.status(400).json({ message: '请传入 enabled: true/false' }); } const result = await subscriptionService.setAutoRenew(me.id, enabled); return res.json(result); }); app.post('/auth/billing/recharge-orders', jsonBody, async (req, res) => { await userAuthReady; if (!userAuth || !rechargeService) { return res.status(503).json({ message: '未启用计费系统' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const amountCents = Number(req.body?.amountCents); const payScene = ['native', 'h5', 'jsapi'].includes(req.body?.payScene) ? req.body.payScene : 'native'; const result = await rechargeService.createOrder({ userId: me.id, amountCents, payScene, clientIp: req.ip, }); if (!result.ok) return res.status(400).json({ message: result.message }); return res.status(201).json({ order: result.order }); }); app.get('/auth/billing/recharge-orders/:orderId', async (req, res) => { await userAuthReady; if (!userAuth || !rechargeService) { return res.status(503).json({ message: '未启用计费系统' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const order = await rechargeService.getOrderForUser(me.id, req.params.orderId); if (!order) return res.status(404).json({ message: '订单不存在' }); let balanceCents = null; if (order.status === 'paid') { const user = await userAuth.getUserById(me.id); balanceCents = user ? Number(user.balance_cents ?? 0) : null; } return res.json({ order, balanceCents }); }); const wechatNotifyBody = express.raw({ type: ['application/json', 'text/xml', 'application/xml'], limit: '64kb', }); const wechatMpBody = express.text({ type: ['text/xml', 'application/xml'], limit: '128kb', }); app.post('/webhooks/wechat-pay/notify', wechatNotifyBody, async (req, res) => { await userAuthReady; const isV2 = wechatPayClient?.apiVersion === 'v2'; if (!rechargeService || !wechatPayClient?.enabled) { if (isV2) { return res .status(503) .type('text/xml') .send( '', ); } 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( ``, ); } return res.status(500).json({ code: 'FAIL', message }); } }); if (WECHAT_MP_CONFIG.enabled) { app.get('/webhooks/wechat-mp/messages', async (req, res) => { await userAuthReady; if (!wechatMpService?.enabled) { return res.status(503).send('wechat mp disabled'); } const result = wechatMpService.verifyUrlChallenge(req.query); if (!result.ok) { console.warn('WeChat MP verify failed:', { encryptType: req.query.encrypt_type ?? null, timestamp: req.query.timestamp ?? null, nonce: req.query.nonce ?? null, }); return res.status(result.status ?? 403).send(result.body ?? 'invalid signature'); } console.log('WeChat MP verify ok:', { encryptType: req.query.encrypt_type ?? null, timestamp: req.query.timestamp ?? null, nonce: req.query.nonce ?? null, }); return res.type('text/plain').send(String(result.body ?? '')); }); app.post('/webhooks/wechat-mp/messages', wechatMpBody, async (req, res) => { await userAuthReady; if (!wechatMpService?.enabled) { return res.status(503).send('wechat mp disabled'); } try { const bodyText = String(req.body ?? ''); const fromUser = bodyText.match(/<\/FromUserName>/)?.[1] ?? null; const msgType = bodyText.match(/<\/MsgType>/)?.[1] ?? null; const content = bodyText.match(/<\/Content>/)?.[1] ?? null; console.log('WeChat MP message received:', { at: new Date().toISOString(), fromUser: fromUser ? `${fromUser.slice(0, 8)}...` : null, msgType, contentPreview: content ? `${String(content).slice(0, 24)}` : null, }); const result = await wechatMpService.handleInboundMessage(req.body, req.query); if (result.task) void result.task; if (result.contentType) res.type(result.contentType); return res.status(result.status ?? 200).send(result.body ?? 'success'); } catch (err) { console.error('WeChat MP message failed:', err); return res.status(500).send('internal error'); } }); } // ============ Wiki API ============ function wikiCookie(token, secure) { const parts = [ `${wikiAuth.COOKIE_NAME}=${encodeURIComponent(token)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=604800', ]; if (secure) parts.push('Secure'); return parts.join('; '); } function clearWikiCookie(secure) { const parts = [`${wikiAuth.COOKIE_NAME}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0']; if (secure) parts.push('Secure'); return parts.join('; '); } const wikiApi = express.Router(); wikiApi.use(jsonBody); wikiApi.post('/auth/register', (req, res) => { const { username, password, displayName } = req.body || {}; if (!username || !password) { return res.status(400).json({ message: '用户名和密码不能为空' }); } const result = wikiAuth.register(username, password, displayName); if (!result.ok) return res.status(409).json({ message: result.message }); return res.json({ ok: true, user: result.user }); }); wikiApi.post('/auth/login', (req, res) => { const { username, password } = req.body || {}; if (!username || !password) { return res.status(400).json({ message: '用户名和密码不能为空' }); } const result = wikiAuth.login(username, password); if (!result.ok) return res.status(401).json({ message: result.message }); const secure = isSecureRequest(req); res.set('Set-Cookie', wikiCookie(result.token, secure)); return res.json({ ok: true, user: result.user }); }); wikiApi.post('/auth/logout', (req, res) => { const cookies = parseCookies(req.get('cookie')); wikiAuth.revoke(cookies[wikiAuth.COOKIE_NAME]); res.set('Set-Cookie', clearWikiCookie(isSecureRequest(req))); return res.json({ ok: true }); }); wikiApi.get('/auth/me', (req, res) => { const cookies = parseCookies(req.get('cookie')); const session = wikiAuth.verify(cookies[wikiAuth.COOKIE_NAME]); if (!session) return res.json({ authenticated: false, user: null }); const user = wikiAuth.getUser(session.username); return res.json({ authenticated: true, user }); }); function requireWikiAuth(req, res, next) { const cookies = parseCookies(req.get('cookie')); const session = wikiAuth.verify(cookies[wikiAuth.COOKIE_NAME]); if (!session) return res.status(401).json({ message: '未登录' }); req.wikiUser = session; next(); } wikiApi.get('/pages', requireWikiAuth, (req, res) => { const { q } = req.query; if (q) return res.json(wikiAuth.searchPages(req.wikiUser.username, q)); res.json(wikiAuth.listPages(req.wikiUser.username)); }); wikiApi.get('/pages/:slug', requireWikiAuth, (req, res) => { const page = wikiAuth.getPage(req.wikiUser.username, req.params.slug); if (!page) return res.status(404).json({ message: '页面不存在' }); res.json(page); }); wikiApi.post('/pages/:slug', requireWikiAuth, (req, res) => { const { title, content, tags } = req.body || {}; const page = wikiAuth.savePage(req.wikiUser.username, req.params.slug, title, content, tags); res.json(page); }); wikiApi.delete('/pages/:slug', requireWikiAuth, (req, res) => { wikiAuth.deletePage(req.wikiUser.username, req.params.slug); res.json({ ok: true }); }); app.use('/wiki-api', wikiApi); // ============ TKMind API proxy ============ const api = express.Router(); api.use(jsonUnlessMultipart); api.use(async (req, res, next) => { await userAuthReady; if (req.path === '/status' || req.path === '/runtime/status') return next(); if (req.path.startsWith('/internal/agent/')) return next(); if (req.path === '/agent/mindspace_page_patch') return next(); if (req.path === '/agent/mindspace_asset_delete') return next(); if (req.path === '/config/blocked-words') return next(); if (req.method === 'GET' && /^\/mindspace\/v1\/assets\/[^/]+\/download$/.test(req.path)) { return next(); } const plazaPublic = isPlazaPublicRead(req.path, req.method); if (userAuth && tkmindProxy) { if (req.userSessionError) { if (plazaPublic) return next(); return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' }); } try { 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(); } catch (err) { console.error('[Auth] API auth failed:', err instanceof Error ? err.message : err); return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' }); } } if (legacyAuth?.verify(legacySessionToken(req))) return next(); return res.status(401).json({ message: '未授权,请重新登录' }); }); attachAsrRoutes(api, { sendError, sendData }); api.get('/config/blocked-words', async (_req, res) => { await userAuthReady; if (!wordFilterService) return res.json({ words: [] }); const words = await wordFilterService.listAllForFrontend(); res.json({ words }); }); 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(); }); function runtimeEnvFlag(value, fallback = false) { const raw = String(value ?? '').trim().toLowerCase(); if (!raw) return fallback; return ['1', 'true', 'yes', 'on'].includes(raw); } function runtimeCsvList(value) { return String(value ?? '') .split(',') .map((item) => item.trim()) .filter(Boolean); } function runtimeCodeRunPolicyStatus() { return { enabled: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED), userAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS), taskTypeAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES), requireValidation: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION), }; } api.get('/runtime/status', async (_req, res) => { await userAuthReady; if (!tkmindProxy?.getRuntimeStatus) { return res.status(503).json({ ok: false, message: 'runtime router unavailable' }); } try { const status = await tkmindProxy.getRuntimeStatus(); const toolQueue = agentRunGateway?.getQueueStatus ? await agentRunGateway.getQueueStatus().catch((err) => ({ error: err instanceof Error ? err.message : String(err), })) : null; if (toolQueue) { status.toolRuntime = { ...(status.toolRuntime ?? {}), codeRunPolicy: runtimeCodeRunPolicyStatus(), queue: toolQueue, }; } return res.json({ ok: true, timestamp: new Date().toISOString(), ...status, }); } catch (err) { return res.status(502).json({ ok: false, message: err instanceof Error ? err.message : 'runtime status failed', }); } }); async function ensureUserMemoryCapability(req, res) { if (!userAuth) { res.status(503).json({ message: '未启用用户系统' }); return null; } const userRow = await userAuth.getUserById(req.currentUser.id); if (!userRow) { res.status(404).json({ message: '用户不存在' }); return null; } const capabilityState = await userAuth.resolveUserCapabilities(userRow); if (!capabilityState.unrestricted && !capabilityState.capabilities.memory_store) { res.status(403).json({ message: '当前账户未开通长期记忆,无法访问该 API' }); return null; } return capabilityState; } async function loadUserVisibleConversation(sessionId) { const target = await tkmindProxy.resolveTarget(sessionId); const upstream = await tkmindProxy.apiFetchTo(target, `/sessions/${encodeURIComponent(sessionId)}`, { method: 'GET', }); if (!upstream.ok) { const message = await upstream.text().catch(() => ''); throw new Error(message || '读取会话失败'); } const session = await upstream.json(); return (session?.conversation ?? []).filter((message) => message?.metadata?.userVisible); } async function syncUserMemoriesIntoSession(userId, sessionId) { if (!tkmindProxy || !sessionId) return false; await tkmindProxy.reconcileSessionPolicyForUser(userId, sessionId); return true; } api.post('/user-memory/v1/remember-recent', async (req, res) => { if (!conversationMemoryService?.isEnabled?.()) { return res.status(503).json({ message: '长期记忆功能未启用' }); } if (!tkmindProxy) { return res.status(503).json({ message: '会话代理尚未就绪' }); } const capabilityState = await ensureUserMemoryCapability(req, res); if (!capabilityState) return; const sessionId = String(req.body?.sessionId ?? '').trim(); if (!sessionId) { return res.status(400).json({ message: '缺少 sessionId' }); } const owns = await userAuth.ownsSession(req.currentUser.id, sessionId); if (!owns) { return res.status(403).json({ message: '无权访问该会话' }); } try { const messages = await loadUserVisibleConversation(sessionId); const result = await conversationMemoryService.saveAndAnalyze( sessionId, req.currentUser.id, messages, ); const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId); const memories = await conversationMemoryService.listMemories(req.currentUser.id, { limit: 200 }); return res.json({ ok: true, analyzed: result.analyzed ?? 0, memories: result.memories ?? 0, totalMemories: memories.length, syncedToSession, }); } catch (err) { return res.status(500).json({ message: err instanceof Error ? err.message : '保存长期记忆失败' }); } }); api.post('/user-memory/v1/sync', async (req, res) => { if (!conversationMemoryService?.isEnabled?.()) { return res.status(503).json({ message: '长期记忆功能未启用' }); } const capabilityState = await ensureUserMemoryCapability(req, res); if (!capabilityState) return; const sessionId = String(req.body?.sessionId ?? '').trim(); if (!sessionId) { return res.status(400).json({ message: '缺少 sessionId' }); } const owns = await userAuth.ownsSession(req.currentUser.id, sessionId); if (!owns) { return res.status(403).json({ message: '无权访问该会话' }); } try { const result = await conversationMemoryService.analyzeUser(req.currentUser.id); const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId); const memories = await conversationMemoryService.listMemories(req.currentUser.id, { limit: 200 }); return res.json({ ok: true, analyzed: result.analyzed ?? 0, memories: result.memories ?? 0, totalMemories: memories.length, syncedToSession, }); } catch (err) { return res.status(500).json({ message: err instanceof Error ? err.message : '刷新长期记忆失败' }); } }); 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.post('/mindspace/v1/schedule/reminders/:reminderId/ignore', async (req, res) => { if (!ensureMindSpaceEnabled(res, req) || !scheduleService) { return sendError(res, req, 503, 'feature_disabled', '日程服务未启用'); } try { const reminder = await scheduleService.cancelReminder({ userId: req.currentUser.id, reminderId: req.params.reminderId, }); return sendData(res, req, reminder); } catch (err) { const message = err instanceof Error ? err.message : '忽略提醒失败'; return sendError(res, req, 400, 'invalid_schedule_input', message); } }); api.post('/mindspace/v1/schedule/reminders/bulk-delete', async (req, res) => { if (!ensureMindSpaceEnabled(res, req) || !scheduleService) { return sendError(res, req, 503, 'feature_disabled', '日程服务未启用'); } try { const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(String) : []; const deleted = await scheduleService.deleteReminders({ userId: req.currentUser.id, reminderIds: ids, }); return sendData(res, req, { deleted }); } catch (err) { const message = err instanceof Error ? err.message : '删除提醒失败'; return sendError(res, req, 400, 'invalid_schedule_input', message); } }); api.get('/mindspace/v1/space/quota', async (req, res) => { if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' }); const quota = await mindSpace.getQuota(req.currentUser.id); if (!quota) return res.status(404).json({ message: '用户空间不存在' }); return res.json({ data: quota }); }); api.get('/mindspace/v1/space/categories', async (req, res) => { if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' }); const categories = await mindSpace.listCategories(req.currentUser.id); if (!categories) return res.status(404).json({ message: '用户空间不存在' }); return res.json({ data: categories }); }); api.get('/mindspace/v1/space/cleanup', async (req, res) => { if (!mindSpaceCleanup || !ensureMindSpaceEnabled(res, req)) return; try { const username = req.currentUser.username ?? req.currentUser.slug; const items = await mindSpaceCleanup.listCandidates(req.currentUser.id, username); const totalBytes = items.reduce((sum, item) => sum + item.sizeBytes, 0); return sendData(res, req, { items, totalBytes }); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/space/cleanup', async (req, res) => { if (!mindSpaceCleanup || !ensureMindSpaceEnabled(res, req)) return; try { const username = req.currentUser.username ?? req.currentUser.slug; const itemIds = Array.isArray(req.body?.item_ids) ? req.body.item_ids : []; const result = await mindSpaceCleanup.runCleanup(req.currentUser.id, username, itemIds); const quota = await mindSpace.getQuota(req.currentUser.id); await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'space.cleanup', objectType: 'space', objectId: req.currentUser.id, ip: req.ip, detail: { removedCount: result.removedCount, freedBytes: result.freedBytes }, }); return sendData(res, req, { ...result, quota }); } catch (error) { return mindSpaceError(res, req, error); } }); function isPlazaPublicRead(path, method) { if (!path.startsWith('/plaza/v1/')) return false; if (method === 'POST' && path === '/plaza/v1/events') return true; if (method !== 'GET') return false; return ( path === '/plaza/v1/feed' || path === '/plaza/v1/categories' || path === '/plaza/v1/seo/sitemap' || /^\/plaza\/v1\/posts\/[^/]+$/.test(path) || /^\/plaza\/v1\/posts\/[^/]+\/comments$/.test(path) || /^\/plaza\/v1\/users\/[^/]+$/.test(path) || /^\/plaza\/v1\/users\/[^/]+\/posts$/.test(path) ); } const PLAZA_SID_COOKIE = 'plaza_sid'; function resolvePlazaSessionId(req, res) { const cookies = parseCookies(req.get('cookie')); let sessionId = cookies[PLAZA_SID_COOKIE]; if (!sessionId) { sessionId = crypto.randomUUID(); res.append( 'Set-Cookie', `${PLAZA_SID_COOKIE}=${sessionId}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`, ); } return sessionId; } function recordPlazaEventsAsync(req, res, events) { if (!plazaEvents || !Array.isArray(events) || events.length === 0) return; const sessionId = resolvePlazaSessionId(req, res); void plazaEvents .recordEvents({ userId: req.currentUser?.id ?? null, sessionId, events, }) .catch(() => {}); } function reactionEventType(type) { if (type === 'like' || type === 'collect' || type === 'share') return type; return null; } function ensurePlazaInteractions(res, req) { if (!plazaInteractions) { sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); return false; } return true; } function plazaRouteError(res, req, error) { const status = mapPlazaError(error); const code = error?.code ?? 'internal_error'; const message = error instanceof Error ? error.message : 'Plaza 请求失败'; return sendError(res, req, status, code, message, error?.details); } function ensurePlazaEnabled(res, req) { if (!plazaPosts) { sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); return false; } return true; } function plazaClientIp(req) { const forwarded = req.headers['x-forwarded-for']; if (typeof forwarded === 'string' && forwarded.length > 0) { return forwarded.split(',')[0].trim(); } return req.ip; } function mindSpaceError(res, req, error) { const statusByCode = { invalid_filename: 400, invalid_file_size: 400, category_not_uploadable: 400, file_size_mismatch: 409, invalid_upload_state: 409, file_too_large: 413, unsupported_file_type: 415, category_not_found: 404, upload_not_found: 404, asset_not_found: 404, asset_in_use: 409, page_not_found: 404, upload_expired: 410, quota_exceeded: 429, public_page_limit_exceeded: 429, space_unavailable: 423, invalid_page_input: 400, page_content_too_large: 413, source_message_not_found: 404, invalid_source_message: 409, version_conflict: 409, slug_conflict: 409, invalid_state_transition: 409, invalid_publish_input: 400, publication_not_found: 404, security_scan_required: 422, security_ack_required: 422, security_risk_blocked: 422, invalid_agent_job_input: 400, invalid_agent_job_output: 400, agent_job_not_found: 404, agent_job_token_invalid: 401, agent_job_expired: 410, feature_disabled: 503, cover_ai_unavailable: 503, llm_not_configured: 503, cover_ai_failed: 502, cover_ai_invalid_output: 422, thumbnail_not_supported: 422, thumbnail_image_required: 400, thumbnail_image_invalid: 400, category_not_pageable: 400, redaction_not_needed: 409, invalid_category_code: 400, invalid_page_path: 400, empty_page_content: 400, static_page_not_found: 404, preview_not_supported: 422, }; const code = error?.code ?? 'internal_error'; const status = statusByCode[code] ?? 500; const message = status === 500 && (!error?.code || code === 'internal_error') ? 'MindSpace 服务异常' : error?.message || 'MindSpace 服务异常'; return sendError(res, req, status, code, message, error?.details); } function ensureMindSpaceEnabled(res, req, { upload = false, agent = false } = {}) { try { assertMindSpaceRoute(msFlags, upload ? 'upload' : agent ? 'agent' : undefined); return true; } catch (error) { mindSpaceError(res, req, error); return false; } } function bearerToken(req) { const header = req.get('authorization') ?? ''; const [scheme, value] = header.split(/\s+/, 2); if (scheme?.toLowerCase() !== 'bearer' || !value) return null; return value.trim(); } function requireInternalAgentSecret(req, res) { if (bearerToken(req) === INTERNAL_AGENT_SECRET) return true; sendError(res, req, 401, 'agent_job_token_invalid', '内部 Agent 凭据无效'); return false; } api.post('/mindspace/v1/agent/jobs', async (req, res) => { if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; try { const job = await mindSpaceAgentJobs.createJob(req.currentUser.id, { jobType: req.body?.job_type, instruction: req.body?.instruction, allowedAssetIds: req.body?.allowed_asset_ids, outputCategoryId: req.body?.output_category_id, outputType: req.body?.output_type, idempotencyKey: req.body?.idempotency_key, locale: req.body?.locale, timezone: req.body?.timezone, capabilities: req.body?.capabilities, }); await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'agent_access', objectType: 'agent_job', objectId: job.id, ip: req.ip, detail: { jobType: job.jobType, assetIds: job.assets.map((asset) => asset.assetId), }, }); return sendData(res, req, job, 201); } catch (error) { return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/agent/jobs/:jobId', async (req, res) => { if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; try { return sendData(res, req, await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId)); } catch (error) { return mindSpaceError(res, req, error); } }); // Server-Sent Events stream of a job's progress, so long-running agent tasks can // be dispatched async (enqueue → 202 → subscribe here) instead of holding a // synchronous streaming connection. Polls the job (ownership enforced by getJob) // and pushes on change; closes on terminal status or client disconnect. api.get('/mindspace/v1/agent/jobs/:jobId/stream', async (req, res) => { if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'timed_out']); let job; try { job = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId); } catch (error) { return mindSpaceError(res, req, error); } res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); const send = (event, payload) => { res.write(`event: ${event}\n`); res.write(`data: ${JSON.stringify(payload)}\n\n`); }; let lastSignature = ''; const emitIfChanged = (current) => { const signature = `${current.status}:${JSON.stringify(current.progress ?? {})}`; if (signature !== lastSignature) { lastSignature = signature; send('progress', current); } return signature; }; emitIfChanged(job); if (TERMINAL.has(job.status)) { send('done', job); return res.end(); } let closed = false; const cleanup = () => { if (closed) return; closed = true; clearInterval(pollTimer); clearInterval(keepAliveTimer); }; const pollTimer = setInterval(async () => { if (closed) return; try { const current = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId); emitIfChanged(current); if (TERMINAL.has(current.status)) { send('done', current); cleanup(); res.end(); } } catch { // Job vanished or transient read error: end the stream rather than leak it. cleanup(); res.end(); } }, Math.max(500, Number(process.env.MINDSPACE_AGENT_SSE_POLL_MS ?? 1000))); // Comment line keeps proxies from closing an idle connection. const keepAliveTimer = setInterval(() => { if (!closed) res.write(': keep-alive\n\n'); }, 15_000); pollTimer.unref?.(); keepAliveTimer.unref?.(); req.on('close', cleanup); }); 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/authorize-image', async (req, res) => { if (!mindSpacePublications) { return res.status(503).json({ error: 'Service unavailable' }); } const assetId = String(req.query.asset_id ?? ''); if (!assetId) { return res.status(400).json({ error: 'Missing asset_id parameter' }); } try { const [refs] = await pool.query( `SELECT pr.access_mode, pr.expires_at FROM h5_publication_asset_refs refs JOIN h5_publish_records pr ON refs.publication_id = pr.id WHERE refs.asset_id = ? AND pr.status = 'online' ORDER BY CASE WHEN pr.access_mode = 'public' THEN 0 WHEN pr.access_mode = 'time_limited' THEN 1 ELSE 2 END, pr.expires_at DESC LIMIT 1`, [assetId], ); if (!refs[0]) { return res.status(403).json({ error: 'Forbidden' }); } const publication = refs[0]; const now = Date.now(); if (publication.access_mode === 'public') { res.set('Cache-Control', 'public, max-age=31536000, immutable'); return res.status(200).json({ ok: true }); } if ( publication.access_mode === 'time_limited' && publication.expires_at && Number(publication.expires_at) > now ) { res.set('Cache-Control', 'public, max-age=60'); return res.status(200).json({ ok: true }); } return res.status(403).json({ error: 'Forbidden' }); } catch (error) { console.error('[authorize-image]', error instanceof Error ? error.message : error); return res.status(500).json({ error: 'Internal server error' }); } }); api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => { if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return; try { const assetId = req.params.assetId; const currentUserId = req.currentUser?.id ?? null; let allowedByPublication = false; let publicationAccessMode = null; if (!currentUserId) { const referrer = String(req.get('referer') ?? req.get('referrer') ?? ''); const isPublicPageReferrer = (() => { if (!referrer) return false; try { return new URL(referrer, resolveRequestOrigin(req) || 'http://localhost').pathname.includes('/public/'); } catch { return referrer.includes('/public/'); } })(); const [refs] = await authPool.query( `SELECT pr.access_mode, pr.expires_at FROM h5_publication_asset_refs refs JOIN h5_publish_records pr ON refs.publication_id = pr.id WHERE refs.asset_id = ? AND pr.status = 'online' ORDER BY CASE WHEN pr.access_mode = 'public' THEN 0 WHEN pr.access_mode = 'time_limited' THEN 1 ELSE 2 END, pr.expires_at DESC LIMIT 1`, [assetId], ); const publication = refs[0] ?? null; if (publication?.access_mode === 'public') { allowedByPublication = true; publicationAccessMode = 'public'; } else if ( publication?.access_mode === 'time_limited' && publication.expires_at && Number(publication.expires_at) > Date.now() ) { allowedByPublication = true; publicationAccessMode = 'time_limited'; } if (!allowedByPublication && isPublicPageReferrer) { allowedByPublication = true; publicationAccessMode = 'public-page-referrer'; } if (!allowedByPublication) { return res.status(401).json({ message: '未授权,请重新登录' }); } } const { asset, path: assetPath } = currentUserId ? await mindSpaceAssets.readAsset(currentUserId, assetId) : await mindSpaceAssets.readPublicAsset(assetId); await mindSpaceAudit?.write({ userId: req.currentUser?.id ?? null, action: 'asset.download', objectType: 'asset', objectId: assetId, ip: req.ip, riskLevel: asset.riskLevel, detail: allowedByPublication ? { accessMode: publicationAccessMode } : undefined, }); res.type(asset.mimeType); const inline = req.query.inline === '1' || req.query.disposition === 'inline' || req.get('sec-fetch-dest') === 'iframe'; if (inline && asset.mimeType.startsWith('image/') && wantsInlineImageViewer(req)) { const downloadUrl = `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download?inline=1`; const html = renderImageAssetViewerHtml({ asset, downloadUrl }); res.set('Content-Type', 'text/html; charset=utf-8'); res.set('Cache-Control', 'private, no-store'); res.setHeader('X-Request-Id', req.requestId); return res.send(html); } res.set( 'Content-Disposition', inline ? `inline; filename*=UTF-8''${encodeURIComponent(asset.filename)}` : `attachment; filename*=UTF-8''${encodeURIComponent(asset.filename)}`, ); res.setHeader('X-Request-Id', req.requestId); return res.sendFile(assetPath); } catch (error) { await mindSpaceAudit?.write({ userId: req.currentUser?.id ?? null, 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, ); if (asset.mimeType === 'text/html') { const relativePath = normalizeWorkspaceRelativePath( String(asset.originalFilename ?? asset.original_filename ?? '').includes('/') ? asset.originalFilename ?? asset.original_filename : `public/${asset.originalFilename ?? asset.original_filename ?? ''}`, ); const existingByPath = relativePath ? await mindSpacePages.findPageByRelativePath(req.currentUser.id, relativePath).catch(() => null) : null; if (existingByPath) { return sendData(res, req, { kind: 'page', categoryCode: existingByPath.categoryCode ?? 'draft', page: existingByPath, }); } } const content = await fs.promises.readFile(assetPath, 'utf8'); const contentFormat = asset.mimeType === 'text/html' ? 'html' : 'markdown'; const htmlRelativePath = contentFormat === 'html' ? normalizeWorkspaceRelativePath( String(asset.originalFilename ?? asset.original_filename ?? '').includes('/') ? asset.originalFilename ?? asset.original_filename : `public/${asset.originalFilename ?? asset.original_filename ?? ''}`, ) : null; 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, ...(htmlRelativePath ? { relative_path: htmlRelativePath } : {}), }, }, ); 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 }; } async function resolveExistingSavedPage(userId, { sessionId, messageId, relativePath } = {}) { if (!mindSpacePages || !userId) return null; const byMessage = await mindSpacePages .findPageBySourceMessage(userId, sessionId, messageId) .catch(() => null); if (byMessage) return byMessage; const normalizedPath = normalizeWorkspaceRelativePath(relativePath); if (!normalizedPath) return null; return mindSpacePages.findPageByRelativePath(userId, normalizedPath).catch(() => null); } const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']); const pageSyncInFlight = new Map(); async function syncUserGeneratedPages(userId) { if (!mindSpacePages || !authPool || !userId) return; let inFlight = pageSyncInFlight.get(userId); if (!inFlight) { inFlight = syncGeneratedPagesFromPublicAssets({ pool: authPool, pageService: mindSpacePages, assetService: mindSpaceAssets, userId, publishDir: resolvePublishDir(__dirname, { id: userId }), syncWorkspaceAssets: mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED ? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options) : null, }) .catch((error) => { console.warn('[MindSpace] page sync failed:', error?.message ?? error); }) .finally(() => { if (pageSyncInFlight.get(userId) === inFlight) { pageSyncInFlight.delete(userId); } }); pageSyncInFlight.set(userId, inFlight); } await inFlight; } 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); let { analysis, resolvedHtml } = await resolveChatSaveAnalysis({ content: source.content, userId: user.id, username: user.username, h5Root, selectedLinkIndex, }); if (resolvedHtml?.content && resolvedHtml.relativePath && authPool) { const storageRoot = process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'); let htmlContent = resolvedHtml.content; const repaired = await repairMissingHtmlAssetReferences({ pool: authPool, userId: user.id, sourceContent: source.content, html: htmlContent, }).catch(() => ({ html: htmlContent, changed: false })); if (repaired.changed) { htmlContent = repaired.html; } const materialized = await materializePrivateAssetsInWorkspaceHtml({ pool: authPool, storageRoot, h5Root, userId: user.id, html: htmlContent, htmlRelativePath: resolvedHtml.relativePath, writeBack: false, }).catch(() => ({ html: htmlContent, count: 0, changed: false })); if (materialized.changed) { htmlContent = materialized.html; } if (htmlContent !== resolvedHtml.content) { const publishDir = resolvePublishDir(h5Root, { id: user.id }); await fsPromises.writeFile( path.join(publishDir, resolvedHtml.relativePath), htmlContent, 'utf8', ); resolvedHtml = { ...resolvedHtml, content: htmlContent }; } } 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 storageRoot = process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'); const resolveAssetDataUri = createAssetDataUriResolver( authPool, storageRoot, req.currentUser.id, ); const svg = await generateHtmlThumbnail(publishDir, thumbRel, bundle.resolvedHtml.content, { title, subtitle, contentBaseDir: path.dirname(bundle.resolvedHtml.absolute), resolveAssetDataUri, 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 sessionId = req.body?.session_id; const messageId = req.body?.message_id; 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; } } const existingPage = await resolveExistingSavedPage(req.currentUser.id, { sessionId, messageId, relativePath: resolvedHtml?.relativePath ?? analysis.relativePath, }); 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), existingPage: existingPage ? { id: existingPage.id, title: existingPage.title, categoryCode: existingPage.categoryCode } : null, }); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body); console.log('[quick-share] bundle resolved, hasHtml:', Boolean(bundle.resolvedHtml)); if (!bundle.resolvedHtml) { throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); } const storageRoot = process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'); const { html: localizedHtml } = await inlinePrivateAssetsInHtml( authPool, storageRoot, req.currentUser.id, bundle.resolvedHtml.content, ); const publishDir = resolvePublishDir(__dirname, req.currentUser); const sharedDir = path.join(publishDir, PUBLIC_ZONE_DIR, 'shared'); await fsPromises.mkdir(sharedDir, { recursive: true }); const basename = bundle.resolvedHtml.filename.replace(/\.html$/i, ''); const filename = `${basename}-${crypto.randomUUID().slice(0, 8)}.html`; const sharedRelativePath = `${PUBLIC_ZONE_DIR}/shared/${filename}`; const sharedHtml = rewriteWorkspacePublicAssetReferences(localizedHtml, sharedRelativePath); const destPath = path.join(sharedDir, filename); await fsPromises.writeFile(destPath, sharedHtml, 'utf8'); const publishKey = req.currentUser.id; const publicUrl = `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}/${sharedRelativePath.split('/').map((part) => encodeURIComponent(part)).join('/')}`; return res.status(201).json({ data: { publicUrl, filename } }); } catch (error) { console.error('[quick-share] error:', 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 ? normalizeWorkspaceRelativePath(analysis.relativePath) : analysis.relativePath, }; let resolvedHtml = null; if (analysis.contentMode === 'static_html') { resolvedHtml = await resolveStaticHtmlContent(analysis).catch(() => null); } const privacyScan = scanContent(resolvedHtml?.content ?? source.content); 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 saveAsNew = Boolean(req.body?.save_as_new); let replacePageId = req.body?.replace_page_id ? String(req.body.replace_page_id).trim() : null; if (!replacePageId && !saveAsNew && analysis.contentMode === 'static_html' && analysis.relativePath) { const existingByPath = await mindSpacePages .findPageByRelativePath(req.currentUser.id, analysis.relativePath) .catch(() => null); if (existingByPath) replacePageId = existingByPath.id; } let page; if (replacePageId) { const existingPage = await mindSpacePages.getPage(req.currentUser.id, replacePageId); page = await mindSpacePages.updatePage(req.currentUser.id, replacePageId, { ...pageInput, expectedVersion: existingPage.versionNo, }); } else { 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 { await syncUserGeneratedPages(req.currentUser.id); const limit = Number.parseInt(String(req.query.limit ?? ''), 10); const offset = Number.parseInt(String(req.query.offset ?? ''), 10); const result = await mindSpacePages.listPages(req.currentUser.id, { status: typeof req.query.status === 'string' ? req.query.status : undefined, categoryCode: typeof req.query.category_code === 'string' ? req.query.category_code : undefined, ...(Number.isFinite(limit) ? { limit } : {}), ...(Number.isFinite(offset) ? { offset } : {}), }); return res.json({ data: result.items, page: { total: result.total, limit: result.limit, offset: result.offset, has_more: result.hasMore, next_cursor: null, }, }); } 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 removeFromPlaza = String(req.query?.remove_from_plaza ?? req.body?.remove_from_plaza ?? '').toLowerCase() === 'true' || req.query?.remove_from_plaza === '1' || req.body?.remove_from_plaza === true; const result = await mindSpacePages.deletePage(req.currentUser.id, req.params.pageId, { removeFromPlaza, }); 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, hiddenPlazaPostCount: result.hiddenPlazaPostCount, 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.post('/agent/mindspace_asset_delete', async (req, res) => { if (!mindSpaceAssetAgent) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { const result = await mindSpaceAssetAgent.applyAgentDelete(req.body ?? {}); await mindSpaceAudit?.write({ userId: result.userId ?? null, action: 'asset.delete', objectType: 'asset', objectId: result.assetId, ip: req.ip, metadata: { via: 'agent' }, }); return sendData(res, req, result); } catch (error) { if (error?.code === 'forbidden') { return sendError(res, req, 403, error.code, error.message); } if (error?.code === 'invalid_request' || error?.code === 'confirmation_required') { 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.post('/mindspace/v1/pages/:pageId/rewrite-download-links', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const content = String(req.body?.content ?? '').trim(); if (!content) { throw Object.assign(new Error('缺少页面内容'), { code: 'invalid_page_input' }); } const html = await mindSpacePages.rewriteHtmlDownloadLinksForPage( req.currentUser.id, req.params.pageId, content, ); return sendData(res, req, { html }); } 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/publish-fix', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const result = await mindSpacePages.localizePrivateResources(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.publish_fix', 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/update-status', async (req, res) => { if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const publication = await mindSpacePublications.updatePublicationStatus( req.currentUser.id, req.params.publicationId, { accessMode: req.body?.access_mode, expiresAt: req.body?.expires_at, }, ); await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'publication.status_updated', objectType: 'publication', objectId: req.params.publicationId, ip: req.ip, }); return sendData(res, req, publication); } catch (error) { return mindSpaceError(res, req, error); } }); api.post('/mindspace/v1/publications/:publicationId/offline', async (req, res) => { if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const publication = await mindSpacePublications.offline( req.currentUser.id, req.params.publicationId, ); await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'page.offline', objectType: 'publication', objectId: req.params.publicationId, ip: req.ip, }); return sendData(res, req, publication); } catch (error) { return mindSpaceError(res, req, error); } }); api.get('/mindspace/v1/publications/:publicationId/stats', async (req, res) => { if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' }); try { return sendData( res, req, await mindSpacePublications.getStats( req.currentUser.id, req.params.publicationId, ), ); } catch (error) { return mindSpaceError(res, req, error); } }); api.get('/plaza/v1/categories', async (req, res) => { if (!ensurePlazaEnabled(res, req)) return; try { return sendData(res, req, { categories: await plazaPosts.listCategories() }); } catch (error) { return plazaRouteError(res, req, error); } }); api.get('/plaza/v1/seo/sitemap', async (req, res) => { if (!ensurePlazaEnabled(res, req)) return; if (!plazaSeo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza SEO 未启用'); try { const data = await plazaSeo.listSitemapData({ postLimit: req.query.post_limit, userLimit: req.query.user_limit, }); return sendData(res, req, data); } catch (error) { return plazaRouteError(res, req, error); } }); api.post('/plaza/v1/attribution/events', async (req, res) => { if (!plazaSeo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); try { const result = await plazaSeo.recordAttribution(req.body ?? {}, plazaClientIp(req)); return sendData(res, req, result, 201); } catch (error) { return plazaRouteError(res, req, error); } }); api.post('/plaza/v1/posts/:id/reports', async (req, res) => { if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const report = await plazaOps.createReport(req.currentUser.id, { target_type: 'post', target_id: req.params.id, reason: req.body?.reason, detail: req.body?.detail, }); return sendData(res, req, { report }, 201); } catch (error) { return plazaRouteError(res, req, error); } }); api.post('/plaza/v1/comments/:id/reports', async (req, res) => { if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const report = await plazaOps.createReport(req.currentUser.id, { target_type: 'comment', target_id: req.params.id, reason: req.body?.reason, detail: req.body?.detail, }); return sendData(res, req, { report }, 201); } catch (error) { return plazaRouteError(res, req, error); } }); api.get('/plaza/v1/feed', async (req, res) => { if (!ensurePlazaEnabled(res, req)) return; try { const sessionId = resolvePlazaSessionId(req, res); const feed = await plazaPosts.listFeed({ sort: req.query.sort, categorySlug: req.query.category ?? null, cursor: req.query.cursor ?? null, limit: req.query.limit, viewerId: req.currentUser?.id ?? null, sessionId, }); return sendData(res, req, feed); } catch (error) { return plazaRouteError(res, req, error); } }); api.post('/plaza/v1/events', async (req, res) => { if (!ensurePlazaEnabled(res, req)) return; if (!plazaEvents) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); try { const sessionId = String(req.body?.session_id ?? '').trim() || resolvePlazaSessionId(req, res); const result = await plazaEvents.recordEvents({ userId: req.currentUser?.id ?? null, sessionId, events: req.body?.events ?? [], }); return sendData(res, req, { ...result, session_id: sessionId }, 201); } catch (error) { return plazaRouteError(res, req, error); } }); api.post('/plaza/v1/posts/:id/reactions', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const result = await plazaInteractions.addReaction( req.currentUser.id, req.params.id, req.body?.type, ); const eventType = reactionEventType(result.type); if (eventType) { recordPlazaEventsAsync(req, res, [{ event_type: eventType, post_id: req.params.id }]); } return sendData(res, req, result); } catch (error) { return plazaRouteError(res, req, error); } }); api.delete('/plaza/v1/posts/:id/reactions/:type', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const result = await plazaInteractions.removeReaction( req.currentUser.id, req.params.id, req.params.type, ); return sendData(res, req, result); } catch (error) { return plazaRouteError(res, req, error); } }); api.get('/plaza/v1/posts/:id/comments', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; try { const comments = await plazaInteractions.listComments(req.params.id, { cursor: req.query.cursor ?? null, limit: req.query.limit, parentId: req.query.parent_id ?? null, viewerId: req.currentUser?.id ?? null, }); return sendData(res, req, comments); } catch (error) { return plazaRouteError(res, req, error); } }); api.post('/plaza/v1/posts/:id/comments', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const comment = await plazaInteractions.createComment(req.currentUser.id, req.params.id, req.body ?? {}); recordPlazaEventsAsync(req, res, [{ event_type: 'comment', post_id: req.params.id }]); return sendData(res, req, { comment }, 201); } catch (error) { return plazaRouteError(res, req, error); } }); api.delete('/plaza/v1/comments/:id', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const comment = await plazaInteractions.deleteComment(req.currentUser.id, req.params.id); return sendData(res, req, { comment }); } catch (error) { return plazaRouteError(res, req, error); } }); api.post('/plaza/v1/comments/:id/reactions', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const liked = req.body?.liked !== false; const result = await plazaInteractions.toggleCommentLike(req.currentUser.id, req.params.id, liked); return sendData(res, req, result); } catch (error) { return plazaRouteError(res, req, error); } }); api.get('/plaza/v1/users/:slug', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; try { const profile = await plazaInteractions.getUserProfile( req.params.slug, req.currentUser?.id ?? null, ); return sendData(res, req, profile); } catch (error) { return plazaRouteError(res, req, error); } }); api.get('/plaza/v1/users/:slug/posts', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; try { const feed = await plazaInteractions.listUserPosts(req.params.slug, { cursor: req.query.cursor ?? null, limit: req.query.limit, viewerId: req.currentUser?.id ?? null, }); return sendData(res, req, feed); } catch (error) { return plazaRouteError(res, req, error); } }); api.post('/plaza/v1/users/:slug/follow', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const result = await plazaInteractions.followUser(req.currentUser.id, req.params.slug); return sendData(res, req, result); } catch (error) { return plazaRouteError(res, req, error); } }); api.delete('/plaza/v1/users/:slug/follow', async (req, res) => { if (!ensurePlazaInteractions(res, req)) return; if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); try { const result = await plazaInteractions.unfollowUser(req.currentUser.id, req.params.slug); return sendData(res, req, result); } catch (error) { return plazaRouteError(res, req, error); } }); api.get('/plaza/v1/posts/:id', async (req, res) => { if (!ensurePlazaEnabled(res, req)) return; try { const post = await plazaPosts.getPostById(req.params.id, { viewerId: req.currentUser?.id ?? null, }); void plazaRedis.recordView(req.params.id, plazaClientIp(req)).catch(() => {}); recordPlazaEventsAsync(req, res, [{ event_type: 'view', post_id: req.params.id }]); return sendData(res, req, { post }); } catch (error) { return plazaRouteError(res, req, error); } }); api.post('/plaza/v1/posts', async (req, res) => { if (!ensurePlazaEnabled(res, req)) return; if (!req.currentUser) { return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); } try { const post = await plazaPosts.createPost(req.currentUser.id, req.body ?? {}); return sendData(res, req, { post }, 201); } catch (error) { return plazaRouteError(res, req, error); } }); api.patch('/plaza/v1/posts/:id', async (req, res) => { if (!ensurePlazaEnabled(res, req)) return; if (!req.currentUser) { return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); } try { const post = await plazaPosts.updatePost(req.currentUser.id, req.params.id, req.body ?? {}); return sendData(res, req, { post }); } catch (error) { return plazaRouteError(res, req, error); } }); api.delete('/plaza/v1/posts/:id', async (req, res) => { if (!ensurePlazaEnabled(res, req)) return; if (!req.currentUser) { return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); } try { const post = await plazaPosts.hidePost(req.currentUser.id, req.params.id); return sendData(res, req, { post }); } catch (error) { return plazaRouteError(res, req, error); } }); function runHandlerChain(chain, req, res, next) { let index = 0; const run = (err) => { if (err) return next(err); const layer = chain[index++]; if (!layer) return; layer(req, res, (error) => run(error)); }; run(); } api.post('/llm/apply-local-fallback', async (req, res) => { // Client calls after creditsExhausted or relay 500 (see useTKMindChat). await userAuthReady; if (!userAuth || !llmProviderService || !tkmindProxy) { return res.status(503).json({ message: '未启用 LLM 配置' }); } const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const sessionId = String(req.body?.session_id ?? '').trim(); if (!sessionId) return res.status(400).json({ message: '缺少 session_id' }); const owns = await userAuth.ownsSession(me.id, sessionId); if (!owns) return res.status(403).json({ message: '无权访问该会话' }); try { const result = await tkmindProxy.applyLocalFallbackForSession(sessionId); if (!result.ok) return res.status(503).json(result); res.json(result); } catch (err) { res.status(500).json({ message: err instanceof Error ? err.message : '切换本地 LLM 失败', }); } }); api.post('/agent/start', async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy) return next(); return runHandlerChain(tkmindProxy.handlers['POST /agent/start'], req, res, next); }); api.post('/agent/runs', async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy || !agentRunGateway) return next(); return runHandlerChain( [ tkmindProxy.requireUser, tkmindProxy.ensureChatAllowed, createPostAgentRunsHandler({ userAuth, agentRunGateway }), ], req, res, next, ); }); api.get('/agent/runs/:runId', async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy || !agentRunGateway) return next(); return runHandlerChain( [ tkmindProxy.requireUser, createGetAgentRunHandler({ agentRunGateway }), ], req, res, next, ); }); api.get('/agent/runs/:runId/events', async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy || !agentRunGateway) return next(); return runHandlerChain( [ tkmindProxy.requireUser, createAgentRunEventsHandler({ agentRunGateway }), ], 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); }); // Session detail — serve from DB snapshot cache when fresh, fall through to Goose on miss. api.get('/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: '无权访问该会话' }); } // Hints from the client (session list already has these values). const hintMc = req.query.hint_mc ? Number(req.query.hint_mc) : null; const hintUa = req.query.hint_ua ? String(req.query.hint_ua) : null; try { if (sessionSnapshotService?.isEnabled()) { const snapshot = await sessionSnapshotService.get(sessionId); if (snapshot) { // REGRESSION GUARD: without both hints, stale snapshot can wipe mid-turn chat. const canUseSnapshotCache = hintMc != null && hintUa != null; const mcMatch = snapshot.meta.synced_msg_count === hintMc; const uaMatch = snapshot.meta.source_updated_at === hintUa; if (canUseSnapshotCache && mcMatch && uaMatch) { const sanitizedMessages = sanitizeSessionConversationPublicHtmlLinks( snapshot.messages, req.currentUser, ); // Cache hit — reconstruct a Goose-compatible session response. const cachedGooseSession = { ...snapshot.session, // Embed only userVisible messages so getSession callers still work. conversation: sanitizedMessages, }; return res.json(cachedGooseSession); } } } } catch { // Snapshot read error: fall through silently to Goose. } // Cache miss — proxy to Goose and write-through on success. try { const target = await tkmindProxy.resolveTarget(sessionId); const upstream = await tkmindProxy.apiFetchTo( target, `/sessions/${encodeURIComponent(sessionId)}`, { method: 'GET' }, ); if (!upstream.ok) { const text = await upstream.text().catch(() => ''); return res.status(upstream.status).send(text); } const gooseSession = await upstream.json(); if (Array.isArray(gooseSession.conversation)) { gooseSession.conversation = sanitizeSessionConversationPublicHtmlLinks( gooseSession.conversation, req.currentUser, ); } // Write-through: persist snapshot async, don't block the response. if (sessionSnapshotService?.isEnabled()) { const messages = (gooseSession.conversation ?? []) .filter((m) => m.metadata?.userVisible); if (messages.length > 0) { void sessionSnapshotService .save(sessionId, req.currentUser.id, gooseSession, messages) .catch(() => {}); } } return res.json(gooseSession); } catch (err) { return res.status(502).json({ message: err instanceof Error ? err.message : '读取会话失败' }); } }); api.delete('/sessions/:sessionId', async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy) return next(); const sessionId = req.params.sessionId; const owns = await userAuth.ownsSession(req.currentUser.id, sessionId); if (!owns) { return res.status(403).json({ message: '无权访问该会话' }); } try { const deleteTarget = await tkmindProxy.resolveTarget(sessionId); const upstream = await tkmindProxy.apiFetchTo(deleteTarget, `/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE', }); if (!upstream.ok && upstream.status !== 404) { const text = await upstream.text().catch(() => ''); return res.status(upstream.status).send(text || '删除会话失败'); } await userAuth.unregisterAgentSession(req.currentUser.id, sessionId); // Remove snapshot so it doesn't linger after deletion. void sessionSnapshotService?.remove(sessionId).catch(() => {}); 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: '无权访问该会话' }); } const publishDir = resolvePublishDir(__dirname, { id: req.currentUser.id }); const syncPublicHtmlDuringStream = (event) => { materializePublicHtmlWritesFromSessionEvent(event, { publishDir }); }; // After Finish, refresh the snapshot and persist any newly generated public // workspace HTML into the asset store before a later restart rebuilds the // workspace from DB-backed assets only. const onAfterFinish = async (sid, uid) => { const apiFetchFn = async (pathname, init) => { const target = await tkmindProxy.resolveTarget(sid); return tkmindProxy.apiFetchTo(target, pathname, init); }; let messages = null; if (sessionSnapshotService?.isEnabled()) { await sessionSnapshotService.refresh(sid, uid, apiFetchFn); messages = (await sessionSnapshotService.get(sid))?.messages ?? null; } else { try { const upstream = await apiFetchFn(`/sessions/${encodeURIComponent(sid)}`, { method: 'GET' }); if (upstream.ok) { const payload = await upstream.json().catch(() => null); messages = Array.isArray(payload?.conversation) ? payload.conversation.filter((message) => message?.metadata?.userVisible) : null; } } catch { messages = null; } } const syncResult = await syncPublicHtmlAfterFinish({ messages, currentUser: req.currentUser, publishDir, syncWorkspaceAssets: WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets ? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options) : null, }); if (Array.isArray(syncResult?.docxSync?.missing) && syncResult.docxSync.missing.length > 0) { console.warn( `[MindSpace] missing public download files after finish for user ${uid}: ${syncResult.docxSync.missing.join(', ')}`, ); } await syncUserGeneratedPages(uid); }; return tkmindProxy.proxySessionEvents(req, res, sessionId, { onAfterFinish, onEvent: syncPublicHtmlDuringStream, }); }); 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}`); } if (req.method === 'GET' && /^\/agent\/runs\/[^/]+\/events$/.test(req.path)) { return res.status(503).json({ message: 'Agent Run SSE 接口需在 Portal 本地处理,请确认 server.mjs 已更新并重启后端', code: 'AGENT_RUNS_NATIVE_REQUIRED', }); } const sessionMatch = req.path.match(/^\/sessions\/([^/]+)/); if (sessionMatch) { const sessionId = sessionMatch[1]; if (req.path.endsWith('/events') && req.method === 'GET') { return next(); } if (req.path.endsWith('/reply') && req.method === 'POST') { return res.status(410).json({ message: '聊天提交入口已统一为 POST /agent/runs', code: 'AGENT_RUNS_REQUIRED', }); } const owns = await userAuth.ownsSession(req.currentUser.id, sessionId); if (!owns) { return res.status(403).json({ message: '无权访问该会话' }); } } 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 scriptSrcDirective({ inline = false, urls = [], hashes = [] } = {}) { const parts = []; if (inline) parts.push("'unsafe-inline'"); for (const hash of hashes) parts.push(`'sha256-${hash}'`); for (const url of urls) parts.push(url); return parts.length ? `script-src ${parts.join(' ')}` : "script-src 'none'"; } function publishedPageCsp(html, { embed = false, raw = false, wechatShare = false, scriptHashes = [] } = {}) { const isFullHtml = /^\s*]/i.test(html); if (embed && isFullHtml) { return publishedPageCspForEmbed(true); } if (wechatShare && isFullHtml) { return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline' https://res.wx.qq.com"; } if (raw && isFullHtml) { return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline'"; } if (isFullHtml) { return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrcDirective({ hashes: scriptHashes })}`; } return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'"; } const PUBLIC_FILE_SHARE_SCRIPT = `(function(){var root=document.querySelector('[data-mindspace-public-share]');if(!root)return;var button=root.querySelector('button');var status=root.querySelector('small');var timer=null;function setStatus(text,err){if(!status)return;status.textContent=text||'';status.classList.toggle('is-error',!!err);if(timer)clearTimeout(timer);if(text)timer=setTimeout(function(){status.textContent='';status.classList.remove('is-error');},2200);}function fallbackCopy(text){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();var ok=document.execCommand('copy');ta.remove();if(!ok)throw new Error('复制失败');}async function copyText(text){if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(text);return;}catch(e){}}fallbackCopy(text);}button&&button.addEventListener('click',async function(){var url=location.href.split('#')[0];var title=document.title||'MindSpace';try{if(navigator.share){await navigator.share({title:title,url:url});setStatus('已打开分享');return;}await copyText(url);setStatus('链接已复制');}catch(e){setStatus(e&&e.message?e.message:'分享失败',true);}});})();`; const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto .createHash('sha256') .update(PUBLIC_FILE_SHARE_SCRIPT) .digest('base64'); function injectPublicFileShareButton(html) { const source = String(html ?? ''); if (!source || source.includes('data-mindspace-public-share')) { return { html: source, scriptHashes: [] }; } const markup = `
`; if (/<\/body>/i.test(source)) { return { html: source.replace(/<\/body>/i, `${markup}`), scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH], }; } return { html: `${source}${markup}`, scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH], }; } function extractOgImageUrl(html) { return ( String(html ?? '').match(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)?.[1] || String(html ?? '').match(/]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i)?.[1] || '' ); } function extractShareMetaFromPageHtml(html) { const signals = extractCoverSignals(String(html ?? '')); return { title: detectPublishedPageTitle(html), subtitle: signals.subtitle, }; } function appendQueryParam(url, key, value) { const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`; } function resolveRequestOrigin(req) { const host = (req.headers['x-forwarded-host'] || req.headers.host || '').toString().split(',')[0].trim(); if (!host) return ''; const isLocalHost = /^(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|100\.)/i.test(host); const fwdProto = (req.headers['x-forwarded-proto'] || '').toString().split(',')[0].trim(); const proto = isLocalHost ? fwdProto || req.protocol || 'http' : 'https'; return `${proto}://${host}`; } function detectPublishedPageTitle(html) { const match = String(html ?? '').match(/]*>([^<]+)<\/title>/i); return match?.[1]?.replace(/\s+/g, ' ').trim() || 'MindSpace 页面'; } function publishedPageShellHtml({ iframeUrl, shareUrl, title }) { const iframeSrc = escapePublicHtml(iframeUrl); const safeShareUrl = escapePublicHtml(shareUrl); const safeTitle = escapePublicHtml(title); const serializedShareUrl = JSON.stringify(shareUrl).replace(/ ${safeTitle}
`; } function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) { let html = result.html; const origin = resolveRequestOrigin(req); const pageUrl = req.originalUrl ? new URL(req.originalUrl, origin || 'http://localhost').toString().split('#')[0] : ''; const pageDirUrl = pageUrl ? `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}` : ''; const wechatShare = !embed && isWechatUserAgent(req.get('user-agent') || ''); if (embed) { html = preparePublicationHtmlForEmbed(html); allowPlazaEmbedFrame(res); } else if (raw) { html = stripPublicationHtmlCspMeta(html); } if (!embed) { try { html = injectOgTags(html, { origin, pageUrl, pageDirUrl }); if (wechatShare) html = injectWechatShareBridge(html, { pageUrl }); } catch { // Never block public page delivery on share metadata injection. } } const isFullHtml = /^\s*]/i.test(html); const canWrapWithShell = !embed && !raw && isFullHtml && result.publication?.accessMode !== 'password'; if (canWrapWithShell) { const title = detectPublishedPageTitle(html); const rawUrl = appendQueryParam(req.originalUrl || req.url || '', 'view', 'raw'); let shellHtml = publishedPageShellHtml({ iframeUrl: rawUrl, shareUrl: pageUrl, title, }); try { shellHtml = injectOgTags(shellHtml, { origin, pageUrl, pageDirUrl, fallbackImageUrl: extractOgImageUrl(html), meta: extractShareMetaFromPageHtml(html), }); if (wechatShare) shellHtml = injectWechatShareBridge(shellHtml, { pageUrl }); } catch { // Keep the share shell usable even if metadata injection fails. } res.set('Content-Type', 'text/html; charset=utf-8'); res.set( 'Content-Security-Policy', wechatShare ? "default-src 'none'; style-src 'unsafe-inline'; img-src data: https:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net https://res.wx.qq.com; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'" : "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'", ); res.set( 'Cache-Control', result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store', ); return res.send(shellHtml); } res.set('Content-Type', 'text/html; charset=utf-8'); res.set('Content-Security-Policy', publishedPageCsp(html, { embed, raw, wechatShare })); res.set( 'Cache-Control', result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store', ); return res.send(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(req, res, result, { embed: isPlazaEmbedRequest(req.query), raw: String(req.query.view ?? '').toLowerCase() === 'raw', }); } catch (error) { if (error?.code === 'publication_password_required') { return res.status(password ? 403 : 200).send(passwordGateHtml(req.originalUrl)); } if (error?.code === 'publication_login_required') { return res.status(401).send('请先登录 TKMind 后再访问此页面'); } if (error?.code === 'publication_not_found') return res.status(404).send('页面不存在或已下线'); return res.status(500).send('页面加载失败'); } } app.use(async (req, res, next) => { const thumbnailMatch = /^\/u\/([^/]+)\/pages\/([^/]+)\.thumbnail\.png$/.exec(req.path); if (!thumbnailMatch) return next(); const ownerSlug = thumbnailMatch[1]; const urlSlug = thumbnailMatch[2]; await userAuthReady; if (!authPool || !mindSpacePages) return res.status(503).send('MindSpace 未启用'); try { const [rows] = await authPool.query( `SELECT pr.user_id, pr.page_id FROM h5_publish_records pr JOIN h5_users u ON u.id = pr.user_id WHERE COALESCE(u.slug, u.username) = ? AND pr.url_slug = ? AND pr.status = 'online' ORDER BY pr.published_at DESC LIMIT 1`, [ownerSlug, urlSlug], ); const row = rows[0]; if (!row) return res.status(404).send('缩略图不存在'); const svg = await mindSpacePages.renderThumbnail(row.user_id, row.page_id); res.set('Content-Type', 'image/png'); res.set('Cache-Control', 'public, max-age=300'); return res.send(rasterizeThumbnailSvgToPng(svg)); } catch (error) { return res.status(500).send('缩略图加载失败'); } }); app.get('/u/:ownerSlug/pages/:urlSlug', async (req, res) => { return resolvePublishedRoute(req, res); }); app.get('/u/:ownerSlug', async (req, res) => { await userAuthReady; if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用'); try { const data = await mindSpacePublications.getPublicHomepage(req.params.ownerSlug); res.set('Content-Type', 'text/html; charset=utf-8'); res.set( 'Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'", ); res.set('Cache-Control', 'public, max-age=60'); return res.send(publicHomepageHtml(data)); } catch (error) { if (error?.code === 'publication_not_found') return res.status(404).send('主页不存在'); return res.status(500).send('主页加载失败'); } }); app.post( '/u/:ownerSlug/pages/:urlSlug', express.urlencoded({ extended: false, limit: '2kb' }), async (req, res) => resolvePublishedRoute(req, res, req.body?.password), ); app.get('/s/:token', async (req, res) => { await userAuthReady; if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用'); try { const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null; return sendPublishedPage( req, res, await mindSpacePublications.resolvePrivateLink(req.params.token, viewer?.id, { userAgent: req.get('user-agent'), referrer: req.get('referer'), }), { embed: isPlazaEmbedRequest(req.query), raw: String(req.query.view ?? '').toLowerCase() === 'raw', }, ); } catch (error) { if (error?.code === 'publication_not_found') return res.status(404).send('页面不存在或已下线'); return res.status(500).send('页面加载失败'); } }); const USERNAME_SLUG = /^[a-z0-9_]{2,32}$/; async function resolvePublishDirKey(segment) { const lower = String(segment ?? '').trim().toLowerCase(); if (!lower) return null; if (PUBLISH_KEY_UUID.test(lower)) return lower; if (USERNAME_SLUG.test(lower)) { if (authPool) { const [rows] = await authPool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [lower]); if (rows[0]?.id) return String(rows[0].id).toLowerCase(); } return lower; } return null; } /** * Send a file, injecting Open Graph tags for .html so forwarded links unfurl with a cover. * Non-HTML files (assets, etc.) are streamed unchanged via res.sendFile. */ function sendPublishFile(req, res, filePath) { if (!filePath.toLowerCase().endsWith('.html')) { res.sendFile(filePath, (err) => { if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' }); }); return; } let html; try { html = fs.readFileSync(filePath, 'utf8'); } catch { res.status(404).json({ message: '文件不存在' }); return; } const embed = isPlazaEmbedRequest(req.query); if (embed) { html = preparePublicationHtmlForEmbed(html); allowPlazaEmbedFrame(res); res.set('Content-Security-Policy', publishedPageCsp(html, { embed })); } const host = (req.headers['x-forwarded-host'] || req.headers.host || '').toString().split(',')[0].trim(); // Share cards (esp. WeChat) require https og:image. The edge only serves public domains // over https, but the proxy chain forwards X-Forwarded-Proto: http to the node — so for a // public host we force https and ignore the (wrong) forwarded scheme. Loopback/LAN stays http. const isLocalHost = /^(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|100\.)/i.test(host); const fwdProto = (req.headers['x-forwarded-proto'] || '').toString().split(',')[0].trim(); const proto = isLocalHost ? fwdProto || req.protocol || 'http' : 'https'; const origin = host ? `${proto}://${host}` : ''; const cleanPath = req.originalUrl.split('?')[0].split('#')[0]; // Distinguish an explicit file URL (.../space.html) from a directory that resolves to // index.html (.../ or ...//) so relative covers and the thumbnail resolve correctly. const servedName = path.basename(filePath); const urlLast = decodeURIComponent(cleanPath.split('/').filter(Boolean).pop() ?? ''); const isImplicitIndex = urlLast.toLowerCase() !== servedName.toLowerCase(); const pageUrl = origin ? `${origin}${isImplicitIndex && !cleanPath.endsWith('/') ? `${cleanPath}/` : cleanPath}` : ''; const pageDirUrl = !origin ? '' : isImplicitIndex ? `${origin}${cleanPath.endsWith('/') ? cleanPath : `${cleanPath}/`}` : `${origin}${cleanPath.slice(0, cleanPath.lastIndexOf('/') + 1)}`; // Guaranteed fallback cover: the page's feed thumbnail, served on demand as PNG. let fallbackImageUrl = ''; const svgSibling = filePath.replace(/\.[^./]+$/, '.thumbnail.svg'); if (pageDirUrl && fs.existsSync(svgSibling)) { const pngName = path.basename(thumbnailPngPathForSvg(svgSibling)); fallbackImageUrl = `${pageDirUrl}${pngName}`; } try { html = injectOgTags(html, { origin, pageUrl, pageDirUrl, fallbackImageUrl }); const wechatShare = !embed && isWechatUserAgent(req.get('user-agent') || ''); if (wechatShare) { html = injectWechatShareBridge(html, { pageUrl }); } const shareInjection = !embed ? injectPublicFileShareButton(html) : { html, scriptHashes: [] }; html = shareInjection.html; res.set('Content-Security-Policy', publishedPageCsp(html, { embed, wechatShare, scriptHashes: shareInjection.scriptHashes, })); } catch { // On any parse failure, fall back to the original HTML — never break page delivery. res.set('Content-Security-Policy', publishedPageCsp(html, { embed })); } res.set('Content-Type', 'text/html; charset=utf-8'); res.send(html); } const MISPLACED_PUBLIC_HTML_NAME = /^[a-z0-9][a-z0-9._-]{0,127}\.html$/i; async function recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest) { if (rest.length !== 2 || rest[0] !== PUBLIC_ZONE_DIR) return null; const filename = rest[1]; if (!MISPLACED_PUBLIC_HTML_NAME.test(filename)) return null; const destination = path.resolve(targetDir, PUBLIC_ZONE_DIR, filename); if (!destination.startsWith(`${resolvedRoot}${path.sep}`)) return null; const candidates = [ path.resolve(__dirname, filename), path.resolve(__dirname, PUBLIC_ZONE_DIR, filename), ]; for (const candidate of candidates) { if (candidate === destination) continue; if (!candidate.startsWith(`${__dirname}${path.sep}`)) continue; if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) continue; fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.copyFileSync(candidate, destination); await ensureWorkspaceHtmlThumbnail(targetDir, `${PUBLIC_ZONE_DIR}/${filename}`).catch(() => {}); console.warn( `[MindSpace] recovered misplaced public HTML ${path.relative(__dirname, candidate)} -> ${path.relative(__dirname, destination)}`, ); return destination; } return null; } function decodePathSegment(segment) { try { return decodeURIComponent(segment); } catch { return segment; } } 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).map(decodePathSegment)]; const targetDir = path.join(__dirname, PUBLISH_ROOT_DIR, username); const resolvedRoot = path.resolve(targetDir); const filePath = path.join(targetDir, ...rest); const resolvedPath = path.resolve(filePath); if (!resolvedPath.startsWith(`${resolvedRoot}${path.sep}`) && resolvedPath !== resolvedRoot) { res.status(403).json({ message: '禁止访问' }); return; } if (!fs.existsSync(targetDir)) { res.status(404).json({ message: '用户不存在' }); return; } // On-demand cover: rasterize .thumbnail.svg → .thumbnail.png the first time a // forwarded link's og:image is fetched (and refresh it when the SVG changes). if (/\.thumbnail\.png$/i.test(resolvedPath)) { const svgSibling = resolvedPath.replace(/\.png$/i, '.svg'); if (fs.existsSync(svgSibling)) { const pngPath = ensureThumbnailPng(svgSibling); if (pngPath && fs.existsSync(pngPath)) { res.set('Cache-Control', 'public, max-age=300'); res.sendFile(pngPath); return; } } } if (!fs.existsSync(resolvedPath)) { if (rest.length === 1 && rest[0].toLowerCase().endsWith('.html')) { const publicFallback = path.resolve(targetDir, PUBLIC_ZONE_DIR, rest[0]); if ( publicFallback.startsWith(`${resolvedRoot}${path.sep}`) && fs.existsSync(publicFallback) && fs.statSync(publicFallback).isFile() ) { const canonical = `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(username)}/${PUBLIC_ZONE_DIR}/${encodeURIComponent(rest[0])}`; res.redirect(301, canonical); return; } } if (rest.length === 2 && rest[0] === PUBLIC_ZONE_DIR && rest[1].toLowerCase().endsWith('.html')) { const similarRelativePath = await resolveClosestHtmlRelativePath(targetDir, `${PUBLIC_ZONE_DIR}/${rest[1]}`); if (similarRelativePath) { const canonical = `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(username)}/${similarRelativePath .split('/') .map((part) => encodeURIComponent(part)) .join('/')}`; res.redirect(301, canonical); return; } } const recoveredPublicHtml = await recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest); if (recoveredPublicHtml) { sendPublishFile(req, res, recoveredPublicHtml); return; } res.status(404).json({ message: '文件不存在' }); return; } if (fs.statSync(resolvedPath).isDirectory()) { const indexPath = path.join(resolvedPath, 'index.html'); if (fs.existsSync(indexPath)) { sendPublishFile(req, res, indexPath); return; } res.status(404).json({ message: '目录中没有 index.html' }); return; } sendPublishFile(req, res, resolvedPath); } app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => { await userAuthReady; return serveUserPublishFile(req, res, next); }); app.use('/temp', (req, res) => { res.redirect(301, `/${PUBLISH_ROOT_DIR}${req.url}`); }); // Serve user public pages: /user//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/*`, (_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.use('/brand', express.static(path.join(__dirname, 'public/brand'), { maxAge: '7d' })); app.get('/dev/wechat-share-preview', async (req, res) => { try { const rawTarget = String(req.query.url ?? req.query.path ?? '').trim(); if (!rawTarget) { return res.status(400).type('text/plain; charset=utf-8').send('缺少 url 参数,例如 ?url=/MindSpace/john/public/demo.html'); } const origin = resolveRequestOrigin(req) || `http://${HOST}:${PORT}`; const targetUrl = rawTarget.startsWith('http') ? new URL(rawTarget) : new URL(rawTarget.startsWith('/') ? rawTarget : `/${rawTarget}`, origin); const upstream = await fetch(targetUrl.toString(), { headers: { 'user-agent': req.get('user-agent') || 'tkmind-wechat-share-preview', accept: 'text/html,*/*', }, redirect: 'follow', }); if (!upstream.ok) { return res.status(upstream.status).type('text/plain; charset=utf-8').send(`页面请求失败:HTTP ${upstream.status}`); } let html = await upstream.text(); const pageUrl = targetUrl.toString().split('#')[0]; const pageDirUrl = `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}`; html = injectOgTags(html, { origin: targetUrl.origin, pageUrl, pageDirUrl }); const preview = extractSharePreviewMeta(html, { origin: targetUrl.origin, pageUrl, pageDirUrl, }); res.set('Content-Type', 'text/html; charset=utf-8'); res.set('Cache-Control', 'no-store'); return res.send( renderWechatSharePreviewHtml(preview, { note: '此预览读取页面最终 HTML 中的 Open Graph 标签,可用来对照微信里粘贴链接后的卡片效果。', }), ); } catch (err) { return res.status(500).type('text/plain; charset=utf-8').send(String(err?.message ?? err)); } }); app.use('/dev', express.static(path.join(__dirname, 'public/dev'), { maxAge: 0 })); 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('/auth', (_req, res) => { res.status(404).json({ message: '接口不存在,请重启后端服务(node server.mjs 或 pnpm dev)' }); }); app.use('/admin-api', (_req, res) => { res.status(404).json({ message: '接口不存在,请重启后端服务(node server.mjs 或 pnpm dev)' }); }); 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) => { if (isDatabaseConfigured() && !enabled && process.env.NODE_ENV === 'production') { console.error('Refusing to start portal without user auth while database is configured'); process.exit(1); } app.listen(PORT, HOST, () => { console.log(`TKMind H5 @ http://${HOST}:${PORT}`); console.log(`Proxy -> ${API_TARGETS.join(', ')}`); console.log(`Auth -> ${enabled ? 'multi-user (MySQL)' : legacyAuth ? 'legacy password' : 'disabled'}`); console.log(`Wiki @ http://${HOST}:${PORT}/${PUBLISH_ROOT_DIR}/wiki`); }); });