Files
memind/server.mjs
T
2026-06-27 21:31:02 +08:00

4653 lines
167 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import express from 'express';
import crypto from 'node:crypto';
import fs from 'node:fs';
import { createProxyMiddleware } from 'http-proxy-middleware';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
AUTH_COOKIE,
clearSessionCookie,
createAuthManager,
parseCookies,
sessionCookie,
} from './auth.mjs';
import { createDbPool, initSchema, isDatabaseConfigured } from './db.mjs';
import { createTkmindProxy } from './tkmind-proxy.mjs';
import {
clearUserSessionCookie,
createUserAuth,
resolveCookieDomainForRequest,
USER_COOKIE,
userLoginCookies,
userSessionCookie,
} from './user-auth.mjs';
import { createWikiAuth } from './wiki-auth.mjs';
import { isLocalDevHostname } from './scripts/local-test-config.mjs';
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir } from './user-publish.mjs';
import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs';
import { attachRequestId, sendData, sendError } from './api-response.mjs';
import { createMindSpaceAuditWriter } from './mindspace-audit.mjs';
import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs';
import { createMindSpaceService, DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs';
import { ensureMindSpaceConfig } from './mindspace-config.mjs';
import { createAssetService } from './mindspace-assets.mjs';
import { createPageService, pageInternals } from './mindspace-pages.mjs';
import { createPageLiveEditService } from './mindspace-page-live-edit.mjs';
import { createPageEditSessionService } from './mindspace-page-edit-session.mjs';
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
import { createPublicationService } from './mindspace-publications.mjs';
import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs';
import { createPlazaEventService } from './plaza-events.mjs';
import { createPlazaRecommendService } from './plaza-recommend.mjs';
import { createPlazaInteractionService } from './plaza-interactions.mjs';
import {
ensureAlgorithmConfig,
loadAlgorithmConfig,
recalculateHotScores,
} from './plaza-algorithm.mjs';
import { createPlazaRedis, createNoopPlazaRedis } from './plaza-redis.mjs';
import { startPlazaTasks, writebackPublications } from './plaza-tasks.mjs';
import { createPlazaSeoService } from './plaza-seo.mjs';
import { createPlazaOpsService } from './plaza-ops.mjs';
import { 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,
resolveChatSaveAnalysis,
resolveStaticHtmlContent,
} from './mindspace-chat-save.mjs';
import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
import { injectOgTags } 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 { createScheduleService } from './schedule-service.mjs';
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
import { createSessionSnapshotService } from './session-snapshot.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 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();
// 无状态前端节点(如 105MindSpace 经 rclone 挂载)需设 MEMIND_WORKSPACE_MAINTENANCE=0
// 否则启动时对挂载树做 readdir/递归 fs.watch 会占满 libuv 线程池导致 boot 卡死。
const WORKSPACE_MAINTENANCE_ENABLED = process.env.MEMIND_WORKSPACE_MAINTENANCE !== '0';
const USERS_ROOT = process.env.H5_USERS_ROOT ?? path.join(__dirname, 'users');
const app = express();
app.set('trust proxy', 1);
const isSecureRequest = (req) =>
req.secure || req.get('x-forwarded-proto')?.split(',')[0]?.trim() === 'https';
const msFlags = mindspaceFlags();
app.use(attachRequestId);
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(self), geolocation=()');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
if (isSecureRequest(req)) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
next();
});
function csrfOriginCheck(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const host = req.get('host');
const origin = req.get('origin');
const referer = req.get('referer');
if (!origin && !referer) return next();
const requestHostname = (host ?? '').split(':')[0];
const allowed = [origin, referer].some((value) => {
if (!value) return false;
try {
const source = new URL(value);
if (source.host === host) return true;
return (
isLocalDevHostname(requestHostname) && isLocalDevHostname(source.hostname)
);
} catch {
return false;
}
});
if (!allowed) {
return sendError(res, req, 403, 'csrf_failed', '来源校验失败');
}
return next();
}
app.use('/api', csrfOriginCheck);
app.use('/auth', csrfOriginCheck);
const jsonBody = express.json({ limit: '1mb' });
const jsonUnlessMultipart = (req, res, next) => {
if ((req.headers['content-type'] ?? '').includes('multipart/form-data')) return next();
return jsonBody(req, res, next);
};
const rawUploadBody = express.raw({
type: 'application/octet-stream',
limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
});
const rawUploadBodyImage = express.raw({
type: 'application/octet-stream',
limit: 2 * 1024 * 1024,
});
const wikiAuth = createWikiAuth(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki-db'));
let legacyAuth = null;
if (ACCESS_PASSWORD) {
legacyAuth = createAuthManager({ password: ACCESS_PASSWORD });
}
let userAuth = null;
let tkmindProxy = null;
let sessionSnapshotService = null;
let mindSpace = null;
let mindSpaceAssets = null;
let mindSpaceAudit = null;
let mindSpacePages = null;
let mindSpacePageLiveEdit = null;
let mindSpacePageEditSession = null;
let mindSpacePublications = null;
let plazaPosts = null;
let plazaEvents = null;
let plazaRecommend = null;
let plazaInteractions = null;
let plazaSeo = null;
let plazaOps = null;
let plazaRedis = createNoopPlazaRedis();
let mindSpaceCleanup = null;
let mindSpaceAgentJobs = null;
let mindSpaceAgentRunner = null;
let rechargeService = null;
let subscriptionService = null;
let wechatPayClient = null;
let wechatOAuthService = null;
let wechatMpService = null;
let scheduleService = 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',
});
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'),
});
mindSpacePageLiveEdit = createPageLiveEditService({
pageService: mindSpacePages,
resolveUserIdForAgentSession: async (sessionId) => {
const [rows] = await pool.query(
`SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`,
[sessionId],
);
return rows[0]?.user_id ?? null;
},
});
mindSpacePublications = createPublicationService(pool, {
storageRoot:
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5),
});
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);
});
sessionSnapshotService = createSessionSnapshotService(pool);
tkmindProxy = createTkmindProxy({
apiTarget: API_TARGET,
apiTargets: API_TARGETS,
apiSecret: API_SECRET,
userAuth,
llmProviderService,
subscriptionService,
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,
});
wechatMpService = createWechatMpService({
config: WECHAT_MP_CONFIG,
userAuth,
apiFetch: tkmindProxy.apiFetch,
sessionApiFetch: async (sessionId, pathname, init) => {
const target = await tkmindProxy.resolveTarget(sessionId);
return tkmindProxy.apiFetchTo(target, pathname, init);
},
scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null,
applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId),
});
userAuth.setRechargeNotifier(async ({ userId, title, body }) => {
if (!wechatMpService?.enabled) return;
await wechatMpService.sendTextToUser(userId, `${title}\n${body}`.trim());
});
if (
process.env.H5_REMINDER_WORKER_ENABLED === '1' &&
wechatMpService?.enabled &&
scheduleService
) {
scheduleReminderWorker = startScheduleReminderWorker({
scheduleService,
sendWechatTextToUser: (userId, text) => wechatMpService.sendTextToUser(userId, text),
});
console.log('Schedule reminder worker enabled');
}
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);
return false;
}
}
const userAuthReady = bootstrapUserAuth();
function legacySessionToken(req) {
return parseCookies(req.get('cookie'))[AUTH_COOKIE];
}
function userToken(req) {
return parseCookies(req.get('cookie'))[USER_COOKIE];
}
function setUserLoginCookies(res, req, token) {
res.set(
'Set-Cookie',
userLoginCookies(token, isSecureRequest(req), resolveCookieDomainForRequest(req)),
);
}
function clearUserLoginCookies(res, req) {
res.set(
'Set-Cookie',
clearUserSessionCookie(isSecureRequest(req), resolveCookieDomainForRequest(req)),
);
}
async function attachUserSession(req, _res, next) {
if (!userAuth) return next();
const token = userToken(req);
req.userToken = token;
req.userSession = token ? await userAuth.verify(token) : null;
next();
}
app.use(attachUserSession);
// ============ Legacy password auth ============
app.get('/auth/status', async (req, res) => {
await userAuthReady;
if (userAuth) {
const me = await userAuth.getMe(userToken(req));
if (!me) return res.json({ authenticated: false, mode: 'user' });
const row = await userAuth.getUserById(me.id);
const capabilityState = await userAuth.resolveUserCapabilities(row);
return res.json({
authenticated: true,
user: me,
mode: 'user',
capabilities: capabilityState.capabilities,
grantedSkills: capabilityState.grantedSkills ?? [],
unrestricted: capabilityState.unrestricted,
});
}
if (legacyAuth) {
return res.json({
authenticated: legacyAuth.verify(legacySessionToken(req)),
mode: 'legacy',
});
}
return res.json({ authenticated: false, mode: 'none' });
});
app.post('/auth/login', jsonBody, async (req, res) => {
await userAuthReady;
const secure = isSecureRequest(req);
if (userAuth) {
const { username, password } = req.body ?? {};
if (!username || !password) {
return res.status(400).json({ message: '用户名和密码不能为空' });
}
const result = await userAuth.login({ username, password, ip: req.ip });
if (!result.ok) {
if (result.retryAfterMs > 0) {
res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
return res.status(429).json({ message: result.message });
}
return res.status(401).json({ message: result.message });
}
setUserLoginCookies(res, req, result.token);
return res.json({ authenticated: true, user: result.user, mode: 'user' });
}
if (!legacyAuth) {
return res.status(503).json({ message: '未配置用户数据库或访问密码' });
}
const password = typeof req.body?.password === 'string' ? req.body.password : '';
const result = legacyAuth.login(password, req.ip);
if (!result.ok) {
if (result.retryAfterMs > 0) {
res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
return res.status(429).json({ message: '尝试次数过多,请稍后再试' });
}
return res.status(401).json({ message: '密码错误,请重试' });
}
res.set('Set-Cookie', sessionCookie(result.token, secure));
return res.json({ authenticated: true, mode: 'legacy' });
});
app.post('/auth/register', jsonBody, async (req, res) => {
await userAuthReady;
if (!userAuth) {
return res.status(503).json({ message: '未启用用户注册' });
}
const { username, password, displayName, email } = req.body ?? {};
const result = await userAuth.register({ username, password, displayName, email });
if (!result.ok) {
const status = result.message.includes('已存在') ? 409 : 400;
return res.status(status).json({ message: result.message });
}
if (plazaSeo && req.body?.utm_source) {
void plazaSeo
.recordAttribution(
{
event_type: 'signup',
utm_source: req.body.utm_source,
utm_medium: req.body.utm_medium,
utm_campaign: req.body.utm_campaign,
ref_id: req.body.ref ?? req.body.ref_id,
user_id: result.user?.id ?? null,
},
plazaClientIp(req),
)
.catch(() => {});
}
return res.json({ ok: true, user: result.user });
});
app.post('/auth/reset-password', jsonBody, async (req, res) => {
await userAuthReady;
if (!userAuth) {
return res.status(503).json({ message: '未启用用户系统' });
}
const { username, email, password } = req.body ?? {};
const result = await userAuth.resetPassword({ username, email, password });
if (!result.ok) {
return res.status(400).json({ message: result.message });
}
return res.json({ ok: true });
});
app.get('/auth/wechat/config', async (req, res) => {
await userAuthReady;
if (!wechatOAuthService?.enabled) {
return res.json({
enabled: false,
inWechat: isWechatUserAgent(req.get('user-agent') || ''),
scanEnabled: false,
});
}
return res.json(wechatOAuthService.publicConfig(req));
});
app.get('/auth/wechat/js-sdk-signature', async (req, res) => {
await userAuthReady;
if (!userAuth || !wechatMpService?.enabled) {
return res.status(503).json({ message: '微信 JS-SDK 未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const pageUrl = String(req.query?.url ?? '').split('#')[0];
if (!pageUrl) {
return res.status(400).json({ message: '缺少 url' });
}
try {
const publicHost = WECHAT_MP_CONFIG.publicBaseUrl
? new URL(WECHAT_MP_CONFIG.publicBaseUrl).host
: null;
const target = new URL(pageUrl);
const requestHost = req.get('x-forwarded-host') || req.get('host') || '';
if (publicHost && target.host !== publicHost && target.host !== requestHost) {
return res.status(400).json({ message: 'url 不属于当前 H5 域名' });
}
const payload = await wechatMpService.createJsSdkSignature(pageUrl);
return res.json(payload);
} catch (err) {
const message = err instanceof Error ? err.message : '微信 JS-SDK 签名失败';
console.warn('WeChat JS-SDK signature failed:', message);
return res.status(502).json({ message });
}
});
app.get('/auth/wechat/status', async (req, res) => {
await userAuthReady;
if (!userAuth || !wechatOAuthService?.enabled) {
return res.json({ enabled: false, bound: false });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const config = loadWechatOAuthConfig();
const status = await userAuth.getWechatBindingStatus(me.id, config.appId);
return res.json({ enabled: true, ...status });
});
if (WECHAT_MP_CONFIG.enabled) {
app.get('/auth/wechat/agent-route', async (req, res) => {
await userAuthReady;
if (!userAuth || !wechatMpService?.enabled) {
return res.status(503).json({ message: '公众号 Agent 未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
return res.json(await wechatMpService.getRouteStatusForUser(me.id));
});
app.post('/auth/wechat/agent-route/reset', async (req, res) => {
await userAuthReady;
if (!userAuth || !wechatMpService?.enabled) {
return res.status(503).json({ message: '公众号 Agent 未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
try {
const result = await wechatMpService.recreateRouteForUser(me.id);
if (!result.ok) return res.status(400).json({ message: result.message });
return res.json(result);
} catch (err) {
return res.status(500).json({
message: err instanceof Error ? err.message : '重建公众号 Agent 路由失败',
});
}
});
}
app.get('/auth/wechat/pending/:token', async (req, res) => {
await userAuthReady;
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const pending = await userAuth.getWechatPendingBind(req.params.token);
if (!pending) return res.status(404).json({ message: '绑定会话已过期,请重新微信登录' });
return res.json({
nickname: pending.nickname,
avatarUrl: pending.avatar_url,
returnTo: pending.return_to || '/',
});
});
app.post('/auth/wechat/register', jsonBody, async (req, res) => {
await userAuthReady;
const secure = isSecureRequest(req);
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : '';
if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' });
const result = await userAuth.completeWechatRegister({ pendingToken });
if (!result.ok) return res.status(400).json({ message: result.message });
if (result.isNewUser && plazaSeo) {
void plazaSeo
.recordAttribution(
{
event_type: 'signup',
utm_source: result.utmSource || 'wechat',
utm_medium: result.utmMedium,
utm_campaign: result.utmCampaign,
user_id: result.user?.id ?? null,
},
plazaClientIp(req),
)
.catch(() => {});
}
setUserLoginCookies(res, req, result.token);
return res.json({
authenticated: true,
user: result.user,
returnTo: result.returnTo || '/',
});
});
app.post('/auth/wechat/bind', jsonBody, async (req, res) => {
await userAuthReady;
const secure = isSecureRequest(req);
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : '';
const username = typeof req.body?.username === 'string' ? req.body.username : '';
const password = typeof req.body?.password === 'string' ? req.body.password : '';
if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' });
if (!username || !password) {
return res.status(400).json({ message: '用户名和密码不能为空' });
}
const result = await userAuth.completeWechatBindAccount({
pendingToken,
username,
password,
ip: req.ip,
});
if (!result.ok) {
const status = result.retryAfterMs > 0 ? 429 : 401;
if (result.retryAfterMs > 0) {
res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
}
return res.status(status).json({ message: result.message });
}
setUserLoginCookies(res, req, result.token);
return res.json({
authenticated: true,
user: result.user,
bound: true,
returnTo: result.returnTo || '/',
});
});
app.post('/auth/wechat/scan/start', async (req, res) => {
await userAuthReady;
if (!wechatOAuthService?.enabled) {
return res.status(503).json({ message: '微信登录未启用' });
}
try {
const payload = await wechatOAuthService.startScanLogin(req);
return res.json(payload);
} catch (err) {
return res.status(503).json({
message: err instanceof Error ? err.message : '微信扫码登录不可用',
});
}
});
app.get('/auth/wechat/scan/poll', async (req, res) => {
await userAuthReady;
if (!wechatOAuthService?.enabled) {
return res.status(503).json({ message: '微信登录未启用' });
}
const state = typeof req.query?.state === 'string' ? req.query.state : '';
if (!state) return res.status(400).json({ message: '缺少扫码状态' });
const result = await wechatOAuthService.pollScanLogin(state);
if (result.status === 'complete' && result.token) {
setUserLoginCookies(res, req, result.token);
}
return res.json(result);
});
app.get('/auth/wechat/authorize', async (req, res) => {
await userAuthReady;
if (!wechatOAuthService?.enabled) {
return res.status(503).json({ message: '微信登录未启用' });
}
try {
let bindUserId = null;
const intent = typeof req.query?.intent === 'string' ? req.query.intent.trim().toLowerCase() : 'login';
if (intent === 'bind' && userAuth) {
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '请先登录后再绑定微信' });
bindUserId = me.id;
}
const redirectUrl = await wechatOAuthService.buildAuthorizeRedirect(req, { bindUserId });
return res.redirect(302, redirectUrl);
} catch (err) {
console.error('WeChat authorize failed:', err);
return res.status(500).json({ message: err instanceof Error ? err.message : '微信授权失败' });
}
});
app.get('/auth/wechat/callback', async (req, res) => {
await userAuthReady;
const secure = isSecureRequest(req);
if (!wechatOAuthService?.enabled || !userAuth) {
return res.redirect(302, '/?wechat_error=unavailable');
}
try {
const result = await wechatOAuthService.handleCallback({
code: typeof req.query?.code === 'string' ? req.query.code : '',
state: typeof req.query?.state === 'string' ? req.query.state : '',
ip: req.ip,
});
if (result.action === 'binding_gate') {
const params = new URLSearchParams();
params.set('wechat_pending', result.pendingToken);
if (result.returnTo && result.returnTo !== '/') {
params.set('return_to', result.returnTo);
}
return res.redirect(302, `/?${params.toString()}`);
}
if (result.action === 'poll_error') {
return res.redirect(
302,
`/?wechat_error=${encodeURIComponent(result.message || '微信登录失败')}`,
);
}
if (result.isNewUser && plazaSeo) {
void plazaSeo
.recordAttribution(
{
event_type: 'signup',
utm_source: result.utmSource || 'wechat',
utm_medium: result.utmMedium,
utm_campaign: result.utmCampaign,
user_id: result.user?.id ?? null,
},
plazaClientIp(req),
)
.catch(() => {});
}
if (result.authMode === 'open' || result.authMode === 'scan') {
return res.send(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>微信登录</title></head><body><p>扫码登录成功,请返回电脑继续操作。</p></body></html>`);
}
setUserLoginCookies(res, req, result.token);
return res.redirect(302, result.returnTo || '/');
} catch (err) {
console.error('WeChat callback failed:', err);
const message = encodeURIComponent(err instanceof Error ? err.message : '微信登录失败');
return res.redirect(302, `/?wechat_error=${message}`);
}
});
app.get('/auth/me', async (req, res) => {
await userAuthReady;
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const [paths, 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.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(
'<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[支付未启用]]></return_msg></xml>',
);
}
return res.status(503).json({ code: 'FAIL', message: '支付未启用' });
}
try {
const bodyText = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : String(req.body ?? '');
await rechargeService.handleWechatNotify({ headers: req.headers, body: bodyText });
if (isV2) {
return res.type('text/xml').send(WECHAT_NOTIFY_SUCCESS_V2);
}
return res.json({ code: 'SUCCESS', message: '成功' });
} catch (err) {
console.error('WeChat notify failed:', err);
const message = err instanceof Error ? err.message : '处理失败';
if (isV2) {
return res
.status(500)
.type('text/xml')
.send(
`<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[${message}]]></return_msg></xml>`,
);
}
return res.status(500).json({ code: 'FAIL', message });
}
});
if (WECHAT_MP_CONFIG.enabled) {
app.get('/webhooks/wechat-mp/messages', async (req, res) => {
await userAuthReady;
if (!wechatMpService?.enabled) {
return res.status(503).send('wechat mp disabled');
}
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><!\[CDATA\[([\s\S]*?)\]\]><\/FromUserName>/)?.[1] ?? null;
const msgType = bodyText.match(/<MsgType><!\[CDATA\[([\s\S]*?)\]\]><\/MsgType>/)?.[1] ?? null;
const content = bodyText.match(/<Content><!\[CDATA\[([\s\S]*?)\]\]><\/Content>/)?.[1] ?? null;
console.log('WeChat MP message received:', {
at: new Date().toISOString(),
fromUser: fromUser ? `${fromUser.slice(0, 8)}...` : null,
msgType,
contentPreview: content ? `${String(content).slice(0, 24)}` : null,
});
const result = await wechatMpService.handleInboundMessage(req.body, req.query);
if (result.task) void result.task;
if (result.contentType) res.type(result.contentType);
return res.status(result.status ?? 200).send(result.body ?? 'success');
} catch (err) {
console.error('WeChat MP message failed:', err);
return res.status(500).send('internal error');
}
});
}
// ============ Wiki API ============
function wikiCookie(token, secure) {
const parts = [
`${wikiAuth.COOKIE_NAME}=${encodeURIComponent(token)}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
'Max-Age=604800',
];
if (secure) parts.push('Secure');
return parts.join('; ');
}
function clearWikiCookie(secure) {
const parts = [`${wikiAuth.COOKIE_NAME}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
if (secure) parts.push('Secure');
return parts.join('; ');
}
const wikiApi = express.Router();
wikiApi.use(jsonBody);
wikiApi.post('/auth/register', (req, res) => {
const { username, password, displayName } = req.body || {};
if (!username || !password) {
return res.status(400).json({ message: '用户名和密码不能为空' });
}
const result = wikiAuth.register(username, password, displayName);
if (!result.ok) return res.status(409).json({ message: result.message });
return res.json({ ok: true, user: result.user });
});
wikiApi.post('/auth/login', (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
return res.status(400).json({ message: '用户名和密码不能为空' });
}
const result = wikiAuth.login(username, password);
if (!result.ok) return res.status(401).json({ message: result.message });
const secure = isSecureRequest(req);
res.set('Set-Cookie', wikiCookie(result.token, secure));
return res.json({ ok: true, user: result.user });
});
wikiApi.post('/auth/logout', (req, res) => {
const cookies = parseCookies(req.get('cookie'));
wikiAuth.revoke(cookies[wikiAuth.COOKIE_NAME]);
res.set('Set-Cookie', clearWikiCookie(isSecureRequest(req)));
return res.json({ ok: true });
});
wikiApi.get('/auth/me', (req, res) => {
const cookies = parseCookies(req.get('cookie'));
const session = wikiAuth.verify(cookies[wikiAuth.COOKIE_NAME]);
if (!session) return res.json({ authenticated: false, user: null });
const user = wikiAuth.getUser(session.username);
return res.json({ authenticated: true, user });
});
function requireWikiAuth(req, res, next) {
const cookies = parseCookies(req.get('cookie'));
const session = wikiAuth.verify(cookies[wikiAuth.COOKIE_NAME]);
if (!session) return res.status(401).json({ message: '未登录' });
req.wikiUser = session;
next();
}
wikiApi.get('/pages', requireWikiAuth, (req, res) => {
const { q } = req.query;
if (q) return res.json(wikiAuth.searchPages(req.wikiUser.username, q));
res.json(wikiAuth.listPages(req.wikiUser.username));
});
wikiApi.get('/pages/:slug', requireWikiAuth, (req, res) => {
const page = wikiAuth.getPage(req.wikiUser.username, req.params.slug);
if (!page) return res.status(404).json({ message: '页面不存在' });
res.json(page);
});
wikiApi.post('/pages/:slug', requireWikiAuth, (req, res) => {
const { title, content, tags } = req.body || {};
const page = wikiAuth.savePage(req.wikiUser.username, req.params.slug, title, content, tags);
res.json(page);
});
wikiApi.delete('/pages/:slug', requireWikiAuth, (req, res) => {
wikiAuth.deletePage(req.wikiUser.username, req.params.slug);
res.json({ ok: true });
});
app.use('/wiki-api', wikiApi);
// ============ TKMind API proxy ============
const api = express.Router();
api.use(jsonUnlessMultipart);
api.use(async (req, res, next) => {
await userAuthReady;
if (req.path === '/status') return next();
if (req.path.startsWith('/internal/agent/')) return next();
if (req.path === '/agent/mindspace_page_patch') return next();
if (req.path === '/config/blocked-words') return next();
const plazaPublic = isPlazaPublicRead(req.path, req.method);
if (userAuth && tkmindProxy) {
if (req.userSession) {
const me = await userAuth.getMe(req.userToken);
if (me) req.currentUser = me;
}
if (plazaPublic) return next();
if (!req.userSession) {
return res.status(401).json({ message: '未授权,请重新登录' });
}
const me = await userAuth.getMe(req.userToken);
if (!me) return res.status(401).json({ message: '登录已过期' });
req.currentUser = me;
return next();
}
if (legacyAuth?.verify(legacySessionToken(req))) return next();
return res.status(401).json({ message: '未授权,请重新登录' });
});
attachAsrRoutes(api, { sendError, sendData });
api.get('/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();
});
api.get('/mindspace/v1/space', async (req, res) => {
if (!mindSpace || !ensureMindSpaceEnabled(res, req)) return;
const space = await mindSpace.getSpace(req.currentUser.id);
if (!space) return sendError(res, req, 404, 'resource_not_found', '用户空间不存在');
return sendData(res, req, space);
});
api.get('/mindspace/v1/space/quota', async (req, res) => {
if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' });
const quota = await mindSpace.getQuota(req.currentUser.id);
if (!quota) return res.status(404).json({ message: '用户空间不存在' });
return res.json({ data: quota });
});
api.get('/mindspace/v1/space/categories', async (req, res) => {
if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' });
const categories = await mindSpace.listCategories(req.currentUser.id);
if (!categories) return res.status(404).json({ message: '用户空间不存在' });
return res.json({ data: categories });
});
api.get('/mindspace/v1/space/cleanup', async (req, res) => {
if (!mindSpaceCleanup || !ensureMindSpaceEnabled(res, req)) return;
try {
const username = req.currentUser.username ?? req.currentUser.slug;
const items = await mindSpaceCleanup.listCandidates(req.currentUser.id, username);
const totalBytes = items.reduce((sum, item) => sum + item.sizeBytes, 0);
return sendData(res, req, { items, totalBytes });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/space/cleanup', async (req, res) => {
if (!mindSpaceCleanup || !ensureMindSpaceEnabled(res, req)) return;
try {
const username = req.currentUser.username ?? req.currentUser.slug;
const itemIds = Array.isArray(req.body?.item_ids) ? req.body.item_ids : [];
const result = await mindSpaceCleanup.runCleanup(req.currentUser.id, username, itemIds);
const quota = await mindSpace.getQuota(req.currentUser.id);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'space.cleanup',
objectType: 'space',
objectId: req.currentUser.id,
ip: req.ip,
detail: { removedCount: result.removedCount, freedBytes: result.freedBytes },
});
return sendData(res, req, { ...result, quota });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
function isPlazaPublicRead(path, method) {
if (!path.startsWith('/plaza/v1/')) return false;
if (method === 'POST' && path === '/plaza/v1/events') return true;
if (method !== 'GET') return false;
return (
path === '/plaza/v1/feed' ||
path === '/plaza/v1/categories' ||
path === '/plaza/v1/seo/sitemap' ||
/^\/plaza\/v1\/posts\/[^/]+$/.test(path) ||
/^\/plaza\/v1\/posts\/[^/]+\/comments$/.test(path) ||
/^\/plaza\/v1\/users\/[^/]+$/.test(path) ||
/^\/plaza\/v1\/users\/[^/]+\/posts$/.test(path)
);
}
const PLAZA_SID_COOKIE = 'plaza_sid';
function resolvePlazaSessionId(req, res) {
const cookies = parseCookies(req.get('cookie'));
let sessionId = cookies[PLAZA_SID_COOKIE];
if (!sessionId) {
sessionId = crypto.randomUUID();
res.append(
'Set-Cookie',
`${PLAZA_SID_COOKIE}=${sessionId}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`,
);
}
return sessionId;
}
function recordPlazaEventsAsync(req, res, events) {
if (!plazaEvents || !Array.isArray(events) || events.length === 0) return;
const sessionId = resolvePlazaSessionId(req, res);
void plazaEvents
.recordEvents({
userId: req.currentUser?.id ?? null,
sessionId,
events,
})
.catch(() => {});
}
function reactionEventType(type) {
if (type === 'like' || type === 'collect' || type === 'share') return type;
return null;
}
function ensurePlazaInteractions(res, req) {
if (!plazaInteractions) {
sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
return false;
}
return true;
}
function plazaRouteError(res, req, error) {
const status = mapPlazaError(error);
const code = error?.code ?? 'internal_error';
const message = error instanceof Error ? error.message : 'Plaza 请求失败';
return sendError(res, req, status, code, message, error?.details);
}
function ensurePlazaEnabled(res, req) {
if (!plazaPosts) {
sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
return false;
}
return true;
}
function plazaClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string' && forwarded.length > 0) {
return forwarded.split(',')[0].trim();
}
return req.ip;
}
function mindSpaceError(res, req, error) {
const statusByCode = {
invalid_filename: 400,
invalid_file_size: 400,
category_not_uploadable: 400,
file_size_mismatch: 409,
invalid_upload_state: 409,
file_too_large: 413,
unsupported_file_type: 415,
category_not_found: 404,
upload_not_found: 404,
asset_not_found: 404,
asset_in_use: 409,
page_not_found: 404,
upload_expired: 410,
quota_exceeded: 429,
public_page_limit_exceeded: 429,
space_unavailable: 423,
invalid_page_input: 400,
page_content_too_large: 413,
source_message_not_found: 404,
invalid_source_message: 409,
version_conflict: 409,
slug_conflict: 409,
invalid_state_transition: 409,
invalid_publish_input: 400,
publication_not_found: 404,
security_scan_required: 422,
security_ack_required: 422,
security_risk_blocked: 422,
invalid_agent_job_input: 400,
invalid_agent_job_output: 400,
agent_job_not_found: 404,
agent_job_token_invalid: 401,
agent_job_expired: 410,
feature_disabled: 503,
cover_ai_unavailable: 503,
llm_not_configured: 503,
cover_ai_failed: 502,
cover_ai_invalid_output: 422,
thumbnail_not_supported: 422,
thumbnail_image_required: 400,
thumbnail_image_invalid: 400,
category_not_pageable: 400,
redaction_not_needed: 409,
invalid_category_code: 400,
invalid_page_path: 400,
empty_page_content: 400,
static_page_not_found: 404,
preview_not_supported: 422,
};
const code = error?.code ?? 'internal_error';
const status = statusByCode[code] ?? 500;
const message =
status === 500 && (!error?.code || code === 'internal_error')
? 'MindSpace 服务异常'
: error?.message || 'MindSpace 服务异常';
return sendError(res, req, status, code, message, error?.details);
}
function ensureMindSpaceEnabled(res, req, { upload = false, agent = false } = {}) {
try {
assertMindSpaceRoute(msFlags, upload ? 'upload' : agent ? 'agent' : undefined);
return true;
} catch (error) {
mindSpaceError(res, req, error);
return false;
}
}
function bearerToken(req) {
const header = req.get('authorization') ?? '';
const [scheme, value] = header.split(/\s+/, 2);
if (scheme?.toLowerCase() !== 'bearer' || !value) return null;
return value.trim();
}
function requireInternalAgentSecret(req, res) {
if (bearerToken(req) === INTERNAL_AGENT_SECRET) return true;
sendError(res, req, 401, 'agent_job_token_invalid', '内部 Agent 凭据无效');
return false;
}
api.post('/mindspace/v1/agent/jobs', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
const job = await mindSpaceAgentJobs.createJob(req.currentUser.id, {
jobType: req.body?.job_type,
instruction: req.body?.instruction,
allowedAssetIds: req.body?.allowed_asset_ids,
outputCategoryId: req.body?.output_category_id,
outputType: req.body?.output_type,
idempotencyKey: req.body?.idempotency_key,
locale: req.body?.locale,
timezone: req.body?.timezone,
capabilities: req.body?.capabilities,
});
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'agent_access',
objectType: 'agent_job',
objectId: job.id,
ip: req.ip,
detail: {
jobType: job.jobType,
assetIds: job.assets.map((asset) => asset.assetId),
},
});
return sendData(res, req, job, 201);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/agent/jobs/:jobId', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
return sendData(res, req, await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId));
} catch (error) {
return mindSpaceError(res, req, error);
}
});
// 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', rawUploadBodyImage, 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 { asset, path: assetPath } = await mindSpaceAssets.readAsset(
req.currentUser.id,
req.params.assetId,
);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'asset.download',
objectType: 'asset',
objectId: req.params.assetId,
ip: req.ip,
riskLevel: asset.riskLevel,
});
res.type(asset.mimeType);
const inline =
req.query.inline === '1' ||
req.query.disposition === 'inline' ||
req.get('sec-fetch-dest') === 'iframe';
if (inline && asset.mimeType.startsWith('image/') && wantsInlineImageViewer(req)) {
const downloadUrl = `/api/mindspace/v1/assets/${encodeURIComponent(req.params.assetId)}/download?inline=1`;
const html = renderImageAssetViewerHtml({ asset, downloadUrl });
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Cache-Control', 'private, no-store');
res.setHeader('X-Request-Id', req.requestId);
return res.send(html);
}
res.set(
'Content-Disposition',
inline
? `inline; filename*=UTF-8''${encodeURIComponent(asset.filename)}`
: `attachment; filename*=UTF-8''${encodeURIComponent(asset.filename)}`,
);
res.setHeader('X-Request-Id', req.requestId);
return res.sendFile(assetPath);
} catch (error) {
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'asset.download',
objectType: 'asset',
objectId: req.params.assetId,
ip: req.ip,
result: 'denied',
riskLevel: error?.code === 'security_risk_blocked' ? 'high' : null,
});
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/assets/:assetId/thumbnail', async (req, res) => {
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
try {
const svg = await mindSpaceAssets.renderAssetThumbnail(req.currentUser.id, req.params.assetId);
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'private, max-age=300');
res.setHeader('X-Request-Id', req.requestId);
return res.send(svg);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/assets/:assetId/preview', async (req, res) => {
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
try {
const html = await mindSpaceAssets.renderAssetPreview(req.currentUser.id, req.params.assetId);
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Cache-Control', 'private, no-store');
res.setHeader('X-Request-Id', req.requestId);
return res.send(html);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/from-asset', async (req, res) => {
if (!mindSpacePages || !mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const assetId = req.body?.asset_id;
const existingPage = await mindSpacePages.findPageBySourceAsset(req.currentUser.id, assetId);
if (existingPage) {
return sendData(res, req, { kind: 'page', categoryCode: existingPage.categoryCode ?? 'draft', page: existingPage });
}
const { asset, path: assetPath } = await mindSpaceAssets.readAsset(
req.currentUser.id,
assetId,
);
const content = await fs.promises.readFile(assetPath, 'utf8');
const contentFormat = asset.mimeType === 'text/html' ? 'html' : 'markdown';
const page = await mindSpacePages.createFromChat(
req.currentUser.id,
{
title: req.body?.title || asset.displayName,
summary: req.body?.summary,
content,
contentFormat,
templateId: req.body?.template_id,
categoryCode: 'draft',
},
{
assetId: asset.id,
snapshot: {
source_asset_id: asset.id,
source_category: asset.categoryCode,
content_mode: contentFormat,
},
},
);
return res.status(201).json({ data: { kind: 'page', categoryCode: 'draft', page } });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.delete('/mindspace/v1/assets/:assetId', async (req, res) => {
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
try {
const result = await mindSpaceAssets.deleteAsset(req.currentUser.id, req.params.assetId);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'asset.delete',
objectType: 'asset',
objectId: req.params.assetId,
ip: req.ip,
});
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
function messageText(message) {
return (message?.content ?? [])
.filter((item) => item?.type === 'text' && typeof item.text === 'string')
.map((item) => item.text)
.join('')
.trim();
}
async function resolveOwnedAssistantMessage(userId, sessionId, messageId) {
if (!sessionId || !messageId) {
throw Object.assign(new Error('缺少来源会话或消息'), {
code: 'invalid_page_input',
});
}
if (!(await userAuth.ownsSession(userId, sessionId))) {
throw Object.assign(new Error('来源会话不存在'), { code: 'source_message_not_found' });
}
const upstream = await tkmindProxy.apiFetch(`/sessions/${encodeURIComponent(sessionId)}`, {
method: 'GET',
});
if (!upstream.ok) {
throw Object.assign(new Error('无法读取来源会话'), { code: 'source_message_not_found' });
}
const session = await upstream.json();
const message = (session.conversation ?? []).find((item) => item.id === messageId);
if (!message) {
throw Object.assign(new Error('来源消息不存在'), { code: 'source_message_not_found' });
}
const content = messageText(message);
if (message.role !== 'assistant' || !message.metadata?.userVisible || !content) {
throw Object.assign(new Error('只有可见的 AI 文本消息可以保存为页面'), {
code: 'invalid_source_message',
});
}
return { session, message, content };
}
const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'private', 'public']);
function assertPrivateSaveAllowed(categoryCode, privacyScan, acknowledgedFindingIds) {
if (categoryCode !== 'private') return;
if (!privacyScan.allowed) {
throw Object.assign(new Error('内容含阻断级敏感信息,不能保存到私人区'), {
code: 'security_risk_blocked',
details: { findings: privacyScan.findings },
});
}
if (privacyScan.findings.length === 0) return;
const acknowledged = new Set((acknowledgedFindingIds ?? []).map(String));
const missing = privacyScan.findings.filter((finding) => !acknowledged.has(finding.id));
if (missing.length > 0) {
throw Object.assign(new Error('保存到私人区前需确认敏感信息提示'), {
code: 'private_ack_required',
details: { findings: missing },
});
}
}
async function resolveChatSaveBundle(user, h5Root, input = {}) {
const sessionId = input.sessionId ?? input.session_id;
const messageId = input.messageId ?? input.message_id;
const selectedLinkIndex = Number(input.selectedLinkIndex ?? input.selected_link_index ?? 0);
const previewTitle = String(input.previewTitle ?? input.preview_title ?? '').trim();
const previewSummary = String(input.previewSummary ?? input.preview_summary ?? '').trim();
const source = await resolveOwnedAssistantMessage(user.id, sessionId, messageId);
const { analysis, resolvedHtml } = await resolveChatSaveAnalysis({
content: source.content,
userId: user.id,
username: user.username,
h5Root,
selectedLinkIndex,
});
return {
source,
analysis,
resolvedHtml,
selectedLinkIndex,
previewTitle,
previewSummary,
previewFrameUrl:
resolvedHtml != null
? buildChatSavePreviewFrameUrl({ sessionId, messageId, selectedLinkIndex })
: null,
thumbnailUrl:
resolvedHtml != null
? buildChatSaveThumbnailUrl({
sessionId,
messageId,
selectedLinkIndex,
previewTitle,
previewSummary,
})
: null,
localPreviewUrl:
resolvedHtml?.relativePath != null
? buildWorkspaceAssetUrl(user.id, resolvedHtml.relativePath)
: null,
localThumbnailUrl:
resolvedHtml?.relativePath != null
? buildWorkspaceThumbnailUrl(user.id, resolvedHtml.relativePath)
: null,
};
}
api.get('/mindspace/v1/pages/chat-save-preview', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.query);
if (!bundle.resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
const baseHref = buildWorkspaceBaseHref(req.currentUser.id, bundle.resolvedHtml.relativePath);
const html = injectHtmlBaseHref(bundle.resolvedHtml.content, baseHref);
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Cache-Control', 'private, no-store');
res.setHeader('X-Request-Id', req.requestId);
return res.send(html);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.query);
if (!bundle.resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
const publishDir = resolvePublishDir(__dirname, req.currentUser);
const thumbRel = workspaceThumbnailRelativePath(bundle.resolvedHtml.relativePath);
const title =
bundle.previewTitle ||
bundle.resolvedHtml.suggestedTitle ||
bundle.analysis.suggestedTitle;
const subtitle =
bundle.previewSummary ||
bundle.resolvedHtml.suggestedSummary ||
bundle.analysis.suggestedSummary;
await ensureWorkspaceHtmlThumbnail(
publishDir,
bundle.resolvedHtml.relativePath,
bundle.resolvedHtml.content,
{ title, subtitle, force: true },
).catch(() => {});
const svg = await generateHtmlThumbnail(publishDir, thumbRel, bundle.resolvedHtml.content, {
title,
subtitle,
contentBaseDir: path.dirname(bundle.resolvedHtml.absolute),
force: true,
});
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'private, no-store');
res.setHeader('X-Request-Id', req.requestId);
return res.send(svg);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
const {
source,
analysis,
resolvedHtml,
previewTitle,
previewSummary,
previewFrameUrl,
thumbnailUrl,
localPreviewUrl,
localThumbnailUrl,
} = bundle;
let thumbnailReady = false;
if (resolvedHtml?.content && resolvedHtml.relativePath) {
const publishDir = resolvePublishDir(__dirname, req.currentUser);
try {
await ensureWorkspaceHtmlThumbnail(publishDir, resolvedHtml.relativePath, resolvedHtml.content, {
title: previewTitle || resolvedHtml.suggestedTitle,
subtitle: previewSummary || resolvedHtml.suggestedSummary,
force: Boolean(previewTitle || previewSummary),
});
thumbnailReady = true;
} catch {
thumbnailReady = false;
}
}
return sendData(res, req, {
contentMode: analysis.contentMode,
links: analysis.links,
selectedLinkIndex: analysis.selectedLink
? analysis.links.findIndex((link) => link.publicUrl === analysis.selectedLink.publicUrl)
: -1,
suggestedTitle: resolvedHtml?.suggestedTitle ?? analysis.suggestedTitle,
suggestedSummary: resolvedHtml?.suggestedSummary ?? analysis.suggestedSummary,
previewUrl: analysis.previewUrl,
previewFrameUrl,
localPreviewUrl,
localThumbnailUrl,
thumbnailUrl: resolvedHtml ? thumbnailUrl : null,
thumbnailReady,
relativePath: resolvedHtml?.relativePath ?? analysis.relativePath,
filename: resolvedHtml?.filename ?? analysis.filename,
hasHtmlContent: Boolean(resolvedHtml),
privacyScan: scanContent(resolvedHtml?.content ?? source.content),
});
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
if (!mindSpacePages || !mindSpaceAssets) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const categoryCode = String(req.body?.category_code ?? 'draft');
if (!SAVE_TARGET_CATEGORIES.has(categoryCode)) {
throw Object.assign(new Error('无效的保存目标'), { code: 'invalid_category_code' });
}
const source = await resolveOwnedAssistantMessage(
req.currentUser.id,
req.body?.session_id,
req.body?.message_id,
);
const analysis = analyzeChatMessageForSave({
content: source.content,
userId: req.currentUser.id,
username: req.currentUser.username,
h5Root: __dirname,
selectedLinkIndex: Number(req.body?.selected_link_index ?? 0),
});
const snapshot = {
session_name: source.session.name,
message_created: source.message.created,
role: source.message.role,
content_mode: analysis.contentMode,
public_url: analysis.previewUrl,
relative_path: analysis.relativePath,
};
let resolvedHtml = null;
if (analysis.contentMode === 'static_html') {
resolvedHtml = await resolveStaticHtmlContent(analysis).catch(() => null);
}
const privacyScan = scanContent(resolvedHtml?.content ?? source.content);
assertPrivateSaveAllowed(categoryCode, privacyScan, req.body?.acknowledged_finding_ids);
if (categoryCode !== 'draft') {
let buffer;
let filename;
let displayName = req.body?.title;
if (analysis.contentMode === 'static_html') {
if (!resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
buffer = Buffer.from(resolvedHtml.content, 'utf8');
filename = resolvedHtml.filename;
displayName = displayName || resolvedHtml.suggestedTitle;
} else {
buffer = Buffer.from(source.content, 'utf8');
filename = `${String(displayName || 'chat-export')
.replace(/[^\w\u4e00-\u9fff-]+/g, '-')
.slice(0, 48) || 'chat-export'}.md`;
}
const asset = await mindSpaceAssets.createChatAsset(req.currentUser.id, {
categoryCode,
buffer,
filename,
displayName,
sourceType: 'chat',
});
return res.status(201).json({
data: {
kind: 'asset',
categoryCode,
asset,
},
});
}
let pageInput = {
title: req.body?.title,
summary: req.body?.summary,
templateId: req.body?.template_id,
pageType: req.body?.page_type,
categoryCode: 'draft',
};
if (analysis.contentMode === 'static_html') {
if (!resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
pageInput = {
...pageInput,
title: req.body?.title || resolvedHtml.suggestedTitle,
summary: req.body?.summary || resolvedHtml.suggestedSummary,
content: resolvedHtml.content,
contentFormat: 'html',
pageType: 'html',
};
} else {
pageInput = {
...pageInput,
content: source.content,
contentFormat: 'markdown',
};
}
if (analysis.contentMode === 'static_html' && resolvedHtml?.content && analysis.relativePath) {
const publishDir = resolvePublishDir(__dirname, req.currentUser);
await ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, {
title: pageInput.title,
subtitle: pageInput.summary,
}).catch(() => {});
}
const page = await mindSpacePages.createFromChat(req.currentUser.id, pageInput, {
sessionId: req.body.session_id,
messageId: req.body.message_id,
snapshot,
});
return res.status(201).json({ data: { kind: 'page', categoryCode: 'draft', page } });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const page = await mindSpacePages.createPage(req.currentUser.id, {
title: req.body?.title,
summary: req.body?.summary,
content: req.body?.content,
contentFormat: req.body?.content_format === 'html' ? 'html' : undefined,
templateId: req.body?.template_id,
pageType: req.body?.page_type,
categoryCode: req.body?.category_code,
});
return res.status(201).json({ data: page });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const pages = await mindSpacePages.listPages(req.currentUser.id, {
status: typeof req.query.status === 'string' ? req.query.status : undefined,
});
return res.json({ data: pages, page: { next_cursor: null, has_more: false } });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const [page, versions, publication] = await Promise.all([
mindSpacePages.getPage(req.currentUser.id, req.params.pageId),
mindSpacePages.listVersions(req.currentUser.id, req.params.pageId),
mindSpacePublications?.getCurrent(req.currentUser.id, req.params.pageId) ?? null,
]);
return res.json({ data: { ...page, versions, publication } });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.delete('/mindspace/v1/pages/:pageId', async (req, res) => {
if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return;
try {
const result = await mindSpacePages.deletePage(req.currentUser.id, req.params.pageId);
const quota = await mindSpace.getQuota(req.currentUser.id);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'page.delete',
objectType: 'page',
objectId: req.params.pageId,
ip: req.ip,
detail: {
offlinedPublicationCount: result.offlinedPublicationCount,
deletedAssetCount: result.deletedAssetCount,
freedBytes: result.freedBytes,
},
});
return sendData(res, req, { ...result, quota });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId/delete-preview', async (req, res) => {
if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return;
try {
return sendData(res, req, await mindSpacePages.getDeletePreview(req.currentUser.id, req.params.pageId));
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.put('/mindspace/v1/pages/:pageId', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const page = await mindSpacePages.updatePage(req.currentUser.id, req.params.pageId, {
expectedVersion: req.body?.expected_version,
title: req.body?.title,
summary: req.body?.summary,
content: req.body?.content,
templateId: req.body?.template_id,
pageType: req.body?.page_type,
changeNote: req.body?.change_note,
});
return res.json({ data: page });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/live-edit/bind', async (req, res) => {
if (!mindSpacePageLiveEdit || !mindSpacePages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const sessionId = String(req.body?.session_id ?? '').trim();
if (!sessionId) {
return sendError(res, req, 400, 'invalid_request', '缺少 session_id');
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return sendError(res, req, 403, 'forbidden', '无权绑定该 Agent 会话');
}
await mindSpacePages.getPage(req.currentUser.id, req.params.pageId);
const parentSessionId = String(req.body?.parent_session_id ?? '').trim() || null;
return sendData(
res,
req,
mindSpacePageLiveEdit.bindSession({
userId: req.currentUser.id,
sessionId,
pageId: req.params.pageId,
parentSessionId,
}),
);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/live-edit/fork-session', async (req, res) => {
if (!mindSpacePageEditSession || !mindSpacePages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const parentSessionId = String(req.body?.parent_session_id ?? '').trim();
if (!parentSessionId) {
return sendError(res, req, 400, 'invalid_request', '缺少 parent_session_id');
}
const gate = await userAuth.canUseChat(req.currentUser.id);
if (!gate.ok) {
return sendError(res, req, 402, gate.code ?? 'insufficient_balance', gate.message);
}
return sendData(
res,
req,
await mindSpacePageEditSession.forkSession({
userId: req.currentUser.id,
pageId: req.params.pageId,
parentSessionId,
h5ApiBase: String(req.body?.h5_api_base ?? req.body?.h5ApiBase ?? '').trim() || null,
}),
);
} catch (error) {
if (error?.code === 'forbidden') {
return sendError(res, req, 403, error.code, error.message);
}
if (error?.code === 'invalid_request') {
return sendError(res, req, 400, error.code, error.message);
}
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/live-edit/close-session', async (req, res) => {
if (!mindSpacePageEditSession || !mindSpacePages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const sessionId = String(req.body?.session_id ?? '').trim();
if (!sessionId) {
return sendError(res, req, 400, 'invalid_request', '缺少 session_id');
}
return sendData(
res,
req,
await mindSpacePageEditSession.closeSession({
userId: req.currentUser.id,
pageId: req.params.pageId,
sessionId,
parentSessionId: String(req.body?.parent_session_id ?? '').trim() || null,
summary: String(req.body?.summary ?? ''),
}),
);
} catch (error) {
if (error?.code === 'forbidden' || error?.code === 'page_binding_mismatch') {
return sendError(res, req, 403, error.code, error.message);
}
if (error?.code === 'invalid_request') {
return sendError(res, req, 400, error.code, error.message);
}
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId/live-edit/revision', async (req, res) => {
if (!mindSpacePageLiveEdit || !mindSpacePages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
return sendData(
res,
req,
await mindSpacePageLiveEdit.getRevisionSnapshot(req.currentUser.id, req.params.pageId),
);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/agent/mindspace_page_patch', async (req, res) => {
if (!mindSpacePageLiveEdit) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const result = await mindSpacePageLiveEdit.applyAgentPatch(req.body ?? {});
return res.json({
data: {
pageId: result.page.id,
title: result.page.title,
summary: result.page.summary,
versionNo: result.page.versionNo,
updatedAt: result.page.updatedAt,
liveRevision: result.liveRevision,
},
});
} catch (error) {
if (error?.code === 'forbidden' || error?.code === 'page_binding_mismatch') {
return sendError(res, req, 403, error.code, error.message);
}
if (error?.code === 'invalid_request') {
return sendError(res, req, 400, error.code, error.message);
}
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId/thumbnail', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const svg = await mindSpacePages.renderThumbnail(req.currentUser.id, req.params.pageId);
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'private, max-age=300');
res.setHeader('X-Request-Id', req.requestId);
return res.send(svg);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/thumbnail/upload', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpacePages.uploadThumbnail(req.currentUser.id, req.params.pageId, {
imageBase64: req.body?.image_base64,
mimeType: req.body?.mime_type,
title: req.body?.title,
summary: req.body?.summary,
html: req.body?.html,
});
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/thumbnail/regenerate', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpacePages.regenerateThumbnail(
req.currentUser.id,
req.params.pageId,
{
html: req.body?.html,
title: req.body?.title,
summary: req.body?.summary,
useAi: Boolean(req.body?.use_ai),
instruction: req.body?.instruction,
},
{
suggestCoverMeta: req.body?.use_ai
? (input) => {
if (!authPool) {
throw Object.assign(new Error('LLM 服务未就绪'), { code: 'llm_not_configured' });
}
return suggestCoverMetaWithAi(authPool, {
...input,
encryptionKey:
process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY,
});
}
: null,
},
);
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId/preview', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const { html, contentFormat } = await mindSpacePages.renderPreview(
req.currentUser.id,
req.params.pageId,
);
res.set('Content-Type', 'text/html; charset=utf-8');
res.set(
'Content-Security-Policy',
pageInternals.previewContentSecurityPolicy(contentFormat),
);
res.set('Cache-Control', 'private, no-store');
res.set('X-Content-Type-Options', 'nosniff');
return res.send(html);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/preview-draft', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const { html, contentFormat } = await mindSpacePages.renderDraftPreview(
req.currentUser.id,
req.params.pageId,
{
title: req.body?.title,
summary: req.body?.summary,
content: req.body?.content,
templateId: req.body?.template_id,
},
);
res.set('Content-Type', 'text/html; charset=utf-8');
res.set(
'Content-Security-Policy',
pageInternals.previewContentSecurityPolicy(contentFormat),
);
res.set('Cache-Control', 'private, no-store');
res.set('X-Content-Type-Options', 'nosniff');
return res.send(html);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/publish-check', async (req, res) => {
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpacePublications.check(req.currentUser.id, req.params.pageId, {
pageVersionId: req.body?.page_version_id,
accessMode: req.body?.access_mode,
urlSlug: req.body?.url_slug,
password: req.body?.password,
expiresAt: req.body?.expires_at,
});
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/redact', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpacePages.redactPage(req.currentUser.id, req.params.pageId, {
pageVersionId: req.body?.page_version_id,
expectedVersion: req.body?.expected_version,
title: req.body?.title,
summary: req.body?.summary,
content: req.body?.content,
});
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'page.redact',
objectType: 'page',
objectId: result.page.id,
ip: req.ip,
riskLevel: result.originalScan.riskLevel,
});
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/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/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) {
const mcMatch = hintMc == null || snapshot.meta.synced_msg_count === hintMc;
const uaMatch = hintUa == null || snapshot.meta.source_updated_at === hintUa;
if (mcMatch && uaMatch) {
// Cache hit — reconstruct a Goose-compatible session response.
const cachedGooseSession = {
...snapshot.session,
// Embed only userVisible messages so getSession callers still work.
conversation: snapshot.messages,
};
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();
// 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: '无权访问该会话' });
}
// After Finish, async-refresh snapshot so next open is a cache hit.
const onAfterFinish = sessionSnapshotService?.isEnabled()
? (sid, uid) =>
sessionSnapshotService.refresh(sid, uid, async (pathname, init) => {
const target = await tkmindProxy.resolveTarget(sid);
return tkmindProxy.apiFetchTo(target, pathname, init);
})
: null;
return tkmindProxy.proxySessionEvents(req, res, sessionId, { onAfterFinish });
});
api.use(async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
if (isNativeH5ApiPath(req.path)) {
return sendError(res, req, 404, 'not_found', `接口不存在:${req.method} ${req.path}`);
}
const sessionMatch = req.path.match(/^\/sessions\/([^/]+)/);
if (sessionMatch) {
const sessionId = sessionMatch[1];
if (req.path.endsWith('/events') && req.method === 'GET') {
return next();
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
if (req.path.endsWith('/reply') && req.method === 'POST') {
const gate = await userAuth.canUseChat(req.currentUser.id);
if (!gate.ok) {
return res.status(402).json({
message: gate.message,
code: gate.code,
balanceCents: gate.balanceCents,
minRechargeCents: gate.minRechargeCents,
suggestedTiers: gate.suggestedTiers,
});
}
try {
await tkmindProxy.reconcileSessionPolicyForUser(req.currentUser.id, sessionId);
} catch (err) {
console.warn(
'Session policy sync before reply failed:',
err instanceof Error ? err.message : err,
);
return res.status(500).json({
message:
err instanceof Error
? `会话策略同步失败:${err.message}`
: '会话策略同步失败',
});
}
if (llmProviderService) {
try {
await tkmindProxy.applySessionLlmProvider(sessionId);
} catch (err) {
console.warn(
'LLM provider sync before reply skipped:',
err instanceof Error ? err.message : err,
);
}
}
}
}
if (req.method === 'POST' && req.path === '/agent/resume' && req.body?.session_id) {
const owns = await userAuth.ownsSession(req.currentUser.id, req.body.session_id);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
}
if (req.body?.working_dir) {
const allowed = await userAuth.isPathAllowed(req.currentUser.id, req.body.working_dir);
if (!allowed) {
return res.status(403).json({ message: '工作目录不在授权范围内' });
}
}
return tkmindProxy.proxyFallback(req, res);
});
api.use(
createProxyMiddleware({
target: API_TARGET,
changeOrigin: true,
secure: false,
pathRewrite: { '^/api': '' },
on: {
proxyReq: (proxyReq) => {
proxyReq.setHeader('X-Secret-Key', API_SECRET);
},
},
}),
);
app.use('/api', api);
function publishedPageCsp(html, { embed = false, raw = false } = {}) {
const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
if (embed && isFullHtml) {
return publishedPageCspForEmbed(true);
}
if (raw && isFullHtml) {
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline'";
}
if (isFullHtml) {
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'none'";
}
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'";
}
function appendQueryParam(url, key, value) {
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}
function resolveRequestOrigin(req) {
const host = (req.headers['x-forwarded-host'] || req.headers.host || '').toString().split(',')[0].trim();
if (!host) return '';
const isLocalHost = /^(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|100\.)/i.test(host);
const fwdProto = (req.headers['x-forwarded-proto'] || '').toString().split(',')[0].trim();
const proto = isLocalHost ? fwdProto || req.protocol || 'http' : 'https';
return `${proto}://${host}`;
}
function detectPublishedPageTitle(html) {
const match = String(html ?? '').match(/<title[^>]*>([^<]+)<\/title>/i);
return match?.[1]?.replace(/\s+/g, ' ').trim() || 'MindSpace 页面';
}
function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
const iframeSrc = escapePublicHtml(iframeUrl);
const safeShareUrl = escapePublicHtml(shareUrl);
const safeTitle = escapePublicHtml(title);
const serializedShareUrl = JSON.stringify(shareUrl).replace(/</g, '\\u003c');
const serializedTitle = JSON.stringify(title).replace(/</g, '\\u003c');
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'">
<title>${safeTitle}</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: #f6f1e7; }
body { font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif; color: #18211d; }
.publication-shell { position: relative; min-height: 100vh; }
.publication-frame { display: block; width: 100%; min-height: 100vh; border: 0; background: #fff; }
.publication-share-fab {
position: fixed;
right: 18px;
bottom: calc(18px + env(safe-area-inset-bottom, 0px));
z-index: 50;
border: 0;
border-radius: 999px;
padding: 14px 18px;
background: linear-gradient(135deg, #2f6f57, #1f4c3c);
color: #fffdf7;
font: 700 14px/1 ui-sans-serif, sans-serif;
letter-spacing: .04em;
box-shadow: 0 20px 48px rgba(24, 33, 29, .24);
cursor: pointer;
}
.publication-share-sheet[hidden] { display: none; }
.publication-share-sheet {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: end center;
padding: 16px;
background: rgba(7, 12, 10, 0.56);
}
.publication-share-panel {
width: min(480px, 100%);
padding: 18px 18px 22px;
border-radius: 24px 24px 20px 20px;
background: #fffaf2;
box-shadow: 0 24px 80px rgba(24, 33, 29, 0.24);
}
.publication-share-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.publication-share-header p {
margin: 0;
color: #9b6518;
font-size: 11px;
font-weight: 800;
letter-spacing: .16em;
text-transform: uppercase;
}
.publication-share-header h2 {
margin: 6px 0 0;
font: 500 28px/1.1 Georgia, 'Times New Roman', serif;
color: #18211d;
}
.publication-share-header strong {
display: block;
margin-top: 6px;
color: #68716c;
font-size: 13px;
font-weight: 500;
}
.publication-share-close {
width: 36px;
height: 36px;
border: 0;
border-radius: 999px;
background: rgba(24, 33, 29, 0.08);
color: #18211d;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.publication-share-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.publication-share-option {
display: grid;
gap: 8px;
justify-items: center;
padding: 14px 10px;
border: 1px solid rgba(24, 33, 29, 0.08);
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
color: #18211d;
font: inherit;
cursor: pointer;
}
.publication-share-option:disabled {
opacity: .6;
cursor: wait;
}
.publication-share-option span {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 14px;
color: #fff;
font-size: 18px;
font-weight: 700;
}
.publication-share-option[data-action="wechat"] span { background: linear-gradient(145deg, #07c160, #06ad56); }
.publication-share-option[data-action="copy"] span { background: linear-gradient(145deg, #2f6f57, #245845); }
.publication-share-option[data-action="capture"] span { background: linear-gradient(145deg, #8f6b2f, #6f5121); }
.publication-share-link, .publication-share-message {
margin-top: 16px;
padding: 12px 14px;
border-radius: 14px;
font-size: 13px;
}
.publication-share-link {
background: rgba(24, 33, 29, 0.05);
}
.publication-share-link code {
display: block;
margin-top: 6px;
word-break: break-all;
color: #445049;
font-size: 12px;
}
.publication-share-message {
display: none;
color: #245845;
background: rgba(47, 111, 87, 0.12);
}
.publication-share-message.is-visible {
display: block;
}
@media (max-width: 480px) {
.publication-share-sheet {
padding: 0;
place-items: end stretch;
}
.publication-share-panel {
width: 100%;
border-radius: 24px 24px 0 0;
}
.publication-share-fab {
right: 14px;
bottom: calc(14px + env(safe-area-inset-bottom, 0px));
}
}
</style>
</head>
<body>
<div class="publication-shell">
<!-- Game-like publications need script execution, but not same-origin sandbox escape. -->
<iframe class="publication-frame" title="${safeTitle}" src="${iframeSrc}" sandbox="allow-scripts"></iframe>
</div>
<button type="button" class="publication-share-fab" id="publication-share-fab">分享</button>
<div class="publication-share-sheet" id="publication-share-sheet" hidden>
<section class="publication-share-panel" role="dialog" aria-modal="true" aria-labelledby="publication-share-title">
<header class="publication-share-header">
<div>
<p>Share</p>
<h2 id="publication-share-title">分享页面</h2>
<strong>${safeTitle}</strong>
</div>
<button type="button" class="publication-share-close" id="publication-share-close" aria-label="关闭">×</button>
</header>
<div class="publication-share-grid">
<button type="button" class="publication-share-option" data-action="wechat"><span>微</span>微信</button>
<button type="button" class="publication-share-option" data-action="copy"><span>链</span>复制链接</button>
<button type="button" class="publication-share-option" data-action="capture"><span>图</span>保存长图</button>
</div>
<div class="publication-share-link">
分享链接
<code>${safeShareUrl}</code>
</div>
<div class="publication-share-message" id="publication-share-message" role="status"></div>
</section>
</div>
<script>
(function () {
var shareUrl = ${serializedShareUrl};
var shareTitle = ${serializedTitle};
var fab = document.getElementById('publication-share-fab');
var sheet = document.getElementById('publication-share-sheet');
var close = document.getElementById('publication-share-close');
var message = document.getElementById('publication-share-message');
var frame = document.querySelector('.publication-frame');
var actionButtons = sheet ? Array.prototype.slice.call(sheet.querySelectorAll('.publication-share-option')) : [];
var html2canvasLoader = null;
function setBusy(busy) {
actionButtons.forEach(function (button) {
button.disabled = !!busy;
button.setAttribute('aria-busy', busy ? 'true' : 'false');
});
}
function setMessage(text, isError) {
if (!message) return;
message.textContent = text || '';
message.classList.toggle('is-visible', Boolean(text));
message.style.color = isError ? '#8b2d20' : '#245845';
message.style.background = isError ? 'rgba(139, 45, 32, 0.12)' : 'rgba(47, 111, 87, 0.12)';
}
function openSheet() {
if (sheet) sheet.hidden = false;
setMessage('');
}
function closeSheet() {
if (sheet) sheet.hidden = true;
}
async function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Fall back to execCommand for browsers where clipboard API exists but is blocked.
}
}
var textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
var copied = document.execCommand('copy');
textarea.remove();
if (!copied) throw new Error('复制失败,请手动复制链接');
}
function loadHtml2Canvas() {
if (window.html2canvas) return Promise.resolve(window.html2canvas);
if (html2canvasLoader) return html2canvasLoader;
html2canvasLoader = new Promise(function (resolve, reject) {
var script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
script.async = true;
script.onload = function () {
if (!window.html2canvas) {
reject(new Error('无法加载页面截图能力'));
return;
}
resolve(window.html2canvas);
};
script.onerror = function () { reject(new Error('无法加载页面截图能力')); };
document.body.appendChild(script);
}).catch(function (error) {
html2canvasLoader = null;
throw error;
});
return html2canvasLoader;
}
async function saveLongImage() {
if (!frame || !frame.contentDocument || !frame.contentDocument.body) {
throw new Error('页面内容暂时不可访问');
}
var renderer = await loadHtml2Canvas();
var doc = frame.contentDocument;
var body = doc.body;
var width = Math.max(doc.documentElement ? doc.documentElement.scrollWidth : 0, body.scrollWidth, 320);
var height = Math.max(doc.documentElement ? doc.documentElement.scrollHeight : 0, body.scrollHeight, 320);
var canvas = await renderer(body, {
useCORS: true,
scale: 2,
backgroundColor: '#ffffff',
width: width,
height: height,
windowWidth: width,
windowHeight: height,
x: 0,
y: 0,
scrollX: 0,
scrollY: 0
});
var link = document.createElement('a');
var timestamp = new Date().toISOString().replace(/[T:]/g, '-').replace(/\..+/, '');
link.href = canvas.toDataURL('image/png');
link.download = 'mindspace-public-page-' + timestamp + '.png';
document.body.appendChild(link);
link.click();
link.remove();
}
if (fab) fab.addEventListener('click', openSheet);
if (close) close.addEventListener('click', closeSheet);
if (sheet) {
sheet.addEventListener('click', function (event) {
if (event.target === sheet) closeSheet();
});
actionButtons.forEach(function (button) {
button.addEventListener('click', async function () {
var action = button.getAttribute('data-action');
try {
setBusy(true);
setMessage('');
if (action === 'wechat') {
await copyText([shareTitle, shareUrl].filter(Boolean).join('\\n'));
setMessage('文案已复制,请打开微信粘贴分享');
return;
}
if (action === 'copy') {
await copyText(shareUrl);
setMessage('链接已复制');
return;
}
if (action === 'capture') {
setMessage('正在生成长图,请稍候');
await saveLongImage();
setMessage('长图已保存');
}
} catch (error) {
setMessage(error && error.message ? error.message : '操作失败,请稍后再试', true);
} finally {
setBusy(false);
}
});
});
}
function syncFrameHeight() {
if (!frame) return;
var doc = frame.contentDocument;
var body = doc && doc.body;
var root = doc && doc.documentElement;
var height = Math.max(
body ? body.scrollHeight : 0,
body ? body.offsetHeight : 0,
root ? root.scrollHeight : 0,
root ? root.offsetHeight : 0,
window.innerHeight,
320
);
frame.style.height = height + 'px';
}
if (frame) {
frame.addEventListener('load', function () {
syncFrameHeight();
window.setTimeout(syncFrameHeight, 300);
});
}
window.addEventListener('message', function (event) {
if (!event || !event.data || event.data.type !== 'plaza:embed-section' || !frame) return;
if (event.data.height) {
frame.style.height = Math.max(Number(event.data.height) || 0, window.innerHeight, 320) + 'px';
}
});
window.addEventListener('resize', syncFrameHeight);
})();
</script>
</body>
</html>`;
}
function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) {
let html = result.html;
if (embed) {
html = preparePublicationHtmlForEmbed(html);
allowPlazaEmbedFrame(res);
} else if (raw) {
html = stripPublicationHtmlCspMeta(html);
}
const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
const canWrapWithShell = !embed && !raw && isFullHtml && result.publication?.accessMode !== 'password';
if (canWrapWithShell) {
const title = detectPublishedPageTitle(html);
const rawUrl = appendQueryParam(req.originalUrl || req.url || '', 'view', 'raw');
res.set('Content-Type', 'text/html; charset=utf-8');
res.set(
'Content-Security-Policy',
"default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
);
res.set(
'Cache-Control',
result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store',
);
return res.send(
publishedPageShellHtml({
iframeUrl: rawUrl,
shareUrl: req.originalUrl ? new URL(req.originalUrl, resolveRequestOrigin(req) || 'http://localhost').toString() : '',
title,
}),
);
}
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Content-Security-Policy', publishedPageCsp(html, { embed, raw }));
res.set(
'Cache-Control',
result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store',
);
return res.send(html);
}
function passwordGateHtml(action) {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'">
<title>受保护页面</title>
<style>
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; color: #17221d; background: radial-gradient(circle at top right, #d9e9df, transparent 42rem), #f5f0e5; font-family: ui-sans-serif, sans-serif; }
form { width: min(420px, 100%); padding: 36px; border: 1px solid #d6d0c3; border-radius: 28px; background: #fffdf7; box-shadow: 0 24px 70px rgba(35, 48, 40, .12); }
p { color: #68716c; line-height: 1.7; }
input, button { width: 100%; margin-top: 14px; padding: 14px 16px; border-radius: 12px; font: inherit; }
input { border: 1px solid #c9c6bc; }
button { border: 0; color: white; background: #2f6f57; cursor: pointer; font-weight: 800; }
</style>
</head>
<body>
<form method="post" action="${action}">
<h1>此页面受密码保护</h1>
<p>请输入发布者提供的访问密码。密码不会写入链接或浏览器日志。</p>
<input type="password" name="password" minlength="8" maxlength="128" required autocomplete="current-password">
<button type="submit">访问页面</button>
</form>
</body>
</html>`;
}
function escapePublicHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function publicHomepageHtml(data) {
const cards = data.pages
.map(
(page) => `\
<a class="card" href="${escapePublicHtml(page.publicUrl)}">
<div class="card-top">
<span>${escapePublicHtml(page.templateId)}</span>
<strong>${Number(page.viewCount)} 次浏览</strong>
</div>
<h2>${escapePublicHtml(page.title)}</h2>
<p>${escapePublicHtml(page.summary || '暂无摘要')}</p>
<div class="card-foot">
<span>${new Date(page.publishedAt).toLocaleDateString('zh-CN')}</span>
<span>打开页面</span>
</div>
</a>`,
)
.join('');
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'">
<title>${escapePublicHtml(data.owner.displayName)} · MindSpace</title>
<style>
:root { color: #17221d; background: #f5f0e5; font-family: 'Iowan Old Style', 'Palatino Linotype', serif; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background:
radial-gradient(circle at top right, rgba(79, 137, 112, .18), transparent 32rem),
linear-gradient(180deg, #f9f6ef 0%, #f1eadc 100%);
color: #17221d;
}
main { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0 72px; }
.hero { padding: 28px 0 32px; border-bottom: 1px solid rgba(23, 34, 29, .12); }
.eyebrow { margin: 0 0 12px; color: #8f6b2f; font: 700 12px/1.4 ui-sans-serif, sans-serif; letter-spacing: .18em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(40px, 8vw, 82px); line-height: .96; letter-spacing: -.05em; }
.lead { max-width: 760px; margin: 18px 0 0; color: #56615b; font: 18px/1.8 ui-sans-serif, sans-serif; }
.stats { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 22px; }
.stats span { border: 1px solid rgba(23, 34, 29, .12); border-radius: 999px; padding: 10px 14px; background: rgba(255,255,255,.6); font: 600 13px/1 ui-sans-serif, sans-serif; color: #405048; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; margin-top: 34px; }
.card { display: flex; flex-direction: column; gap: 16px; min-height: 230px; padding: 22px; border-radius: 24px; text-decoration: none; color: inherit; background: rgba(255,253,247,.92); border: 1px solid rgba(23, 34, 29, .08); box-shadow: 0 20px 60px rgba(45, 53, 47, .08); transition: transform .18s ease, box-shadow .18s ease; }
.card:hover { transform: translateY(-2px); box-shadow: 0 24px 70px rgba(45, 53, 47, .12); }
.card-top, .card-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; font: 600 12px/1.4 ui-sans-serif, sans-serif; color: #6a746f; text-transform: uppercase; letter-spacing: .08em; }
.card h2 { margin: 0; font-size: 28px; line-height: 1.08; letter-spacing: -.03em; }
.card p { margin: 0; color: #56615b; font: 15px/1.75 ui-sans-serif, sans-serif; }
.empty { margin-top: 32px; padding: 28px; border-radius: 24px; background: rgba(255,253,247,.76); border: 1px dashed rgba(23, 34, 29, .18); color: #56615b; font: 16px/1.7 ui-sans-serif, sans-serif; }
@media (max-width: 640px) {
main { width: min(100% - 24px, 1120px); padding: 24px 0 48px; }
.hero { padding-top: 12px; }
.grid { gap: 14px; }
.card { min-height: 0; border-radius: 20px; }
.card h2 { font-size: 24px; }
}
</style>
</head>
<body>
<main>
<section class="hero">
<p class="eyebrow">MindSpace Public</p>
<h1>${escapePublicHtml(data.owner.displayName)}</h1>
<p class="lead">这里展示 ${escapePublicHtml(data.owner.displayName)} 当前公开发布且在线的 MindSpace 页面。</p>
<div class="stats">
<span>${Number(data.pageCount)} 个公开页面</span>
<span>${Number(data.totalViews)} 次累计浏览</span>
<span>/u/${escapePublicHtml(data.owner.slug)}</span>
</div>
</section>
${
data.pages.length
? `<section class="grid">${cards}</section>`
: '<section class="empty">这个主页还没有公开页面。稍后再来看看,或者直接访问作者分享给你的专属链接。</section>'
}
</main>
</body>
</html>`;
}
async function resolvePublishedRoute(req, res, password = null) {
await userAuthReady;
if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用');
try {
const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null;
const result = await mindSpacePublications.resolvePublic(
req.params.ownerSlug,
req.params.urlSlug,
viewer?.id,
password,
{
userAgent: req.get('user-agent'),
referrer: req.get('referer'),
},
);
return sendPublishedPage(req, res, result, {
embed: isPlazaEmbedRequest(req.query),
raw: String(req.query.view ?? '').toLowerCase() === 'raw',
});
} catch (error) {
if (error?.code === 'publication_password_required') {
return res.status(password ? 403 : 200).send(passwordGateHtml(req.originalUrl));
}
if (error?.code === 'publication_login_required') {
return res.status(401).send('请先登录 TKMind 后再访问此页面');
}
if (error?.code === 'publication_not_found') return res.status(404).send('页面不存在或已下线');
return res.status(500).send('页面加载失败');
}
}
app.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 (.../<uuid> or .../<uuid>/) so relative covers and the thumbnail resolve correctly.
const servedName = path.basename(filePath);
const urlLast = decodeURIComponent(cleanPath.split('/').filter(Boolean).pop() ?? '');
const isImplicitIndex = urlLast.toLowerCase() !== servedName.toLowerCase();
const pageUrl = origin
? `${origin}${isImplicitIndex && !cleanPath.endsWith('/') ? `${cleanPath}/` : cleanPath}`
: '';
const pageDirUrl = !origin
? ''
: isImplicitIndex
? `${origin}${cleanPath.endsWith('/') ? cleanPath : `${cleanPath}/`}`
: `${origin}${cleanPath.slice(0, cleanPath.lastIndexOf('/') + 1)}`;
// Guaranteed fallback cover: the page's feed thumbnail, served on demand as PNG.
let fallbackImageUrl = '';
const svgSibling = filePath.replace(/\.[^./]+$/, '.thumbnail.svg');
if (pageDirUrl && fs.existsSync(svgSibling)) {
const pngName = path.basename(thumbnailPngPathForSvg(svgSibling));
fallbackImageUrl = `${pageDirUrl}${pngName}`;
}
try {
html = injectOgTags(html, { origin, pageUrl, pageDirUrl, fallbackImageUrl });
} catch {
// On any parse failure, fall back to the original HTML — never break page delivery.
}
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(html);
}
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;
}
async function serveUserPublishFile(req, res, next) {
const parts = req.path.split('/').filter(Boolean);
if (parts.length < 1) {
res.status(404).json({ message: '未找到页面' });
return;
}
const dirKey = await resolvePublishDirKey(parts[0]);
if (!dirKey) {
res.status(404).json({ message: '未找到页面' });
return;
}
if (parts[0].toLowerCase() !== dirKey && USERNAME_SLUG.test(parts[0])) {
const rest = parts.slice(1).map(encodeURIComponent).join('/');
const target = `/${PUBLISH_ROOT_DIR}/${dirKey}${rest ? `/${rest}` : '/'}`;
res.redirect(301, target);
return;
}
const [username, ...rest] = [dirKey, ...parts.slice(1)];
const targetDir = path.join(__dirname, PUBLISH_ROOT_DIR, username);
const resolvedRoot = path.resolve(targetDir);
const filePath = path.join(targetDir, ...rest);
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(`${resolvedRoot}${path.sep}`) && resolvedPath !== resolvedRoot) {
res.status(403).json({ message: '禁止访问' });
return;
}
if (!fs.existsSync(targetDir)) {
res.status(404).json({ message: '用户不存在' });
return;
}
// On-demand cover: rasterize <base>.thumbnail.svg → .thumbnail.png the first time a
// forwarded link's og:image is fetched (and refresh it when the SVG changes).
if (/\.thumbnail\.png$/i.test(resolvedPath)) {
const svgSibling = resolvedPath.replace(/\.png$/i, '.svg');
if (fs.existsSync(svgSibling)) {
const pngPath = ensureThumbnailPng(svgSibling);
if (pngPath && fs.existsSync(pngPath)) {
res.set('Cache-Control', 'public, max-age=300');
res.sendFile(pngPath);
return;
}
}
}
if (!fs.existsSync(resolvedPath)) {
if (rest.length === 1 && rest[0].toLowerCase().endsWith('.html')) {
const publicFallback = path.resolve(targetDir, PUBLIC_ZONE_DIR, rest[0]);
if (
publicFallback.startsWith(`${resolvedRoot}${path.sep}`) &&
fs.existsSync(publicFallback) &&
fs.statSync(publicFallback).isFile()
) {
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/<username>/path/to/file
// These are public-facing pages (like travel guides) stored in user directories
// Accessed via URL like https://goo.tkmind.cn/user/john/vietnam-guide/
app.use('/user', async (req, res, next) => {
await userAuthReady;
// Path format: /user/<username>/<rest...>
const parts = req.path.split('/').filter(Boolean);
if (parts.length < 1) return next();
const [username, ...rest] = parts;
const targetDir = path.join(USERS_ROOT, username);
// Security: ensure resolved path is within the user's directory
const filePath = path.join(targetDir, ...rest);
const resolvedRoot = path.resolve(targetDir);
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(resolvedRoot + path.sep) && resolvedPath !== resolvedRoot) {
return res.status(403).json({ message: '禁止访问' });
}
// Check if the user directory exists
if (!fs.existsSync(targetDir)) {
return res.status(404).json({ message: '用户不存在' });
}
if (!fs.existsSync(resolvedPath)) {
return res.status(404).json({ message: '文件不存在' });
}
// If it's a directory, serve index.html
if (fs.statSync(resolvedPath).isDirectory()) {
const indexPath = path.join(resolvedPath, 'index.html');
if (fs.existsSync(indexPath)) {
return res.sendFile(indexPath);
}
return res.status(404).json({ message: '目录中没有 index.html' });
}
res.sendFile(resolvedPath, (err) => {
if (err) res.status(404).json({ message: '文件不存在' });
});
});
app.get(`/${PUBLISH_ROOT_DIR}/wiki/*`, (_req, res) => {
res.sendFile(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki', 'index.html'));
});
app.get('/temp/wiki/*', (req, res) => {
res.redirect(301, `/${PUBLISH_ROOT_DIR}/wiki${req.url.slice('/temp/wiki'.length)}`);
});
app.use(
'/plaza-covers',
express.static(path.join(__dirname, 'public/plaza-covers'), { maxAge: '7d' }),
);
app.get(/^\/MP_verify_[A-Za-z0-9]+\.txt$/, (req, res) => {
const fileName = path.basename(req.path);
const filePath = path.join(__dirname, 'public', fileName);
if (!fs.existsSync(filePath)) return res.status(404).end();
res.type('text/plain').sendFile(filePath);
});
app.use(express.static(path.join(__dirname, 'dist'), { index: 'index.html' }));
app.get('*', (_req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
userAuthReady.then((enabled) => {
app.listen(PORT, '127.0.0.1', () => {
console.log(`TKMind H5 @ http://127.0.0.1:${PORT}`);
console.log(`Proxy -> ${API_TARGETS.join(', ')}`);
console.log(`Auth -> ${enabled ? 'multi-user (MySQL)' : legacyAuth ? 'legacy password' : 'disabled'}`);
console.log(`Wiki @ http://127.0.0.1:${PORT}/${PUBLISH_ROOT_DIR}/wiki`);
});
});