f818dd0307
Use JSAPI with bound openid inside WeChat instead of QR long-press, and include related auth redirect and admin UX fixes. Co-authored-by: Cursor <cursoragent@cursor.com>
3589 lines
125 KiB
JavaScript
3589 lines
125 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 { 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, hasOpsRole } 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;
|
|
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 isLoopbackHostname(hostname) {
|
|
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
|
}
|
|
|
|
function csrfOriginCheck(req, res, next) {
|
|
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
|
|
const host = req.get('host');
|
|
const origin = req.get('origin');
|
|
const referer = req.get('referer');
|
|
if (!origin && !referer) return next();
|
|
const requestHostname = (host ?? '').split(':')[0];
|
|
const allowed = [origin, referer].some((value) => {
|
|
if (!value) return false;
|
|
try {
|
|
const source = new URL(value);
|
|
if (source.host === host) return true;
|
|
return (
|
|
isLoopbackHostname(requestHostname) && isLoopbackHostname(source.hostname)
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
if (!allowed) {
|
|
return sendError(res, req, 403, 'csrf_failed', '来源校验失败');
|
|
}
|
|
return next();
|
|
}
|
|
|
|
app.use('/api', csrfOriginCheck);
|
|
app.use('/auth', csrfOriginCheck);
|
|
|
|
const jsonBody = express.json({ limit: '1mb' });
|
|
const 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);
|
|
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?.();
|
|
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();
|
|
});
|
|
|
|
// ============ Admin API ============
|
|
|
|
function requireAdmin(req, res, next) {
|
|
if (!req.currentUser || req.currentUser.role !== 'admin') {
|
|
res.status(403).json({ message: '需要管理员权限' });
|
|
return;
|
|
}
|
|
next();
|
|
}
|
|
|
|
const adminApi = express.Router();
|
|
adminApi.use(jsonBody);
|
|
|
|
adminApi.use(async (req, res, next) => {
|
|
await userAuthReady;
|
|
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
|
|
const me = await userAuth.getMe(userToken(req));
|
|
if (!me) return res.status(401).json({ message: '未登录' });
|
|
req.currentUser = me;
|
|
next();
|
|
});
|
|
|
|
adminApi.get('/users', requireAdmin, async (_req, res) => {
|
|
const users = await userAuth.listUsers();
|
|
res.json({ users });
|
|
});
|
|
|
|
adminApi.get('/summary', requireAdmin, async (_req, res) => {
|
|
const summary = await userAuth.getAdminSummary();
|
|
let llm = null;
|
|
if (llmProviderService) {
|
|
const keys = await llmProviderService.listKeys();
|
|
const global = await llmProviderService.getGlobalSettings();
|
|
const selected = keys.find((key) => key.isSelected);
|
|
llm = {
|
|
keyCount: keys.length,
|
|
selectedKeyName: selected?.name ?? null,
|
|
globalModel: global?.model ?? null,
|
|
};
|
|
}
|
|
res.json({ summary: { ...summary, llm } });
|
|
});
|
|
|
|
adminApi.post('/users', requireAdmin, async (req, res) => {
|
|
const result = await userAuth.createUser(req.body ?? {});
|
|
if (!result.ok) return res.status(400).json({ message: result.message });
|
|
res.status(201).json({ user: result.user });
|
|
});
|
|
|
|
adminApi.patch('/users/:userId', requireAdmin, async (req, res) => {
|
|
const result = await userAuth.updateUser(req.params.userId, req.body ?? {});
|
|
if (!result.ok) return res.status(400).json({ message: result.message });
|
|
res.json({ user: result.user });
|
|
});
|
|
|
|
adminApi.post('/users/:userId/recharge', requireAdmin, async (req, res) => {
|
|
const amountCents = Number(req.body?.amountCents);
|
|
const note = typeof req.body?.note === 'string' ? req.body.note : '';
|
|
const result = await userAuth.recharge(
|
|
req.params.userId,
|
|
amountCents,
|
|
req.currentUser.id,
|
|
note,
|
|
);
|
|
if (!result.ok) return res.status(400).json({ message: result.message });
|
|
res.json({ user: result.user });
|
|
});
|
|
|
|
adminApi.get('/usage', requireAdmin, async (req, res) => {
|
|
const userId = typeof req.query.userId === 'string' ? req.query.userId : null;
|
|
const limit = Number(req.query.limit ?? 50);
|
|
const records = await userAuth.listUsageRecords({ userId, limit });
|
|
res.json({ records });
|
|
});
|
|
|
|
adminApi.get('/capabilities/catalog', requireAdmin, (_req, res) => {
|
|
res.json({ catalog: userAuth.capabilityCatalog });
|
|
});
|
|
|
|
adminApi.get('/capabilities/role/:role', requireAdmin, async (req, res) => {
|
|
const role = req.params.role === 'admin' ? 'admin' : 'user';
|
|
if (role === 'admin') {
|
|
return res.json({
|
|
role,
|
|
capabilities: Object.fromEntries(
|
|
userAuth.capabilityCatalog.map((item) => [item.key, true]),
|
|
),
|
|
unrestricted: true,
|
|
});
|
|
}
|
|
res.json(await userAuth.getRoleCapabilities('user'));
|
|
});
|
|
|
|
adminApi.put('/capabilities/role/:role', requireAdmin, async (req, res) => {
|
|
const role = req.params.role === 'admin' ? 'admin' : 'user';
|
|
const result = await userAuth.setRoleCapabilities(role, req.body?.capabilities ?? {});
|
|
if (!result.ok) return res.status(400).json({ message: result.message });
|
|
res.json(result);
|
|
});
|
|
|
|
adminApi.get('/users/:userId/capabilities', requireAdmin, async (req, res) => {
|
|
const result = await userAuth.getUserCapabilities(req.params.userId);
|
|
if (!result.ok) return res.status(404).json({ message: result.message });
|
|
res.json(result);
|
|
});
|
|
|
|
adminApi.put('/users/:userId/capabilities', requireAdmin, async (req, res) => {
|
|
const result = await userAuth.setUserCapabilities(
|
|
req.params.userId,
|
|
req.body?.capabilities ?? {},
|
|
);
|
|
if (!result.ok) return res.status(400).json({ message: result.message });
|
|
res.json(result);
|
|
});
|
|
|
|
adminApi.delete('/users/:userId/capabilities', requireAdmin, async (req, res) => {
|
|
const result = await userAuth.clearUserCapabilityOverrides(req.params.userId);
|
|
if (!result.ok) return res.status(404).json({ message: result.message });
|
|
res.json(result);
|
|
});
|
|
|
|
adminApi.get('/policies/catalog', requireAdmin, (_req, res) => {
|
|
res.json({ catalog: userAuth.policyCatalog });
|
|
});
|
|
|
|
adminApi.get('/policies/role/:role', requireAdmin, async (req, res) => {
|
|
const role = req.params.role === 'admin' ? 'admin' : 'user';
|
|
if (role === 'admin') {
|
|
return res.json({ role, policies: {}, unrestricted: true });
|
|
}
|
|
res.json(await userAuth.getRolePolicies('user'));
|
|
});
|
|
|
|
adminApi.put('/policies/role/:role', requireAdmin, async (req, res) => {
|
|
const role = req.params.role === 'admin' ? 'admin' : 'user';
|
|
const result = await userAuth.setRolePolicies(role, req.body?.policies ?? {});
|
|
if (!result.ok) return res.status(400).json({ message: result.message });
|
|
res.json(result);
|
|
});
|
|
|
|
adminApi.get('/users/:userId/policies', requireAdmin, async (req, res) => {
|
|
const result = await userAuth.getUserPolicies(req.params.userId);
|
|
if (!result.ok) return res.status(404).json({ message: result.message });
|
|
res.json(result);
|
|
});
|
|
|
|
adminApi.put('/users/:userId/policies', requireAdmin, async (req, res) => {
|
|
const result = await userAuth.setUserPolicies(req.params.userId, req.body?.policies ?? {});
|
|
if (!result.ok) return res.status(400).json({ message: result.message });
|
|
res.json(result);
|
|
});
|
|
|
|
adminApi.delete('/users/:userId/policies', requireAdmin, async (req, res) => {
|
|
const result = await userAuth.clearUserPolicyOverrides(req.params.userId);
|
|
if (!result.ok) return res.status(404).json({ message: result.message });
|
|
res.json(result);
|
|
});
|
|
|
|
adminApi.get('/skills/catalog', requireAdmin, (_req, res) => {
|
|
res.json({ catalog: userAuth.skillCatalog });
|
|
});
|
|
|
|
adminApi.get('/skills/role/:role', requireAdmin, async (req, res) => {
|
|
const role = req.params.role === 'admin' ? 'admin' : 'user';
|
|
if (role === 'admin') {
|
|
return res.json({
|
|
role,
|
|
skills: Object.fromEntries(userAuth.skillCatalog.map((item) => [item.name, true])),
|
|
});
|
|
}
|
|
res.json(await userAuth.getRoleSkills('user'));
|
|
});
|
|
|
|
adminApi.put('/skills/role/:role', requireAdmin, async (req, res) => {
|
|
const role = req.params.role === 'admin' ? 'admin' : 'user';
|
|
const result = await userAuth.setRoleSkills(role, req.body?.skills ?? {});
|
|
if (!result.ok) return res.status(400).json({ message: result.message });
|
|
res.json(result);
|
|
});
|
|
|
|
adminApi.get('/users/:userId/skills', requireAdmin, async (req, res) => {
|
|
const result = await userAuth.getUserSkills(req.params.userId);
|
|
if (!result.ok) return res.status(404).json({ message: result.message });
|
|
res.json(result);
|
|
});
|
|
|
|
adminApi.put('/users/:userId/skills', requireAdmin, async (req, res) => {
|
|
const result = await userAuth.setUserSkills(req.params.userId, req.body?.skills ?? {});
|
|
if (!result.ok) return res.status(400).json({ message: result.message });
|
|
res.json(result);
|
|
});
|
|
|
|
adminApi.delete('/users/:userId/skills', requireAdmin, async (req, res) => {
|
|
const result = await userAuth.clearUserSkillOverrides(req.params.userId);
|
|
if (!result.ok) return res.status(404).json({ message: result.message });
|
|
res.json(result);
|
|
});
|
|
|
|
adminApi.get('/ledger', requireAdmin, async (req, res) => {
|
|
const userId = typeof req.query.userId === 'string' ? req.query.userId : null;
|
|
const limit = Number(req.query.limit ?? 50);
|
|
const entries = await userAuth.listBillingLedger({ userId, limit });
|
|
res.json({ entries });
|
|
});
|
|
|
|
adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => {
|
|
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
|
res.json({ catalog: llmProviderService.catalog });
|
|
});
|
|
|
|
adminApi.get('/llm-providers/keys', requireAdmin, async (_req, res) => {
|
|
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
|
const keys = await llmProviderService.listKeys();
|
|
res.json({ keys });
|
|
});
|
|
|
|
adminApi.post('/llm-providers/keys', requireAdmin, async (req, res) => {
|
|
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
|
const result = await llmProviderService.createKey(req.body ?? {});
|
|
if (!result.ok) return res.status(400).json({ message: result.message });
|
|
res.status(201).json({ key: result.key });
|
|
});
|
|
|
|
adminApi.patch('/llm-providers/keys/:keyId', requireAdmin, async (req, res) => {
|
|
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
|
const result = await llmProviderService.updateKey(req.params.keyId, req.body ?? {});
|
|
if (!result.ok) return res.status(400).json({ message: result.message });
|
|
res.json({ key: result.key });
|
|
});
|
|
|
|
adminApi.post('/llm-providers/keys/:keyId/select', requireAdmin, async (req, res) => {
|
|
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
|
const result = await llmProviderService.selectKey(req.params.keyId);
|
|
if (!result.ok) return res.status(400).json({ message: result.message });
|
|
res.json({ key: result.key });
|
|
});
|
|
|
|
adminApi.delete('/llm-providers/keys/:keyId', requireAdmin, async (req, res) => {
|
|
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
|
const result = await llmProviderService.deleteKey(req.params.keyId);
|
|
if (!result.ok) return res.status(400).json({ message: result.message });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
adminApi.post('/llm-providers/sync', requireAdmin, async (_req, res) => {
|
|
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
|
try {
|
|
const result = await llmProviderService.syncSelectedToGoosed();
|
|
res.json(result);
|
|
} catch (err) {
|
|
res.status(500).json({
|
|
message: err instanceof Error ? err.message : '同步失败',
|
|
});
|
|
}
|
|
});
|
|
|
|
adminApi.get('/llm-providers/global', requireAdmin, async (_req, res) => {
|
|
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
|
const global = await llmProviderService.getGlobalSettings();
|
|
res.json({ global });
|
|
});
|
|
|
|
adminApi.put('/llm-providers/global', requireAdmin, async (req, res) => {
|
|
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
|
const result = await llmProviderService.setGlobalModel(req.body?.model);
|
|
if (!result.ok) return res.status(400).json({ message: result.message });
|
|
res.json(result);
|
|
});
|
|
|
|
adminApi.post('/llm-providers/test', requireAdmin, async (req, res) => {
|
|
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
|
try {
|
|
const result = await llmProviderService.testDraft(req.body ?? {});
|
|
res.json(result);
|
|
} catch (err) {
|
|
res.status(500).json({
|
|
ok: false,
|
|
message: err instanceof Error ? err.message : '联通测试失败',
|
|
});
|
|
}
|
|
});
|
|
|
|
adminApi.post('/llm-providers/keys/:keyId/test', requireAdmin, async (req, res) => {
|
|
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
|
try {
|
|
const result = await llmProviderService.testKey(req.params.keyId, req.body?.model);
|
|
res.json(result);
|
|
} catch (err) {
|
|
res.status(500).json({
|
|
ok: false,
|
|
message: err instanceof Error ? err.message : '联通测试失败',
|
|
});
|
|
}
|
|
});
|
|
|
|
adminApi.get('/plaza/pending', requireAdmin, async (_req, res) => {
|
|
if (!plazaPosts) return res.status(503).json({ message: 'Plaza 未启用' });
|
|
const posts = await plazaPosts.listPendingPosts();
|
|
res.json({ data: { posts } });
|
|
});
|
|
|
|
adminApi.post('/plaza/posts/:id/review', requireAdmin, async (req, res) => {
|
|
if (!plazaPosts) return res.status(503).json({ message: 'Plaza 未启用' });
|
|
try {
|
|
const action = String(req.body?.action ?? 'approve');
|
|
const reason = req.body?.reason ?? null;
|
|
const result =
|
|
plazaOps && req.currentUser?.id
|
|
? await plazaOps.reviewPostAsOps(req.currentUser.id, req.params.id, action, { reason })
|
|
: await plazaPosts.reviewPost(req.params.id, action, { reason });
|
|
res.json({ data: result });
|
|
} catch (error) {
|
|
const status = mapPlazaError(error);
|
|
res.status(status).json({
|
|
error: { code: error?.code ?? 'review_failed', message: error.message },
|
|
});
|
|
}
|
|
});
|
|
|
|
app.get('/auth/usage', async (req, res) => {
|
|
await userAuthReady;
|
|
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
|
|
const me = await userAuth.getMe(userToken(req));
|
|
if (!me) return res.status(401).json({ message: '未登录' });
|
|
const records = await userAuth.listUsageRecords({ userId: me.id, limit: 30 });
|
|
res.json({ records });
|
|
});
|
|
|
|
app.get('/auth/billing/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 });
|
|
}
|
|
});
|
|
|
|
app.use('/admin-api', adminApi);
|
|
|
|
// ============ Wiki API ============
|
|
|
|
function wikiCookie(token, secure) {
|
|
const parts = [
|
|
`${wikiAuth.COOKIE_NAME}=${encodeURIComponent(token)}`,
|
|
'Path=/',
|
|
'HttpOnly',
|
|
'SameSite=Lax',
|
|
'Max-Age=604800',
|
|
];
|
|
if (secure) parts.push('Secure');
|
|
return parts.join('; ');
|
|
}
|
|
|
|
function clearWikiCookie(secure) {
|
|
const parts = [`${wikiAuth.COOKIE_NAME}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
|
|
if (secure) parts.push('Secure');
|
|
return parts.join('; ');
|
|
}
|
|
|
|
const wikiApi = express.Router();
|
|
wikiApi.use(jsonBody);
|
|
|
|
wikiApi.post('/auth/register', (req, res) => {
|
|
const { username, password, displayName } = req.body || {};
|
|
if (!username || !password) {
|
|
return res.status(400).json({ message: '用户名和密码不能为空' });
|
|
}
|
|
const result = wikiAuth.register(username, password, displayName);
|
|
if (!result.ok) return res.status(409).json({ message: result.message });
|
|
return res.json({ ok: true, user: result.user });
|
|
});
|
|
|
|
wikiApi.post('/auth/login', (req, res) => {
|
|
const { username, password } = req.body || {};
|
|
if (!username || !password) {
|
|
return res.status(400).json({ message: '用户名和密码不能为空' });
|
|
}
|
|
const result = wikiAuth.login(username, password);
|
|
if (!result.ok) return res.status(401).json({ message: result.message });
|
|
const secure = isSecureRequest(req);
|
|
res.set('Set-Cookie', wikiCookie(result.token, secure));
|
|
return res.json({ ok: true, user: result.user });
|
|
});
|
|
|
|
wikiApi.post('/auth/logout', (req, res) => {
|
|
const cookies = parseCookies(req.get('cookie'));
|
|
wikiAuth.revoke(cookies[wikiAuth.COOKIE_NAME]);
|
|
res.set('Set-Cookie', clearWikiCookie(isSecureRequest(req)));
|
|
return res.json({ ok: true });
|
|
});
|
|
|
|
wikiApi.get('/auth/me', (req, res) => {
|
|
const cookies = parseCookies(req.get('cookie'));
|
|
const session = wikiAuth.verify(cookies[wikiAuth.COOKIE_NAME]);
|
|
if (!session) return res.json({ authenticated: false, user: null });
|
|
const user = wikiAuth.getUser(session.username);
|
|
return res.json({ authenticated: true, user });
|
|
});
|
|
|
|
function requireWikiAuth(req, res, next) {
|
|
const cookies = parseCookies(req.get('cookie'));
|
|
const session = wikiAuth.verify(cookies[wikiAuth.COOKIE_NAME]);
|
|
if (!session) return res.status(401).json({ message: '未登录' });
|
|
req.wikiUser = session;
|
|
next();
|
|
}
|
|
|
|
wikiApi.get('/pages', requireWikiAuth, (req, res) => {
|
|
const { q } = req.query;
|
|
if (q) return res.json(wikiAuth.searchPages(req.wikiUser.username, q));
|
|
res.json(wikiAuth.listPages(req.wikiUser.username));
|
|
});
|
|
|
|
wikiApi.get('/pages/:slug', requireWikiAuth, (req, res) => {
|
|
const page = wikiAuth.getPage(req.wikiUser.username, req.params.slug);
|
|
if (!page) return res.status(404).json({ message: '页面不存在' });
|
|
res.json(page);
|
|
});
|
|
|
|
wikiApi.post('/pages/:slug', requireWikiAuth, (req, res) => {
|
|
const { title, content, tags } = req.body || {};
|
|
const page = wikiAuth.savePage(req.wikiUser.username, req.params.slug, title, content, tags);
|
|
res.json(page);
|
|
});
|
|
|
|
wikiApi.delete('/pages/:slug', requireWikiAuth, (req, res) => {
|
|
wikiAuth.deletePage(req.wikiUser.username, req.params.slug);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.use('/wiki-api', wikiApi);
|
|
|
|
// ============ TKMind API proxy ============
|
|
|
|
const api = express.Router();
|
|
api.use(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 makeRequireOps(minRole = 'reviewer') {
|
|
return async (req, res, next) => {
|
|
if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
|
|
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
|
const role = await plazaOps.loadOperatorRole(req.currentUser.id);
|
|
if (!hasOpsRole(role, minRole)) {
|
|
return sendError(res, req, 403, 'OPS_PERMISSION_DENIED', '无运营权限');
|
|
}
|
|
req.opsRole = role;
|
|
return next();
|
|
};
|
|
}
|
|
|
|
const opsApi = express.Router();
|
|
opsApi.use(jsonBody);
|
|
opsApi.use(makeRequireOps('reviewer'));
|
|
|
|
opsApi.get('/review/queue', async (req, res) => {
|
|
try {
|
|
const queue = await plazaOps.listReviewQueue({
|
|
status: req.query.status ?? 'pending_review',
|
|
cursor: req.query.cursor ?? null,
|
|
limit: req.query.limit,
|
|
keyword: req.query.keyword ?? null,
|
|
});
|
|
return sendData(res, req, queue);
|
|
} catch (error) {
|
|
return plazaRouteError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.post('/review/posts/:id', async (req, res) => {
|
|
try {
|
|
const result = await plazaOps.reviewPostAsOps(req.currentUser.id, req.params.id, req.body?.action, {
|
|
reason: req.body?.reason ?? null,
|
|
});
|
|
return sendData(res, req, { post: result });
|
|
} catch (error) {
|
|
return plazaRouteError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.post('/review/batch', makeRequireOps('editor'), async (req, res) => {
|
|
try {
|
|
const result = await plazaOps.batchReviewPosts(req.currentUser.id, req.body ?? {});
|
|
return sendData(res, req, result);
|
|
} catch (error) {
|
|
return plazaRouteError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.get('/reports', async (req, res) => {
|
|
try {
|
|
return sendData(res, req, await plazaOps.listReports({ status: req.query.status ?? 'pending' }));
|
|
} catch (error) {
|
|
return plazaRouteError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.post('/reports/:id/process', async (req, res) => {
|
|
try {
|
|
const result = await plazaOps.processReport(req.currentUser.id, req.params.id, req.body ?? {});
|
|
return sendData(res, req, { report: result });
|
|
} catch (error) {
|
|
return plazaRouteError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.get('/featured', makeRequireOps('editor'), async (req, res) => {
|
|
try {
|
|
return sendData(res, req, await plazaOps.listFeatured());
|
|
} catch (error) {
|
|
return plazaRouteError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.post('/featured', makeRequireOps('editor'), async (req, res) => {
|
|
try {
|
|
const result = await plazaOps.setFeatured(req.currentUser.id, req.body ?? {});
|
|
return sendData(res, req, { featured: result }, 201);
|
|
} catch (error) {
|
|
return plazaRouteError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.delete('/featured/:id', makeRequireOps('editor'), async (req, res) => {
|
|
try {
|
|
const result = await plazaOps.removeFeatured(req.currentUser.id, req.params.id);
|
|
return sendData(res, req, { featured: result });
|
|
} catch (error) {
|
|
return plazaRouteError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.get('/analytics/overview', makeRequireOps('ops_admin'), async (req, res) => {
|
|
try {
|
|
return sendData(res, req, await plazaOps.getAnalyticsOverview());
|
|
} catch (error) {
|
|
return plazaRouteError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.get('/creators', async (req, res) => {
|
|
try {
|
|
return sendData(res, req, await plazaOps.listCreators({ keyword: req.query.keyword ?? null }));
|
|
} catch (error) {
|
|
return plazaRouteError(res, req, error);
|
|
}
|
|
});
|
|
|
|
opsApi.patch('/creators/:userId', async (req, res) => {
|
|
try {
|
|
const result = await plazaOps.updateCreator(req.currentUser.id, req.params.userId, req.body ?? {});
|
|
return sendData(res, req, { creator: result });
|
|
} catch (error) {
|
|
return plazaRouteError(res, req, error);
|
|
}
|
|
});
|
|
|
|
function mindSpaceError(res, req, error) {
|
|
const statusByCode = {
|
|
invalid_filename: 400,
|
|
invalid_file_size: 400,
|
|
category_not_uploadable: 400,
|
|
file_size_mismatch: 409,
|
|
invalid_upload_state: 409,
|
|
file_too_large: 413,
|
|
unsupported_file_type: 415,
|
|
category_not_found: 404,
|
|
upload_not_found: 404,
|
|
asset_not_found: 404,
|
|
asset_in_use: 409,
|
|
page_not_found: 404,
|
|
upload_expired: 410,
|
|
quota_exceeded: 429,
|
|
public_page_limit_exceeded: 429,
|
|
space_unavailable: 423,
|
|
invalid_page_input: 400,
|
|
page_content_too_large: 413,
|
|
source_message_not_found: 404,
|
|
invalid_source_message: 409,
|
|
version_conflict: 409,
|
|
slug_conflict: 409,
|
|
invalid_state_transition: 409,
|
|
invalid_publish_input: 400,
|
|
publication_not_found: 404,
|
|
security_scan_required: 422,
|
|
security_ack_required: 422,
|
|
security_risk_blocked: 422,
|
|
invalid_agent_job_input: 400,
|
|
invalid_agent_job_output: 400,
|
|
agent_job_not_found: 404,
|
|
agent_job_token_invalid: 401,
|
|
agent_job_expired: 410,
|
|
feature_disabled: 503,
|
|
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('/ops/v1', opsApi);
|
|
|
|
api.use(
|
|
createProxyMiddleware({
|
|
target: API_TARGET,
|
|
changeOrigin: true,
|
|
secure: false,
|
|
pathRewrite: { '^/api': '' },
|
|
on: {
|
|
proxyReq: (proxyReq) => {
|
|
proxyReq.setHeader('X-Secret-Key', API_SECRET);
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
app.use('/api', api);
|
|
|
|
function publishedPageCsp(html, { 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`);
|
|
});
|
|
});
|