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