5569 lines
203 KiB
JavaScript
5569 lines
203 KiB
JavaScript
import express from 'express';
|
||
import crypto from 'node:crypto';
|
||
import fs from 'node:fs';
|
||
import fsPromises from 'node:fs/promises';
|
||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import {
|
||
AUTH_COOKIE,
|
||
clearSessionCookie,
|
||
createAuthManager,
|
||
parseCookies,
|
||
sessionCookie,
|
||
} from './auth.mjs';
|
||
import { createDbPool, initSchema, isDatabaseConfigured } from './db.mjs';
|
||
import { createAgentRunGateway } from './agent-run-gateway.mjs';
|
||
import { createToolGateway } from './tool-gateway.mjs';
|
||
import {
|
||
createAgentRunEventsHandler,
|
||
createGetAgentRunHandler,
|
||
createPostAgentRunsHandler,
|
||
} from './agent-run-routes.mjs';
|
||
import { createTkmindProxy, sanitizeSessionConversationPublicHtmlLinks } from './tkmind-proxy.mjs';
|
||
import {
|
||
clearUserSessionCookie,
|
||
createUserAuth,
|
||
resolveCookieDomainForRequest,
|
||
USER_COOKIE,
|
||
userLoginCookies,
|
||
userSessionCookie,
|
||
} from './user-auth.mjs';
|
||
import { createWikiAuth } from './wiki-auth.mjs';
|
||
import { isLocalDevHostname } from './scripts/local-test-config.mjs';
|
||
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir, resolvePublicBaseUrl } from './user-publish.mjs';
|
||
import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
|
||
import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs';
|
||
import { attachRequestId, sendData, sendError } from './api-response.mjs';
|
||
import { createNotificationDispatcher } from './notification-dispatcher.mjs';
|
||
import { createMindSpaceAuditWriter } from './mindspace-audit.mjs';
|
||
import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs';
|
||
import { createMindSpaceService, DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs';
|
||
import { ensureMindSpaceConfig } from './mindspace-config.mjs';
|
||
import { createAssetService } from './mindspace-assets.mjs';
|
||
import { createPageService, pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
|
||
import { createPageLiveEditService } from './mindspace-page-live-edit.mjs';
|
||
import { createAssetAgentService } from './mindspace-asset-agent.mjs';
|
||
import { createPageEditSessionService } from './mindspace-page-edit-session.mjs';
|
||
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
|
||
import { createPublicationService, rewriteWorkspacePublicAssetReferences } from './mindspace-publications.mjs';
|
||
import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs';
|
||
import { createPlazaEventService } from './plaza-events.mjs';
|
||
import { createPlazaRecommendService } from './plaza-recommend.mjs';
|
||
import { createPlazaInteractionService } from './plaza-interactions.mjs';
|
||
import {
|
||
ensureAlgorithmConfig,
|
||
loadAlgorithmConfig,
|
||
recalculateHotScores,
|
||
} from './plaza-algorithm.mjs';
|
||
import { createPlazaRedis, createNoopPlazaRedis } from './plaza-redis.mjs';
|
||
import { startPlazaTasks, writebackPublications } from './plaza-tasks.mjs';
|
||
import { createPlazaSeoService } from './plaza-seo.mjs';
|
||
import { createPlazaOpsService } from './plaza-ops.mjs';
|
||
import { createWordFilterService } from './word-filter.mjs';
|
||
import {
|
||
allowPlazaEmbedFrame,
|
||
preparePublicationHtmlForEmbed,
|
||
isPlazaEmbedRequest,
|
||
publishedPageCspForEmbed,
|
||
stripPublicationHtmlCspMeta,
|
||
} from './plaza-embed.mjs';
|
||
import { createCleanupService } from './mindspace-cleanup.mjs';
|
||
import { createAgentJobService } from './mindspace-agent-jobs.mjs';
|
||
import { createMindSpaceAgentRunner } from './mindspace-agent-runner.mjs';
|
||
import {
|
||
analyzeChatMessageForSave,
|
||
buildChatSavePreviewFrameUrl,
|
||
buildChatSaveThumbnailUrl,
|
||
buildWorkspaceAssetUrl,
|
||
buildWorkspaceBaseHref,
|
||
buildWorkspaceThumbnailUrl,
|
||
injectHtmlBaseHref,
|
||
resolveClosestHtmlRelativePath,
|
||
createAssetDataUriResolver,
|
||
materializePrivateAssetsInWorkspaceHtml,
|
||
repairMissingHtmlAssetReferences,
|
||
resolveChatSaveAnalysis,
|
||
resolveStaticHtmlContent,
|
||
} from './mindspace-chat-save.mjs';
|
||
import {
|
||
materializePublicHtmlWritesFromSessionEvent,
|
||
syncPublicHtmlAfterFinish,
|
||
} from './mindspace-public-finish-sync.mjs';
|
||
import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs';
|
||
import { extractCoverSignals, generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
|
||
import {
|
||
extractSharePreviewMeta,
|
||
injectOgTags,
|
||
injectWechatShareBridge,
|
||
renderWechatSharePreviewHtml,
|
||
} from './mindspace-og-tags.mjs';
|
||
import {
|
||
ensureThumbnailPng,
|
||
rasterizeThumbnailSvgToPng,
|
||
thumbnailPngPathForSvg,
|
||
} from './mindspace-thumbnail-png.mjs';
|
||
import { scanContent } from './mindspace-content-scan.mjs';
|
||
import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs';
|
||
import { createRechargeService } from './billing-recharge.mjs';
|
||
import { createSubscriptionService, createPlanCatalogService, ensurePlanCatalogSchema, PLAN_CATALOG } from './billing-subscription.mjs';
|
||
import {
|
||
createWechatPayClient,
|
||
loadWechatPayConfig,
|
||
WECHAT_NOTIFY_SUCCESS_V2,
|
||
} from './wechat-pay.mjs';
|
||
import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs';
|
||
import { createWechatMpService, loadWechatMpConfig } from './wechat-mp.mjs';
|
||
import { validateWechatShareSignatureUrl } from './wechat-share.mjs';
|
||
import { createScheduleService } from './schedule-service.mjs';
|
||
import { createFeedbackService } from './user-feedback.mjs';
|
||
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
|
||
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
|
||
import { createSessionSnapshotService } from './session-snapshot.mjs';
|
||
import { createConversationMemoryService } from './conversation-memory.mjs';
|
||
import { createExperienceService } from './experience-service.mjs';
|
||
import { attachAsrRoutes } from './asr-proxy.mjs';
|
||
import { isNativeH5ApiPath } from './policies.mjs';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
|
||
function loadEnvFile(filePath) {
|
||
if (!fs.existsSync(filePath)) return;
|
||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||
const eq = trimmed.indexOf('=');
|
||
if (eq < 0) continue;
|
||
const key = trimmed.slice(0, eq).trim();
|
||
const value = trimmed.slice(eq + 1).trim();
|
||
if (!process.env[key]) process.env[key] = value;
|
||
}
|
||
}
|
||
|
||
loadEnvFile(path.join(__dirname, '../../.env.local'));
|
||
loadEnvFile(path.join(__dirname, '.env'));
|
||
|
||
function parseApiTargets() {
|
||
const csvTargets = (process.env.TKMIND_API_TARGETS ?? '')
|
||
.split(',')
|
||
.map((value) => value.trim())
|
||
.filter(Boolean);
|
||
const legacyTargets = [
|
||
process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
|
||
process.env.TKMIND_API_TARGET_1,
|
||
].filter(Boolean);
|
||
return [...new Set([...csvTargets, ...legacyTargets])];
|
||
}
|
||
|
||
const PORT = Number(process.env.H5_PORT ?? 8081);
|
||
const HOST = String(process.env.H5_HOST ?? '127.0.0.1').trim() || '127.0.0.1';
|
||
const API_TARGETS = parseApiTargets();
|
||
const API_TARGET = API_TARGETS[0] ?? 'https://127.0.0.1:18006';
|
||
const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
|
||
const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET;
|
||
const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD;
|
||
const WECHAT_MP_CONFIG = loadWechatMpConfig();
|
||
// 无状态前端节点(如 105,MindSpace 经 rclone 挂载)需设 MEMIND_WORKSPACE_MAINTENANCE=0,
|
||
// 否则启动时对挂载树做 readdir/递归 fs.watch 会占满 libuv 线程池导致 boot 卡死。
|
||
const WORKSPACE_MAINTENANCE_ENABLED = process.env.MEMIND_WORKSPACE_MAINTENANCE !== '0';
|
||
const USERS_ROOT = process.env.H5_USERS_ROOT ?? path.join(__dirname, 'users');
|
||
|
||
const app = express();
|
||
app.set('trust proxy', 1);
|
||
const isSecureRequest = (req) =>
|
||
req.secure || req.get('x-forwarded-proto')?.split(',')[0]?.trim() === 'https';
|
||
const msFlags = mindspaceFlags();
|
||
|
||
app.use(attachRequestId);
|
||
app.use((req, res, next) => {
|
||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(self), geolocation=()');
|
||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||
if (isSecureRequest(req)) {
|
||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||
}
|
||
next();
|
||
});
|
||
|
||
function csrfOriginCheck(req, res, next) {
|
||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
|
||
const host = req.get('host');
|
||
const origin = req.get('origin');
|
||
const referer = req.get('referer');
|
||
if (!origin && !referer) return next();
|
||
const requestHostname = (host ?? '').split(':')[0];
|
||
const allowed = [origin, referer].some((value) => {
|
||
if (!value) return false;
|
||
try {
|
||
const source = new URL(value);
|
||
if (source.host === host) return true;
|
||
return (
|
||
isLocalDevHostname(requestHostname) && isLocalDevHostname(source.hostname)
|
||
);
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
if (!allowed) {
|
||
return sendError(res, req, 403, 'csrf_failed', '来源校验失败');
|
||
}
|
||
return next();
|
||
}
|
||
|
||
app.use('/api', csrfOriginCheck);
|
||
app.use('/auth', csrfOriginCheck);
|
||
|
||
const jsonBody = express.json({ limit: '1mb' });
|
||
const jsonUnlessMultipart = (req, res, next) => {
|
||
if ((req.headers['content-type'] ?? '').includes('multipart/form-data')) return next();
|
||
return jsonBody(req, res, next);
|
||
};
|
||
const rawUploadBody = express.raw({
|
||
type: 'application/octet-stream',
|
||
limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
|
||
});
|
||
const wikiAuth = createWikiAuth(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki-db'));
|
||
|
||
let legacyAuth = null;
|
||
if (ACCESS_PASSWORD && !isDatabaseConfigured()) {
|
||
legacyAuth = createAuthManager({ password: ACCESS_PASSWORD });
|
||
} else if (ACCESS_PASSWORD && isDatabaseConfigured()) {
|
||
console.log('H5_ACCESS_PASSWORD ignored: multi-user database auth is configured');
|
||
}
|
||
|
||
let userAuth = null;
|
||
let tkmindProxy = null;
|
||
let agentRunGateway = null;
|
||
let toolGateway = null;
|
||
let sessionSnapshotService = null;
|
||
let conversationMemoryService = null;
|
||
let mindSpace = null;
|
||
let mindSpaceAssets = null;
|
||
let mindSpaceAudit = null;
|
||
let mindSpacePages = null;
|
||
let mindSpacePageLiveEdit = null;
|
||
let mindSpaceAssetAgent = null;
|
||
let mindSpacePageEditSession = null;
|
||
let mindSpacePublications = null;
|
||
let plazaPosts = null;
|
||
let plazaEvents = null;
|
||
let plazaRecommend = null;
|
||
let plazaInteractions = null;
|
||
let plazaSeo = null;
|
||
let plazaOps = null;
|
||
let plazaRedis = createNoopPlazaRedis();
|
||
let mindSpaceCleanup = null;
|
||
let mindSpaceAgentJobs = null;
|
||
let mindSpaceAgentRunner = null;
|
||
let rechargeService = null;
|
||
let subscriptionService = null;
|
||
let wechatPayClient = null;
|
||
let wechatOAuthService = null;
|
||
let wechatMpService = null;
|
||
let notificationDispatcher = null;
|
||
let scheduleService = null;
|
||
let feedbackService = null;
|
||
let scheduleReminderWorker = null;
|
||
let llmProviderService = null;
|
||
let wordFilterService = null;
|
||
let authPool = null;
|
||
|
||
async function bootstrapUserAuth() {
|
||
try {
|
||
if (!isDatabaseConfigured()) return false;
|
||
const pool = createDbPool();
|
||
authPool = pool;
|
||
await initSchema(pool);
|
||
await ensureMindSpaceConfig(pool, {
|
||
env: process.env,
|
||
});
|
||
scheduleService = createScheduleService(pool, {
|
||
defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
|
||
});
|
||
feedbackService = createFeedbackService(pool);
|
||
mindSpace = createMindSpaceService(pool, {
|
||
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
|
||
aiDailyLimit: Number(process.env.MINDSPACE_FREE_AI_DAILY_LIMIT ?? 10),
|
||
publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5),
|
||
monthlyViewLimit: Number(process.env.MINDSPACE_FREE_MONTHLY_VIEW_LIMIT ?? 1000),
|
||
scheduleService,
|
||
});
|
||
mindSpaceAssets = createAssetService(pool, {
|
||
h5Root: __dirname,
|
||
storageRoot:
|
||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
|
||
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
|
||
});
|
||
mindSpacePages = createPageService(pool, {
|
||
h5Root: __dirname,
|
||
storageRoot:
|
||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
|
||
});
|
||
const resolveUserIdForAgentSession = async (sessionId) => {
|
||
const [rows] = await pool.query(
|
||
`SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`,
|
||
[sessionId],
|
||
);
|
||
return rows[0]?.user_id ?? null;
|
||
};
|
||
mindSpacePageLiveEdit = createPageLiveEditService({
|
||
pageService: mindSpacePages,
|
||
resolveUserIdForAgentSession,
|
||
});
|
||
mindSpaceAssetAgent = createAssetAgentService({
|
||
assetService: mindSpaceAssets,
|
||
resolveUserIdForAgentSession,
|
||
});
|
||
mindSpacePublications = createPublicationService(pool, {
|
||
h5Root: __dirname,
|
||
storageRoot:
|
||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
|
||
publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5),
|
||
});
|
||
setInterval(async () => {
|
||
try {
|
||
const result = await mindSpacePublications.cleanupExpiredUnconfirmedPublications();
|
||
if (result.cleaned > 0) {
|
||
console.log(`[Publication Cleanup] Auto-privatized ${result.cleaned} expired unconfirmed publications`);
|
||
}
|
||
} catch (err) {
|
||
console.error('[Publication Cleanup Error]', err instanceof Error ? err.message : err);
|
||
}
|
||
}, 60 * 1000);
|
||
await ensureAlgorithmConfig(pool);
|
||
const plazaAlgorithmConfig = await loadAlgorithmConfig(pool);
|
||
plazaRedis = await createPlazaRedis(process.env.PLAZA_REDIS_URL, pool);
|
||
if (plazaRedis.enabled) {
|
||
console.log('Plaza Redis enabled');
|
||
}
|
||
plazaSeo = createPlazaSeoService(pool);
|
||
plazaInteractions = createPlazaInteractionService(pool, {
|
||
formatPostRow,
|
||
plazaRedis,
|
||
});
|
||
plazaEvents = createPlazaEventService(pool);
|
||
plazaRecommend = createPlazaRecommendService(pool, {
|
||
eventService: plazaEvents,
|
||
formatPostRow,
|
||
loadViewerReactions: (viewerId, postIds) =>
|
||
plazaInteractions.loadViewerReactions(viewerId, postIds),
|
||
algorithmConfig: plazaAlgorithmConfig,
|
||
});
|
||
plazaPosts = createPlazaPostService(pool, {
|
||
loadViewerReactions: (viewerId, postIds) =>
|
||
plazaInteractions.loadViewerReactions(viewerId, postIds),
|
||
plazaRedis,
|
||
algorithmConfig: plazaAlgorithmConfig,
|
||
recommendService: plazaRecommend,
|
||
onPostPublished: (postId) => plazaSeo?.notifyPostPublished(postId),
|
||
loadFeaturedPosts: async (viewerId) => {
|
||
if (!plazaOps) return { homepage_banner: [], trending: [], category_top: {} };
|
||
return plazaOps.loadActiveFeaturedPosts(viewerId);
|
||
},
|
||
});
|
||
plazaOps = createPlazaOpsService(pool, {
|
||
formatPostRow,
|
||
reviewPost: (...args) => plazaPosts.reviewPost(...args),
|
||
invalidateFeedCaches: () => plazaRedis?.invalidateFeedCaches?.(),
|
||
});
|
||
startPlazaTasks({
|
||
pool,
|
||
plazaRedis,
|
||
recalculateHotScores,
|
||
writebackPublications,
|
||
});
|
||
mindSpaceCleanup = createCleanupService(pool, {
|
||
storageRoot:
|
||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
|
||
h5Root: __dirname,
|
||
});
|
||
await ensurePlanCatalogSchema(pool);
|
||
const planCatalogService = createPlanCatalogService(pool);
|
||
subscriptionService = createSubscriptionService(pool, {
|
||
getPlanAsync: (planType) => planCatalogService.getPlan(planType),
|
||
});
|
||
subscriptionService._planCatalogService = planCatalogService;
|
||
userAuth = createUserAuth(pool, {
|
||
usersRoot: USERS_ROOT,
|
||
h5Root: __dirname,
|
||
defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 500),
|
||
subscriptionService,
|
||
});
|
||
wechatPayClient = createWechatPayClient(loadWechatPayConfig());
|
||
wechatOAuthService = createWechatOAuthService(pool, loadWechatOAuthConfig(), { userAuth });
|
||
rechargeService = createRechargeService(pool, {
|
||
userAuth,
|
||
wechatPay: wechatPayClient,
|
||
});
|
||
if (wechatPayClient.enabled) {
|
||
console.log(`WeChat Pay recharge enabled (${wechatPayClient.apiVersion ?? 'unknown'})`);
|
||
}
|
||
if (wechatOAuthService.enabled) {
|
||
console.log('WeChat OAuth login enabled');
|
||
}
|
||
mindSpaceAgentJobs = createAgentJobService(pool, {
|
||
pageService: mindSpacePages,
|
||
storageRoot:
|
||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
|
||
maxOutputBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
|
||
});
|
||
// Shared experience store (etat C): retrieval before / recording after each
|
||
// agent job, so all instances learn from one another. Gated so it can be
|
||
// disabled without touching the runner. Polyglot: when EXPERIENCE_PG_URL is
|
||
// set we use PostgreSQL + pgvector (semantic search) for this workload only;
|
||
// the MySQL business DB is untouched. Falls back to MySQL keyword store if PG
|
||
// init fails (e.g. driver missing) so a misconfig never blocks startup.
|
||
let experienceService = null;
|
||
if (process.env.MINDSPACE_EXPERIENCE_ENABLED !== 'false') {
|
||
if (process.env.EXPERIENCE_PG_URL) {
|
||
try {
|
||
const { createPgExperienceService } = await import('./experience-service-pg.mjs');
|
||
experienceService = await createPgExperienceService({
|
||
connectionString: process.env.EXPERIENCE_PG_URL,
|
||
});
|
||
console.log('Experience store: PostgreSQL + pgvector');
|
||
} catch (error) {
|
||
console.error(
|
||
'Experience PG init failed, falling back to MySQL store:',
|
||
error instanceof Error ? error.message : error,
|
||
);
|
||
experienceService = createExperienceService(pool);
|
||
}
|
||
} else {
|
||
experienceService = createExperienceService(pool);
|
||
}
|
||
}
|
||
mindSpaceAgentRunner = createMindSpaceAgentRunner({
|
||
apiTarget: API_TARGET,
|
||
apiSecret: API_SECRET,
|
||
userAuth,
|
||
agentJobService: mindSpaceAgentJobs,
|
||
experienceService,
|
||
});
|
||
mindSpaceAudit = createMindSpaceAuditWriter(pool);
|
||
// Agent job consumer: a DB-polling worker that atomically claims queued jobs
|
||
// (claimNextJob uses SELECT ... FOR UPDATE SKIP LOCKED, so multiple instances
|
||
// can run this loop without double-processing) and runs them via the runner.
|
||
// Opt-in per instance: must NOT run on the 105 stateless front (see
|
||
// docs/g2-load-balancing.md) — gate with MINDSPACE_AGENT_WORKER_ENABLED.
|
||
if (process.env.MINDSPACE_AGENT_WORKER_ENABLED === 'true') {
|
||
const workerConcurrency = Math.max(
|
||
1,
|
||
Number(process.env.MINDSPACE_AGENT_WORKER_CONCURRENCY ?? 2),
|
||
);
|
||
const workerPollMs = Math.max(
|
||
200,
|
||
Number(process.env.MINDSPACE_AGENT_WORKER_POLL_MS ?? 1000),
|
||
);
|
||
const workerStaleMs = Math.max(
|
||
10_000,
|
||
Number(process.env.MINDSPACE_AGENT_WORKER_STALE_MS ?? 5 * 60 * 1000),
|
||
);
|
||
let inFlight = 0;
|
||
let draining = false;
|
||
const drainQueue = async () => {
|
||
if (draining) return;
|
||
draining = true;
|
||
try {
|
||
while (inFlight < workerConcurrency) {
|
||
const claim = await mindSpaceAgentJobs.claimNextJob();
|
||
if (!claim) break;
|
||
inFlight += 1;
|
||
void mindSpaceAgentRunner
|
||
.runJob(claim.jobId, claim)
|
||
.catch((error) => {
|
||
console.error('Agent worker job failed:', error);
|
||
})
|
||
.finally(() => {
|
||
inFlight -= 1;
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('Agent worker drain failed:', error);
|
||
} finally {
|
||
draining = false;
|
||
}
|
||
};
|
||
const workerTimer = setInterval(() => {
|
||
void drainQueue();
|
||
}, workerPollMs);
|
||
const reaperTimer = setInterval(() => {
|
||
void mindSpaceAgentJobs
|
||
.reapStaleJobs(workerStaleMs)
|
||
.then((reaped) => {
|
||
if (reaped > 0) {
|
||
console.warn(`Agent worker reaped ${reaped} stale running job(s)`);
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
console.error('Agent worker reaper failed:', error);
|
||
});
|
||
}, Math.min(workerStaleMs, 60_000));
|
||
workerTimer.unref?.();
|
||
reaperTimer.unref?.();
|
||
console.log(
|
||
`Agent job worker enabled (concurrency=${workerConcurrency}, poll=${workerPollMs}ms)`,
|
||
);
|
||
}
|
||
if (WORKSPACE_MAINTENANCE_ENABLED) {
|
||
startWorkspaceThumbnailWatcher(path.join(__dirname, PUBLISH_ROOT_DIR));
|
||
startWorkspaceAssetSyncWatcher({
|
||
publishRoot: path.join(__dirname, PUBLISH_ROOT_DIR),
|
||
syncUserWorkspaceByDirKey: async (dirKey, options) => {
|
||
let userId = dirKey;
|
||
if (!PUBLISH_KEY_UUID.test(dirKey)) {
|
||
const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [
|
||
dirKey,
|
||
]);
|
||
userId = rows[0]?.id;
|
||
}
|
||
if (!userId) return;
|
||
await mindSpaceAssets.syncWorkspaceAssets(userId, options);
|
||
},
|
||
});
|
||
void mindSpaceAssets.expireStaleUploads().catch(() => {});
|
||
setInterval(() => {
|
||
void mindSpaceAssets?.expireStaleUploads().catch(() => {});
|
||
}, 5 * 60 * 1000).unref?.();
|
||
} else {
|
||
console.log('Workspace maintenance daemons disabled (MEMIND_WORKSPACE_MAINTENANCE=0)');
|
||
}
|
||
await userAuth.ensureAdminUser();
|
||
llmProviderService = createLlmProviderService(pool, {
|
||
apiTarget: API_TARGET,
|
||
apiTargets: API_TARGETS,
|
||
apiSecret: API_SECRET,
|
||
});
|
||
wordFilterService = createWordFilterService(pool);
|
||
void llmProviderService
|
||
.ensureBootstrapRelay()
|
||
.then((result) => {
|
||
if (result.created) {
|
||
console.log(`LLM relay bootstrap created: ${RELAY_BOOTSTRAP.name}`);
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
console.warn('LLM relay bootstrap skipped:', err instanceof Error ? err.message : err);
|
||
});
|
||
void llmProviderService.syncSelectedToGoosed().catch((err) => {
|
||
console.warn('LLM provider boot sync skipped:', err instanceof Error ? err.message : err);
|
||
});
|
||
conversationMemoryService = createConversationMemoryService(pool);
|
||
sessionSnapshotService = createSessionSnapshotService(pool, {
|
||
conversationMemoryService,
|
||
});
|
||
tkmindProxy = createTkmindProxy({
|
||
apiTarget: API_TARGET,
|
||
apiTargets: API_TARGETS,
|
||
apiSecret: API_SECRET,
|
||
userAuth,
|
||
llmProviderService,
|
||
subscriptionService,
|
||
sessionSnapshotService,
|
||
conversationMemoryService,
|
||
localFetchAsset: mindSpaceAssets
|
||
? async (userId, assetId) => {
|
||
const { asset, path: assetPath } = await mindSpaceAssets.readAsset(userId, assetId);
|
||
const buffer = await fs.promises.readFile(assetPath);
|
||
return { buffer, mimeType: asset.mimeType };
|
||
}
|
||
: null,
|
||
});
|
||
toolGateway = createToolGateway({ llmProviderService });
|
||
agentRunGateway = createAgentRunGateway({
|
||
pool,
|
||
userAuth,
|
||
tkmindProxy,
|
||
toolGateway,
|
||
autoDispatch: ['1', 'true', 'yes', 'on'].includes(
|
||
String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? '1').trim().toLowerCase(),
|
||
),
|
||
maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1),
|
||
runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000),
|
||
});
|
||
wechatMpService = createWechatMpService({
|
||
config: WECHAT_MP_CONFIG,
|
||
userAuth,
|
||
apiFetch: tkmindProxy.apiFetch,
|
||
startAgentSession: ({ userId, workingDir, sessionPolicy }) =>
|
||
tkmindProxy.startSessionForUser(userId, { workingDir, sessionPolicy }),
|
||
sessionApiFetch: async (sessionId, pathname, init) => {
|
||
const target = await tkmindProxy.resolveTarget(sessionId);
|
||
return tkmindProxy.apiFetchTo(target, pathname, init);
|
||
},
|
||
scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null,
|
||
applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId),
|
||
refreshSessionSnapshot:
|
||
sessionSnapshotService?.isEnabled()
|
||
? (sessionId, userId) =>
|
||
sessionSnapshotService.refresh(sessionId, userId, async (pathname, init) => {
|
||
const target = await tkmindProxy.resolveTarget(sessionId);
|
||
return tkmindProxy.apiFetchTo(target, pathname, init);
|
||
})
|
||
: null,
|
||
});
|
||
notificationDispatcher = createNotificationDispatcher({
|
||
sendWechatTextToUser: wechatMpService?.enabled
|
||
? (userId, text) => wechatMpService.sendTextToUser(userId, text)
|
||
: null,
|
||
});
|
||
userAuth.setRechargeNotifier(async ({ userId, title, body, dedupeKey }) => {
|
||
await notificationDispatcher.sendRechargeSuccess({ userId, title, body, dedupeKey });
|
||
});
|
||
if (
|
||
process.env.H5_REMINDER_WORKER_ENABLED === '1' &&
|
||
wechatMpService?.enabled &&
|
||
scheduleService
|
||
) {
|
||
scheduleReminderWorker = startScheduleReminderWorker({
|
||
scheduleService,
|
||
notificationDispatcher,
|
||
});
|
||
console.log('Schedule reminder worker enabled');
|
||
}
|
||
if (subscriptionService) {
|
||
const subExpiryTimer = setInterval(async () => {
|
||
try {
|
||
const { renewed, failed } = await subscriptionService.processAutoRenewals();
|
||
if (renewed > 0) console.log(`Auto-renewed ${renewed} subscription(s)`);
|
||
if (failed > 0) console.log(`Auto-renew failed for ${failed} subscription(s) (balance insufficient)`);
|
||
const n = await subscriptionService.expireStaleSubscriptions();
|
||
if (n > 0) console.log(`Expired ${n} stale subscription(s)`);
|
||
} catch (err) {
|
||
console.warn('Subscription expiry check failed:', err);
|
||
}
|
||
}, 60 * 60 * 1000); // hourly
|
||
subExpiryTimer.unref?.();
|
||
}
|
||
mindSpacePageEditSession = createPageEditSessionService({
|
||
apiTarget: API_TARGET,
|
||
apiSecret: API_SECRET,
|
||
userAuth,
|
||
pageService: mindSpacePages,
|
||
pageLiveEdit: mindSpacePageLiveEdit,
|
||
llmProviderService,
|
||
});
|
||
if (wechatMpService?.enabled) {
|
||
console.log('WeChat MP webhook enabled');
|
||
}
|
||
console.log(`User auth enabled (MySQL), workspace root: ${USERS_ROOT}`);
|
||
return true;
|
||
} catch (err) {
|
||
console.error('User auth bootstrap failed:', err);
|
||
if (isDatabaseConfigured() && process.env.NODE_ENV === 'production') {
|
||
console.error(
|
||
'Fatal: database is configured but user auth bootstrap failed; exiting so launchd can retry',
|
||
);
|
||
process.exit(1);
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
const userAuthReady = bootstrapUserAuth();
|
||
|
||
function legacySessionToken(req) {
|
||
return parseCookies(req.get('cookie'))[AUTH_COOKIE];
|
||
}
|
||
|
||
function userToken(req) {
|
||
return parseCookies(req.get('cookie'))[USER_COOKIE];
|
||
}
|
||
|
||
function setUserLoginCookies(res, req, token) {
|
||
res.set(
|
||
'Set-Cookie',
|
||
userLoginCookies(token, isSecureRequest(req), resolveCookieDomainForRequest(req)),
|
||
);
|
||
}
|
||
|
||
function clearUserLoginCookies(res, req) {
|
||
res.set(
|
||
'Set-Cookie',
|
||
clearUserSessionCookie(isSecureRequest(req), resolveCookieDomainForRequest(req)),
|
||
);
|
||
}
|
||
|
||
async function attachUserSession(req, _res, next) {
|
||
if (!userAuth) return next();
|
||
const token = userToken(req);
|
||
req.userToken = token;
|
||
try {
|
||
req.userSession = token ? await userAuth.verify(token) : null;
|
||
req.userSessionError = null;
|
||
} catch (err) {
|
||
req.userSession = null;
|
||
req.userSessionError = err;
|
||
console.error('[Auth] session verify failed:', err instanceof Error ? err.message : err);
|
||
}
|
||
next();
|
||
}
|
||
|
||
app.use(attachUserSession);
|
||
|
||
// ============ Legacy password auth ============
|
||
|
||
app.get('/auth/status', async (req, res) => {
|
||
await userAuthReady;
|
||
if (userAuth) {
|
||
try {
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.json({ authenticated: false, mode: 'user' });
|
||
const row = await userAuth.getUserById(me.id);
|
||
const capabilityState = await userAuth.resolveUserCapabilities(row);
|
||
return res.json({
|
||
authenticated: true,
|
||
user: me,
|
||
mode: 'user',
|
||
capabilities: capabilityState.capabilities,
|
||
grantedSkills: capabilityState.grantedSkills ?? [],
|
||
unrestricted: capabilityState.unrestricted,
|
||
});
|
||
} catch (err) {
|
||
console.error('[Auth] status failed:', err instanceof Error ? err.message : err);
|
||
return res.status(503).json({
|
||
authenticated: false,
|
||
mode: 'unavailable',
|
||
message: '用户认证服务不可用,请稍后重试',
|
||
});
|
||
}
|
||
}
|
||
if (isDatabaseConfigured()) {
|
||
return res.status(503).json({
|
||
authenticated: false,
|
||
mode: 'unavailable',
|
||
message: '用户认证服务不可用,请稍后重试',
|
||
});
|
||
}
|
||
if (legacyAuth) {
|
||
return res.json({
|
||
authenticated: legacyAuth.verify(legacySessionToken(req)),
|
||
mode: 'legacy',
|
||
});
|
||
}
|
||
return res.json({ authenticated: false, mode: 'none' });
|
||
});
|
||
|
||
app.post('/auth/login', jsonBody, async (req, res) => {
|
||
await userAuthReady;
|
||
const secure = isSecureRequest(req);
|
||
|
||
if (userAuth) {
|
||
const { username, password } = req.body ?? {};
|
||
if (!username || !password) {
|
||
return res.status(400).json({ message: '用户名和密码不能为空' });
|
||
}
|
||
const result = await userAuth.login({ username, password, ip: req.ip });
|
||
if (!result.ok) {
|
||
if (result.retryAfterMs > 0) {
|
||
res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
|
||
return res.status(429).json({ message: result.message });
|
||
}
|
||
return res.status(401).json({ message: result.message });
|
||
}
|
||
setUserLoginCookies(res, req, result.token);
|
||
return res.json({ authenticated: true, user: result.user, mode: 'user' });
|
||
}
|
||
|
||
if (!legacyAuth) {
|
||
return res.status(503).json({ message: '未配置用户数据库或访问密码' });
|
||
}
|
||
|
||
const password = typeof req.body?.password === 'string' ? req.body.password : '';
|
||
const result = legacyAuth.login(password, req.ip);
|
||
if (!result.ok) {
|
||
if (result.retryAfterMs > 0) {
|
||
res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
|
||
return res.status(429).json({ message: '尝试次数过多,请稍后再试' });
|
||
}
|
||
return res.status(401).json({ message: '密码错误,请重试' });
|
||
}
|
||
res.set('Set-Cookie', sessionCookie(result.token, secure));
|
||
return res.json({ authenticated: true, mode: 'legacy' });
|
||
});
|
||
|
||
app.post('/auth/register', jsonBody, async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth) {
|
||
return res.status(503).json({ message: '未启用用户注册' });
|
||
}
|
||
const { username, password, displayName, email } = req.body ?? {};
|
||
const result = await userAuth.register({ username, password, displayName, email });
|
||
if (!result.ok) {
|
||
const status = result.message.includes('已存在') ? 409 : 400;
|
||
return res.status(status).json({ message: result.message });
|
||
}
|
||
if (plazaSeo && req.body?.utm_source) {
|
||
void plazaSeo
|
||
.recordAttribution(
|
||
{
|
||
event_type: 'signup',
|
||
utm_source: req.body.utm_source,
|
||
utm_medium: req.body.utm_medium,
|
||
utm_campaign: req.body.utm_campaign,
|
||
ref_id: req.body.ref ?? req.body.ref_id,
|
||
user_id: result.user?.id ?? null,
|
||
},
|
||
plazaClientIp(req),
|
||
)
|
||
.catch(() => {});
|
||
}
|
||
return res.json({ ok: true, user: result.user });
|
||
});
|
||
|
||
app.post('/auth/reset-password', jsonBody, async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth) {
|
||
return res.status(503).json({ message: '未启用用户系统' });
|
||
}
|
||
const { username, email, password } = req.body ?? {};
|
||
const result = await userAuth.resetPassword({ username, email, password });
|
||
if (!result.ok) {
|
||
return res.status(400).json({ message: result.message });
|
||
}
|
||
return res.json({ ok: true });
|
||
});
|
||
|
||
app.get('/auth/wechat/config', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!wechatOAuthService?.enabled) {
|
||
return res.json({
|
||
enabled: false,
|
||
inWechat: isWechatUserAgent(req.get('user-agent') || ''),
|
||
scanEnabled: false,
|
||
});
|
||
}
|
||
return res.json(wechatOAuthService.publicConfig(req));
|
||
});
|
||
|
||
app.get('/auth/wechat/js-sdk-signature', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !wechatMpService?.enabled) {
|
||
return res.status(503).json({ message: '微信 JS-SDK 未启用' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
|
||
const pageUrl = String(req.query?.url ?? '').split('#')[0];
|
||
if (!pageUrl) {
|
||
return res.status(400).json({ message: '缺少 url' });
|
||
}
|
||
try {
|
||
const normalizedUrl = validateWechatShareSignatureUrl(pageUrl, {
|
||
publicBaseUrl: WECHAT_MP_CONFIG.publicBaseUrl,
|
||
requestHost: req.get('x-forwarded-host') || req.get('host') || '',
|
||
});
|
||
const payload = await wechatMpService.createJsSdkSignature(normalizedUrl);
|
||
return res.json(payload);
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : '微信 JS-SDK 签名失败';
|
||
console.warn('WeChat JS-SDK signature failed:', message);
|
||
return res.status(502).json({ message });
|
||
}
|
||
});
|
||
|
||
app.get('/auth/wechat/public-js-sdk-signature', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!wechatMpService?.enabled) {
|
||
return res.status(503).json({ message: '微信 JS-SDK 未启用' });
|
||
}
|
||
const pageUrl = String(req.query?.url ?? '').split('#')[0];
|
||
if (!pageUrl) {
|
||
return res.status(400).json({ message: '缺少 url' });
|
||
}
|
||
try {
|
||
const normalizedUrl = validateWechatShareSignatureUrl(pageUrl, {
|
||
publicBaseUrl: WECHAT_MP_CONFIG.publicBaseUrl,
|
||
requestHost: req.get('x-forwarded-host') || req.get('host') || '',
|
||
});
|
||
const payload = await wechatMpService.createJsSdkSignature(normalizedUrl);
|
||
return res.json(payload);
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : '微信 JS-SDK 签名失败';
|
||
console.warn('WeChat public JS-SDK signature failed:', message);
|
||
return res.status(502).json({ message });
|
||
}
|
||
});
|
||
|
||
app.get('/auth/wechat/status', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !wechatOAuthService?.enabled) {
|
||
return res.json({ enabled: false, bound: false });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const config = loadWechatOAuthConfig();
|
||
const status = await userAuth.getWechatBindingStatus(me.id, config.appId);
|
||
return res.json({ enabled: true, ...status });
|
||
});
|
||
|
||
if (WECHAT_MP_CONFIG.enabled) {
|
||
app.get('/auth/wechat/agent-route', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !wechatMpService?.enabled) {
|
||
return res.status(503).json({ message: '公众号 Agent 未启用' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
return res.json(await wechatMpService.getRouteStatusForUser(me.id));
|
||
});
|
||
|
||
app.post('/auth/wechat/agent-route/reset', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !wechatMpService?.enabled) {
|
||
return res.status(503).json({ message: '公众号 Agent 未启用' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
try {
|
||
const result = await wechatMpService.recreateRouteForUser(me.id);
|
||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||
return res.json(result);
|
||
} catch (err) {
|
||
return res.status(500).json({
|
||
message: err instanceof Error ? err.message : '重建公众号 Agent 路由失败',
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
app.get('/auth/wechat/pending/:token', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
|
||
const pending = await userAuth.getWechatPendingBind(req.params.token);
|
||
if (!pending) return res.status(404).json({ message: '绑定会话已过期,请重新微信登录' });
|
||
return res.json({
|
||
nickname: pending.nickname,
|
||
avatarUrl: pending.avatar_url,
|
||
returnTo: pending.return_to || '/',
|
||
});
|
||
});
|
||
|
||
app.post('/auth/wechat/register', jsonBody, async (req, res) => {
|
||
await userAuthReady;
|
||
const secure = isSecureRequest(req);
|
||
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
|
||
const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : '';
|
||
if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' });
|
||
const result = await userAuth.completeWechatRegister({ pendingToken });
|
||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||
if (result.isNewUser && plazaSeo) {
|
||
void plazaSeo
|
||
.recordAttribution(
|
||
{
|
||
event_type: 'signup',
|
||
utm_source: result.utmSource || 'wechat',
|
||
utm_medium: result.utmMedium,
|
||
utm_campaign: result.utmCampaign,
|
||
user_id: result.user?.id ?? null,
|
||
},
|
||
plazaClientIp(req),
|
||
)
|
||
.catch(() => {});
|
||
}
|
||
setUserLoginCookies(res, req, result.token);
|
||
return res.json({
|
||
authenticated: true,
|
||
user: result.user,
|
||
returnTo: result.returnTo || '/',
|
||
});
|
||
});
|
||
|
||
app.post('/auth/wechat/bind', jsonBody, async (req, res) => {
|
||
await userAuthReady;
|
||
const secure = isSecureRequest(req);
|
||
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
|
||
const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : '';
|
||
const username = typeof req.body?.username === 'string' ? req.body.username : '';
|
||
const password = typeof req.body?.password === 'string' ? req.body.password : '';
|
||
if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' });
|
||
if (!username || !password) {
|
||
return res.status(400).json({ message: '用户名和密码不能为空' });
|
||
}
|
||
const result = await userAuth.completeWechatBindAccount({
|
||
pendingToken,
|
||
username,
|
||
password,
|
||
ip: req.ip,
|
||
});
|
||
if (!result.ok) {
|
||
const status = result.retryAfterMs > 0 ? 429 : 401;
|
||
if (result.retryAfterMs > 0) {
|
||
res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
|
||
}
|
||
return res.status(status).json({ message: result.message });
|
||
}
|
||
setUserLoginCookies(res, req, result.token);
|
||
return res.json({
|
||
authenticated: true,
|
||
user: result.user,
|
||
bound: true,
|
||
returnTo: result.returnTo || '/',
|
||
});
|
||
});
|
||
|
||
app.post('/auth/wechat/scan/start', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!wechatOAuthService?.enabled) {
|
||
return res.status(503).json({ message: '微信登录未启用' });
|
||
}
|
||
try {
|
||
const payload = await wechatOAuthService.startScanLogin(req);
|
||
return res.json(payload);
|
||
} catch (err) {
|
||
return res.status(503).json({
|
||
message: err instanceof Error ? err.message : '微信扫码登录不可用',
|
||
});
|
||
}
|
||
});
|
||
|
||
app.get('/auth/wechat/scan/poll', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!wechatOAuthService?.enabled) {
|
||
return res.status(503).json({ message: '微信登录未启用' });
|
||
}
|
||
const state = typeof req.query?.state === 'string' ? req.query.state : '';
|
||
if (!state) return res.status(400).json({ message: '缺少扫码状态' });
|
||
const result = await wechatOAuthService.pollScanLogin(state);
|
||
if (result.status === 'complete' && result.token) {
|
||
setUserLoginCookies(res, req, result.token);
|
||
}
|
||
return res.json(result);
|
||
});
|
||
|
||
app.get('/auth/wechat/authorize', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!wechatOAuthService?.enabled) {
|
||
return res.status(503).json({ message: '微信登录未启用' });
|
||
}
|
||
try {
|
||
let bindUserId = null;
|
||
const intent = typeof req.query?.intent === 'string' ? req.query.intent.trim().toLowerCase() : 'login';
|
||
if (intent === 'bind' && userAuth) {
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '请先登录后再绑定微信' });
|
||
bindUserId = me.id;
|
||
}
|
||
const redirectUrl = await wechatOAuthService.buildAuthorizeRedirect(req, { bindUserId });
|
||
return res.redirect(302, redirectUrl);
|
||
} catch (err) {
|
||
console.error('WeChat authorize failed:', err);
|
||
return res.status(500).json({ message: err instanceof Error ? err.message : '微信授权失败' });
|
||
}
|
||
});
|
||
|
||
app.get('/auth/wechat/callback', async (req, res) => {
|
||
await userAuthReady;
|
||
const secure = isSecureRequest(req);
|
||
if (!wechatOAuthService?.enabled || !userAuth) {
|
||
return res.redirect(302, '/?wechat_error=unavailable');
|
||
}
|
||
try {
|
||
const result = await wechatOAuthService.handleCallback({
|
||
code: typeof req.query?.code === 'string' ? req.query.code : '',
|
||
state: typeof req.query?.state === 'string' ? req.query.state : '',
|
||
ip: req.ip,
|
||
});
|
||
|
||
if (result.action === 'binding_gate') {
|
||
const params = new URLSearchParams();
|
||
params.set('wechat_pending', result.pendingToken);
|
||
if (result.returnTo && result.returnTo !== '/') {
|
||
params.set('return_to', result.returnTo);
|
||
}
|
||
return res.redirect(302, `/?${params.toString()}`);
|
||
}
|
||
|
||
if (result.action === 'poll_error') {
|
||
return res.redirect(
|
||
302,
|
||
`/?wechat_error=${encodeURIComponent(result.message || '微信登录失败')}`,
|
||
);
|
||
}
|
||
|
||
if (result.isNewUser && plazaSeo) {
|
||
void plazaSeo
|
||
.recordAttribution(
|
||
{
|
||
event_type: 'signup',
|
||
utm_source: result.utmSource || 'wechat',
|
||
utm_medium: result.utmMedium,
|
||
utm_campaign: result.utmCampaign,
|
||
user_id: result.user?.id ?? null,
|
||
},
|
||
plazaClientIp(req),
|
||
)
|
||
.catch(() => {});
|
||
}
|
||
|
||
if (result.authMode === 'open' || result.authMode === 'scan') {
|
||
return res.send(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>微信登录</title></head><body><p>扫码登录成功,请返回电脑继续操作。</p></body></html>`);
|
||
}
|
||
|
||
setUserLoginCookies(res, req, result.token);
|
||
return res.redirect(302, result.returnTo || '/');
|
||
} catch (err) {
|
||
console.error('WeChat callback failed:', err);
|
||
const message = encodeURIComponent(err instanceof Error ? err.message : '微信登录失败');
|
||
return res.redirect(302, `/?wechat_error=${message}`);
|
||
}
|
||
});
|
||
|
||
app.get('/auth/me', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const [paths, capabilityState, subscription] = await Promise.all([
|
||
userAuth.listPathGrants(me.id),
|
||
userAuth.resolveUserCapabilities(await userAuth.getUserById(me.id)),
|
||
subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null,
|
||
]);
|
||
return res.json({
|
||
user: { ...me, subscription },
|
||
paths,
|
||
capabilities: capabilityState.capabilities,
|
||
grantedSkills: capabilityState.grantedSkills ?? [],
|
||
unrestricted: capabilityState.unrestricted,
|
||
});
|
||
});
|
||
|
||
app.post('/auth/logout', async (req, res) => {
|
||
await userAuthReady;
|
||
const secure = isSecureRequest(req);
|
||
if (userAuth) {
|
||
await userAuth.revoke(userToken(req));
|
||
clearUserLoginCookies(res, req);
|
||
}
|
||
if (legacyAuth) {
|
||
legacyAuth.revoke(legacySessionToken(req));
|
||
res.set('Set-Cookie', clearSessionCookie(secure));
|
||
}
|
||
res.status(204).end();
|
||
});
|
||
|
||
|
||
app.get('/auth/usage', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const records = await userAuth.listUsageRecords({ userId: me.id, limit: 30 });
|
||
res.json({ records });
|
||
});
|
||
|
||
app.post('/auth/feedback', jsonBody, async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !feedbackService) {
|
||
return res.status(503).json({ message: '反馈服务未启用' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
try {
|
||
const result = await feedbackService.submit(me.id, {
|
||
type: req.body?.type,
|
||
title: req.body?.title,
|
||
description: req.body?.description,
|
||
contact: req.body?.contact,
|
||
images: req.body?.images,
|
||
context: req.body?.context,
|
||
});
|
||
res.status(201).json({ feedback: result });
|
||
} catch (err) {
|
||
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : '';
|
||
if (code === 'invalid_input') {
|
||
return res.status(400).json({ message: err instanceof Error ? err.message : '提交内容无效' });
|
||
}
|
||
console.warn('Submit feedback failed:', err instanceof Error ? err.message : err);
|
||
return res.status(500).json({ message: '反馈提交失败,请稍后重试' });
|
||
}
|
||
});
|
||
|
||
app.get('/auth/feedback', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !feedbackService) {
|
||
return res.status(503).json({ message: '反馈服务未启用' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const limit = Math.min(Math.max(Number(req.query?.limit) || 20, 1), 50);
|
||
const items = await feedbackService.listForUser(me.id, { limit });
|
||
res.json({ items });
|
||
});
|
||
|
||
app.get('/auth/feedback/board', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !feedbackService) {
|
||
return res.status(503).json({ message: '反馈服务未启用' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const page = Math.max(Number(req.query?.page) || 1, 1);
|
||
const limit = Math.min(Math.max(Number(req.query?.limit) || 10, 1), 10);
|
||
try {
|
||
const result = await feedbackService.listAll({ page, limit });
|
||
res.json(result);
|
||
} catch (err) {
|
||
console.warn('List feedback board failed:', err instanceof Error ? err.message : err);
|
||
return res.status(500).json({ message: '反馈列表加载失败' });
|
||
}
|
||
});
|
||
|
||
app.get('/auth/feedback/:feedbackId', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !feedbackService) {
|
||
return res.status(503).json({ message: '反馈服务未启用' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
try {
|
||
const item = await feedbackService.getById(req.params.feedbackId, me.id);
|
||
res.json({ item, isMine: item.userId === me.id });
|
||
} catch (err) {
|
||
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : '';
|
||
if (code === 'feedback_not_found') {
|
||
return res.status(404).json({ message: err instanceof Error ? err.message : '反馈不存在' });
|
||
}
|
||
console.warn('Get feedback detail failed:', err instanceof Error ? err.message : err);
|
||
return res.status(500).json({ message: '反馈详情加载失败' });
|
||
}
|
||
});
|
||
|
||
app.get('/auth/notifications', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !scheduleService) {
|
||
return res.status(503).json({ message: '通知服务未启用' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const rawStatus = typeof req.query?.status === 'string' ? req.query.status : 'unread';
|
||
const status = ['all', 'unread', 'read'].includes(rawStatus) ? rawStatus : 'all';
|
||
const limit = Math.min(Math.max(Number(req.query?.limit) || 20, 1), 100);
|
||
try {
|
||
const notifications = await scheduleService.listUserNotifications({ userId: me.id, status, limit });
|
||
res.json({ notifications });
|
||
} catch (err) {
|
||
console.warn('List user notifications failed:', err instanceof Error ? err.message : err);
|
||
res.status(500).json({ message: '通知列表加载失败' });
|
||
}
|
||
});
|
||
|
||
app.get('/auth/notifications/events', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !scheduleService) {
|
||
return res.status(503).json({ message: '通知服务未启用' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
|
||
res.status(200);
|
||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||
res.setHeader('Connection', 'keep-alive');
|
||
res.flushHeaders?.();
|
||
|
||
let closed = false;
|
||
let lastNotificationId = null;
|
||
|
||
const sendEvent = (event, data) => {
|
||
if (closed || res.destroyed) return;
|
||
res.write(`event: ${event}\n`);
|
||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||
};
|
||
|
||
const checkUnread = async () => {
|
||
if (closed) return;
|
||
try {
|
||
const notifications = await scheduleService.listUserNotifications({
|
||
userId: me.id,
|
||
status: 'unread',
|
||
limit: 1,
|
||
});
|
||
const latest = notifications[0] ?? null;
|
||
const nextId = latest?.id ?? null;
|
||
if (nextId && nextId !== lastNotificationId) {
|
||
lastNotificationId = nextId;
|
||
sendEvent('notification', { notification: latest });
|
||
} else if (!nextId) {
|
||
lastNotificationId = null;
|
||
}
|
||
} catch {
|
||
sendEvent('sync', { reason: 'check_failed' });
|
||
}
|
||
};
|
||
|
||
sendEvent('ready', { ok: true });
|
||
await checkUnread();
|
||
|
||
const checkTimer = setInterval(() => {
|
||
void checkUnread();
|
||
}, 2500);
|
||
const keepaliveTimer = setInterval(() => {
|
||
sendEvent('ping', { at: Date.now() });
|
||
}, 25000);
|
||
|
||
req.on('close', () => {
|
||
closed = true;
|
||
clearInterval(checkTimer);
|
||
clearInterval(keepaliveTimer);
|
||
});
|
||
});
|
||
|
||
app.post('/auth/notifications/:id/read', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !scheduleService) {
|
||
return res.status(503).json({ message: '通知服务未启用' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const ok = await scheduleService.markUserNotificationRead({
|
||
userId: me.id,
|
||
notificationId: req.params.id,
|
||
});
|
||
if (!ok) return res.status(404).json({ message: '通知不存在或已读' });
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.post('/auth/notifications/read-all', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !scheduleService) {
|
||
return res.status(503).json({ message: '通知服务未启用' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const updated = await scheduleService.markAllUserNotificationsRead({ userId: me.id });
|
||
res.json({ ok: true, updated });
|
||
});
|
||
|
||
app.delete('/auth/notifications/:id', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !scheduleService) {
|
||
return res.status(503).json({ message: '通知服务未启用' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const ok = await scheduleService.deleteUserNotification({
|
||
userId: me.id,
|
||
notificationId: req.params.id,
|
||
});
|
||
if (!ok) return res.status(404).json({ message: '通知不存在' });
|
||
res.status(204).end();
|
||
});
|
||
|
||
app.delete('/auth/notifications', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !scheduleService) {
|
||
return res.status(503).json({ message: '通知服务未启用' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const status = typeof req.query?.status === 'string' ? req.query.status : 'all';
|
||
const deleted = await scheduleService.clearUserNotifications({ userId: me.id, status });
|
||
res.json({ ok: true, deleted });
|
||
});
|
||
|
||
app.get('/auth/billing/ledger', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const limit = Math.min(Math.max(Number(req.query?.limit) || 30, 1), 100);
|
||
const entries = await userAuth.listBillingLedger({
|
||
userId: me.id,
|
||
limit,
|
||
types: ['recharge', 'adjust', 'refund'],
|
||
});
|
||
res.json({ entries });
|
||
});
|
||
|
||
app.get('/auth/billing/config', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !rechargeService) {
|
||
return res.status(503).json({ message: '未启用计费系统' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const [config, sub] = await Promise.all([
|
||
rechargeService.getBillingConfig(me.id),
|
||
subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null,
|
||
]);
|
||
return res.json({ ...config, subscription: sub });
|
||
});
|
||
|
||
app.get('/auth/billing/subscription', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const sub = subscriptionService
|
||
? await subscriptionService.getActiveSubscription(me.id)
|
||
: null;
|
||
const planCatalog = subscriptionService?._planCatalogService;
|
||
const plans = (!sub && planCatalog)
|
||
? await planCatalog.listPlans({ includeInactive: false })
|
||
: sub ? undefined : PLAN_CATALOG;
|
||
return res.json({ subscription: sub, plans });
|
||
});
|
||
|
||
app.get('/auth/billing/plans', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const planCatalog = subscriptionService?._planCatalogService;
|
||
const plans = planCatalog
|
||
? (await planCatalog.listPlans({ includeInactive: false }))
|
||
.filter((p) => p.priceCents > 0)
|
||
.map((p) => ({ key: p.planType, ...p }))
|
||
: Object.entries(PLAN_CATALOG)
|
||
.filter(([, plan]) => plan.priceCents > 0)
|
||
.map(([key, plan]) => ({ key, ...plan }));
|
||
const [sub, wallet] = await Promise.all([
|
||
subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null,
|
||
userAuth.getUserById(me.id),
|
||
]);
|
||
return res.json({
|
||
plans,
|
||
subscription: sub,
|
||
balanceCents: wallet ? Number(wallet.balance_cents ?? 0) : 0,
|
||
});
|
||
});
|
||
|
||
app.post('/auth/billing/subscribe', jsonBody, async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !subscriptionService) {
|
||
return res.status(503).json({ message: '未启用订阅系统' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
if (me.status === 'disabled') return res.status(403).json({ message: '账户已禁用' });
|
||
const { planType, autoRenew = false } = req.body ?? {};
|
||
if (!planType || typeof planType !== 'string') {
|
||
return res.status(400).json({ message: '请选择套餐' });
|
||
}
|
||
const result = await subscriptionService.purchaseSubscription(me.id, planType, Boolean(autoRenew));
|
||
if (!result.ok) {
|
||
if (result.code === 'INSUFFICIENT_BALANCE') {
|
||
return res.status(402).json({
|
||
message: result.message,
|
||
code: result.code,
|
||
balanceCents: result.balanceCents,
|
||
requiredCents: result.requiredCents,
|
||
shortfallCents: result.shortfallCents,
|
||
});
|
||
}
|
||
if (result.code === 'DOWNGRADE_NOT_ALLOWED') {
|
||
return res.status(409).json({
|
||
message: result.message,
|
||
code: result.code,
|
||
currentPlanType: result.currentPlanType,
|
||
});
|
||
}
|
||
return res.status(400).json({ message: result.message });
|
||
}
|
||
return res.json({ subscription: result.subscription, balanceCents: result.balanceCents });
|
||
});
|
||
|
||
app.post('/auth/billing/space-purchase', jsonBody, async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !mindSpace) {
|
||
return res.status(503).json({ message: '未启用空间系统' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const sizeMb = Number(req.body?.sizeMb);
|
||
const result = await userAuth.purchaseSpaceQuota(me.id, sizeMb);
|
||
if (!result.ok) {
|
||
if (result.code === 'INSUFFICIENT_BALANCE') {
|
||
return res.status(402).json({
|
||
message: result.message,
|
||
code: result.code,
|
||
details: {
|
||
code: 'INSUFFICIENT_BALANCE',
|
||
balanceCents: result.balanceCents,
|
||
minRechargeCents: result.minRechargeCents,
|
||
suggestedTiers: result.suggestedTiers,
|
||
},
|
||
});
|
||
}
|
||
return res.status(400).json({ message: result.message });
|
||
}
|
||
const quota = await mindSpace.getQuota(me.id);
|
||
return res.json({
|
||
quota: quota ?? result.quota,
|
||
balanceCents: result.balanceCents,
|
||
purchasedMb: sizeMb,
|
||
costCents: sizeMb * 200,
|
||
});
|
||
});
|
||
|
||
app.post('/auth/billing/auto-renew', jsonBody, async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !subscriptionService) {
|
||
return res.status(503).json({ message: '未启用订阅系统' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const { enabled } = req.body ?? {};
|
||
if (typeof enabled !== 'boolean') {
|
||
return res.status(400).json({ message: '请传入 enabled: true/false' });
|
||
}
|
||
const result = await subscriptionService.setAutoRenew(me.id, enabled);
|
||
return res.json(result);
|
||
});
|
||
|
||
app.post('/auth/billing/recharge-orders', jsonBody, async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !rechargeService) {
|
||
return res.status(503).json({ message: '未启用计费系统' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const amountCents = Number(req.body?.amountCents);
|
||
const payScene = ['native', 'h5', 'jsapi'].includes(req.body?.payScene)
|
||
? req.body.payScene
|
||
: 'native';
|
||
const result = await rechargeService.createOrder({
|
||
userId: me.id,
|
||
amountCents,
|
||
payScene,
|
||
clientIp: req.ip,
|
||
});
|
||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||
return res.status(201).json({ order: result.order });
|
||
});
|
||
|
||
app.get('/auth/billing/recharge-orders/:orderId', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !rechargeService) {
|
||
return res.status(503).json({ message: '未启用计费系统' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const order = await rechargeService.getOrderForUser(me.id, req.params.orderId);
|
||
if (!order) return res.status(404).json({ message: '订单不存在' });
|
||
let balanceCents = null;
|
||
if (order.status === 'paid') {
|
||
const user = await userAuth.getUserById(me.id);
|
||
balanceCents = user ? Number(user.balance_cents ?? 0) : null;
|
||
}
|
||
return res.json({ order, balanceCents });
|
||
});
|
||
|
||
const wechatNotifyBody = express.raw({
|
||
type: ['application/json', 'text/xml', 'application/xml'],
|
||
limit: '64kb',
|
||
});
|
||
const wechatMpBody = express.text({
|
||
type: ['text/xml', 'application/xml'],
|
||
limit: '128kb',
|
||
});
|
||
app.post('/webhooks/wechat-pay/notify', wechatNotifyBody, async (req, res) => {
|
||
await userAuthReady;
|
||
const isV2 = wechatPayClient?.apiVersion === 'v2';
|
||
if (!rechargeService || !wechatPayClient?.enabled) {
|
||
if (isV2) {
|
||
return res
|
||
.status(503)
|
||
.type('text/xml')
|
||
.send(
|
||
'<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[支付未启用]]></return_msg></xml>',
|
||
);
|
||
}
|
||
return res.status(503).json({ code: 'FAIL', message: '支付未启用' });
|
||
}
|
||
try {
|
||
const bodyText = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : String(req.body ?? '');
|
||
await rechargeService.handleWechatNotify({ headers: req.headers, body: bodyText });
|
||
if (isV2) {
|
||
return res.type('text/xml').send(WECHAT_NOTIFY_SUCCESS_V2);
|
||
}
|
||
return res.json({ code: 'SUCCESS', message: '成功' });
|
||
} catch (err) {
|
||
console.error('WeChat notify failed:', err);
|
||
const message = err instanceof Error ? err.message : '处理失败';
|
||
if (isV2) {
|
||
return res
|
||
.status(500)
|
||
.type('text/xml')
|
||
.send(
|
||
`<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[${message}]]></return_msg></xml>`,
|
||
);
|
||
}
|
||
return res.status(500).json({ code: 'FAIL', message });
|
||
}
|
||
});
|
||
|
||
if (WECHAT_MP_CONFIG.enabled) {
|
||
app.get('/webhooks/wechat-mp/messages', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!wechatMpService?.enabled) {
|
||
return res.status(503).send('wechat mp disabled');
|
||
}
|
||
const result = wechatMpService.verifyUrlChallenge(req.query);
|
||
if (!result.ok) {
|
||
console.warn('WeChat MP verify failed:', {
|
||
encryptType: req.query.encrypt_type ?? null,
|
||
timestamp: req.query.timestamp ?? null,
|
||
nonce: req.query.nonce ?? null,
|
||
});
|
||
return res.status(result.status ?? 403).send(result.body ?? 'invalid signature');
|
||
}
|
||
console.log('WeChat MP verify ok:', {
|
||
encryptType: req.query.encrypt_type ?? null,
|
||
timestamp: req.query.timestamp ?? null,
|
||
nonce: req.query.nonce ?? null,
|
||
});
|
||
return res.type('text/plain').send(String(result.body ?? ''));
|
||
});
|
||
|
||
app.post('/webhooks/wechat-mp/messages', wechatMpBody, async (req, res) => {
|
||
await userAuthReady;
|
||
if (!wechatMpService?.enabled) {
|
||
return res.status(503).send('wechat mp disabled');
|
||
}
|
||
try {
|
||
const bodyText = String(req.body ?? '');
|
||
const fromUser = bodyText.match(/<FromUserName><!\[CDATA\[([\s\S]*?)\]\]><\/FromUserName>/)?.[1] ?? null;
|
||
const msgType = bodyText.match(/<MsgType><!\[CDATA\[([\s\S]*?)\]\]><\/MsgType>/)?.[1] ?? null;
|
||
const content = bodyText.match(/<Content><!\[CDATA\[([\s\S]*?)\]\]><\/Content>/)?.[1] ?? null;
|
||
console.log('WeChat MP message received:', {
|
||
at: new Date().toISOString(),
|
||
fromUser: fromUser ? `${fromUser.slice(0, 8)}...` : null,
|
||
msgType,
|
||
contentPreview: content ? `${String(content).slice(0, 24)}` : null,
|
||
});
|
||
const result = await wechatMpService.handleInboundMessage(req.body, req.query);
|
||
if (result.task) void result.task;
|
||
if (result.contentType) res.type(result.contentType);
|
||
return res.status(result.status ?? 200).send(result.body ?? 'success');
|
||
} catch (err) {
|
||
console.error('WeChat MP message failed:', err);
|
||
return res.status(500).send('internal error');
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
// ============ Wiki API ============
|
||
|
||
function wikiCookie(token, secure) {
|
||
const parts = [
|
||
`${wikiAuth.COOKIE_NAME}=${encodeURIComponent(token)}`,
|
||
'Path=/',
|
||
'HttpOnly',
|
||
'SameSite=Lax',
|
||
'Max-Age=604800',
|
||
];
|
||
if (secure) parts.push('Secure');
|
||
return parts.join('; ');
|
||
}
|
||
|
||
function clearWikiCookie(secure) {
|
||
const parts = [`${wikiAuth.COOKIE_NAME}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
|
||
if (secure) parts.push('Secure');
|
||
return parts.join('; ');
|
||
}
|
||
|
||
const wikiApi = express.Router();
|
||
wikiApi.use(jsonBody);
|
||
|
||
wikiApi.post('/auth/register', (req, res) => {
|
||
const { username, password, displayName } = req.body || {};
|
||
if (!username || !password) {
|
||
return res.status(400).json({ message: '用户名和密码不能为空' });
|
||
}
|
||
const result = wikiAuth.register(username, password, displayName);
|
||
if (!result.ok) return res.status(409).json({ message: result.message });
|
||
return res.json({ ok: true, user: result.user });
|
||
});
|
||
|
||
wikiApi.post('/auth/login', (req, res) => {
|
||
const { username, password } = req.body || {};
|
||
if (!username || !password) {
|
||
return res.status(400).json({ message: '用户名和密码不能为空' });
|
||
}
|
||
const result = wikiAuth.login(username, password);
|
||
if (!result.ok) return res.status(401).json({ message: result.message });
|
||
const secure = isSecureRequest(req);
|
||
res.set('Set-Cookie', wikiCookie(result.token, secure));
|
||
return res.json({ ok: true, user: result.user });
|
||
});
|
||
|
||
wikiApi.post('/auth/logout', (req, res) => {
|
||
const cookies = parseCookies(req.get('cookie'));
|
||
wikiAuth.revoke(cookies[wikiAuth.COOKIE_NAME]);
|
||
res.set('Set-Cookie', clearWikiCookie(isSecureRequest(req)));
|
||
return res.json({ ok: true });
|
||
});
|
||
|
||
wikiApi.get('/auth/me', (req, res) => {
|
||
const cookies = parseCookies(req.get('cookie'));
|
||
const session = wikiAuth.verify(cookies[wikiAuth.COOKIE_NAME]);
|
||
if (!session) return res.json({ authenticated: false, user: null });
|
||
const user = wikiAuth.getUser(session.username);
|
||
return res.json({ authenticated: true, user });
|
||
});
|
||
|
||
function requireWikiAuth(req, res, next) {
|
||
const cookies = parseCookies(req.get('cookie'));
|
||
const session = wikiAuth.verify(cookies[wikiAuth.COOKIE_NAME]);
|
||
if (!session) return res.status(401).json({ message: '未登录' });
|
||
req.wikiUser = session;
|
||
next();
|
||
}
|
||
|
||
wikiApi.get('/pages', requireWikiAuth, (req, res) => {
|
||
const { q } = req.query;
|
||
if (q) return res.json(wikiAuth.searchPages(req.wikiUser.username, q));
|
||
res.json(wikiAuth.listPages(req.wikiUser.username));
|
||
});
|
||
|
||
wikiApi.get('/pages/:slug', requireWikiAuth, (req, res) => {
|
||
const page = wikiAuth.getPage(req.wikiUser.username, req.params.slug);
|
||
if (!page) return res.status(404).json({ message: '页面不存在' });
|
||
res.json(page);
|
||
});
|
||
|
||
wikiApi.post('/pages/:slug', requireWikiAuth, (req, res) => {
|
||
const { title, content, tags } = req.body || {};
|
||
const page = wikiAuth.savePage(req.wikiUser.username, req.params.slug, title, content, tags);
|
||
res.json(page);
|
||
});
|
||
|
||
wikiApi.delete('/pages/:slug', requireWikiAuth, (req, res) => {
|
||
wikiAuth.deletePage(req.wikiUser.username, req.params.slug);
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.use('/wiki-api', wikiApi);
|
||
|
||
// ============ TKMind API proxy ============
|
||
|
||
const api = express.Router();
|
||
api.use(jsonUnlessMultipart);
|
||
|
||
api.use(async (req, res, next) => {
|
||
await userAuthReady;
|
||
if (req.path === '/status' || req.path === '/runtime/status') return next();
|
||
if (req.path.startsWith('/internal/agent/')) return next();
|
||
if (req.path === '/agent/mindspace_page_patch') return next();
|
||
if (req.path === '/agent/mindspace_asset_delete') return next();
|
||
if (req.path === '/config/blocked-words') return next();
|
||
if (req.method === 'GET' && /^\/mindspace\/v1\/assets\/[^/]+\/download$/.test(req.path)) {
|
||
return next();
|
||
}
|
||
|
||
const plazaPublic = isPlazaPublicRead(req.path, req.method);
|
||
|
||
if (userAuth && tkmindProxy) {
|
||
if (req.userSessionError) {
|
||
if (plazaPublic) return next();
|
||
return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' });
|
||
}
|
||
try {
|
||
if (req.userSession) {
|
||
const me = await userAuth.getMe(req.userToken);
|
||
if (me) req.currentUser = me;
|
||
}
|
||
if (plazaPublic) return next();
|
||
if (!req.userSession) {
|
||
return res.status(401).json({ message: '未授权,请重新登录' });
|
||
}
|
||
const me = await userAuth.getMe(req.userToken);
|
||
if (!me) return res.status(401).json({ message: '登录已过期' });
|
||
req.currentUser = me;
|
||
return next();
|
||
} catch (err) {
|
||
console.error('[Auth] API auth failed:', err instanceof Error ? err.message : err);
|
||
return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' });
|
||
}
|
||
}
|
||
|
||
if (legacyAuth?.verify(legacySessionToken(req))) return next();
|
||
return res.status(401).json({ message: '未授权,请重新登录' });
|
||
});
|
||
|
||
attachAsrRoutes(api, { sendError, sendData });
|
||
|
||
api.get('/config/blocked-words', async (_req, res) => {
|
||
await userAuthReady;
|
||
if (!wordFilterService) return res.json({ words: [] });
|
||
const words = await wordFilterService.listAllForFrontend();
|
||
res.json({ words });
|
||
});
|
||
|
||
api.get('/status', async (_req, res, next) => {
|
||
await userAuthReady;
|
||
if (userAuth && tkmindProxy) {
|
||
try {
|
||
const upstream = await tkmindProxy.apiFetch('/status', { method: 'GET' });
|
||
const text = await upstream.text();
|
||
return res.status(upstream.status).send(text);
|
||
} catch (err) {
|
||
return res.status(502).json({ message: err instanceof Error ? err.message : '代理失败' });
|
||
}
|
||
}
|
||
return next();
|
||
});
|
||
|
||
function runtimeEnvFlag(value, fallback = false) {
|
||
const raw = String(value ?? '').trim().toLowerCase();
|
||
if (!raw) return fallback;
|
||
return ['1', 'true', 'yes', 'on'].includes(raw);
|
||
}
|
||
|
||
function runtimeCsvList(value) {
|
||
return String(value ?? '')
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function runtimeCodeRunPolicyStatus() {
|
||
return {
|
||
enabled: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED),
|
||
userAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS),
|
||
taskTypeAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES),
|
||
requireValidation: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION),
|
||
};
|
||
}
|
||
|
||
api.get('/runtime/status', async (_req, res) => {
|
||
await userAuthReady;
|
||
if (!tkmindProxy?.getRuntimeStatus) {
|
||
return res.status(503).json({ ok: false, message: 'runtime router unavailable' });
|
||
}
|
||
try {
|
||
const status = await tkmindProxy.getRuntimeStatus();
|
||
const toolQueue = agentRunGateway?.getQueueStatus
|
||
? await agentRunGateway.getQueueStatus().catch((err) => ({
|
||
error: err instanceof Error ? err.message : String(err),
|
||
}))
|
||
: null;
|
||
if (toolQueue) {
|
||
status.toolRuntime = {
|
||
...(status.toolRuntime ?? {}),
|
||
codeRunPolicy: runtimeCodeRunPolicyStatus(),
|
||
queue: toolQueue,
|
||
};
|
||
}
|
||
return res.json({
|
||
ok: true,
|
||
timestamp: new Date().toISOString(),
|
||
...status,
|
||
});
|
||
} catch (err) {
|
||
return res.status(502).json({
|
||
ok: false,
|
||
message: err instanceof Error ? err.message : 'runtime status failed',
|
||
});
|
||
}
|
||
});
|
||
|
||
async function ensureUserMemoryCapability(req, res) {
|
||
if (!userAuth) {
|
||
res.status(503).json({ message: '未启用用户系统' });
|
||
return null;
|
||
}
|
||
const userRow = await userAuth.getUserById(req.currentUser.id);
|
||
if (!userRow) {
|
||
res.status(404).json({ message: '用户不存在' });
|
||
return null;
|
||
}
|
||
const capabilityState = await userAuth.resolveUserCapabilities(userRow);
|
||
if (!capabilityState.unrestricted && !capabilityState.capabilities.memory_store) {
|
||
res.status(403).json({ message: '当前账户未开通长期记忆,无法访问该 API' });
|
||
return null;
|
||
}
|
||
return capabilityState;
|
||
}
|
||
|
||
async function loadUserVisibleConversation(sessionId) {
|
||
const target = await tkmindProxy.resolveTarget(sessionId);
|
||
const upstream = await tkmindProxy.apiFetchTo(target, `/sessions/${encodeURIComponent(sessionId)}`, {
|
||
method: 'GET',
|
||
});
|
||
if (!upstream.ok) {
|
||
const message = await upstream.text().catch(() => '');
|
||
throw new Error(message || '读取会话失败');
|
||
}
|
||
const session = await upstream.json();
|
||
return (session?.conversation ?? []).filter((message) => message?.metadata?.userVisible);
|
||
}
|
||
|
||
async function syncUserMemoriesIntoSession(userId, sessionId) {
|
||
if (!tkmindProxy || !sessionId) return false;
|
||
await tkmindProxy.reconcileSessionPolicyForUser(userId, sessionId);
|
||
return true;
|
||
}
|
||
|
||
api.post('/user-memory/v1/remember-recent', async (req, res) => {
|
||
if (!conversationMemoryService?.isEnabled?.()) {
|
||
return res.status(503).json({ message: '长期记忆功能未启用' });
|
||
}
|
||
if (!tkmindProxy) {
|
||
return res.status(503).json({ message: '会话代理尚未就绪' });
|
||
}
|
||
const capabilityState = await ensureUserMemoryCapability(req, res);
|
||
if (!capabilityState) return;
|
||
|
||
const sessionId = String(req.body?.sessionId ?? '').trim();
|
||
if (!sessionId) {
|
||
return res.status(400).json({ message: '缺少 sessionId' });
|
||
}
|
||
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
|
||
if (!owns) {
|
||
return res.status(403).json({ message: '无权访问该会话' });
|
||
}
|
||
|
||
try {
|
||
const messages = await loadUserVisibleConversation(sessionId);
|
||
const result = await conversationMemoryService.saveAndAnalyze(
|
||
sessionId,
|
||
req.currentUser.id,
|
||
messages,
|
||
);
|
||
const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId);
|
||
const memories = await conversationMemoryService.listMemories(req.currentUser.id, { limit: 200 });
|
||
return res.json({
|
||
ok: true,
|
||
analyzed: result.analyzed ?? 0,
|
||
memories: result.memories ?? 0,
|
||
totalMemories: memories.length,
|
||
syncedToSession,
|
||
});
|
||
} catch (err) {
|
||
return res.status(500).json({ message: err instanceof Error ? err.message : '保存长期记忆失败' });
|
||
}
|
||
});
|
||
|
||
api.post('/user-memory/v1/sync', async (req, res) => {
|
||
if (!conversationMemoryService?.isEnabled?.()) {
|
||
return res.status(503).json({ message: '长期记忆功能未启用' });
|
||
}
|
||
const capabilityState = await ensureUserMemoryCapability(req, res);
|
||
if (!capabilityState) return;
|
||
|
||
const sessionId = String(req.body?.sessionId ?? '').trim();
|
||
if (!sessionId) {
|
||
return res.status(400).json({ message: '缺少 sessionId' });
|
||
}
|
||
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
|
||
if (!owns) {
|
||
return res.status(403).json({ message: '无权访问该会话' });
|
||
}
|
||
|
||
try {
|
||
const result = await conversationMemoryService.analyzeUser(req.currentUser.id);
|
||
const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId);
|
||
const memories = await conversationMemoryService.listMemories(req.currentUser.id, { limit: 200 });
|
||
return res.json({
|
||
ok: true,
|
||
analyzed: result.analyzed ?? 0,
|
||
memories: result.memories ?? 0,
|
||
totalMemories: memories.length,
|
||
syncedToSession,
|
||
});
|
||
} catch (err) {
|
||
return res.status(500).json({ message: err instanceof Error ? err.message : '刷新长期记忆失败' });
|
||
}
|
||
});
|
||
|
||
api.get('/mindspace/v1/space', async (req, res) => {
|
||
if (!mindSpace || !ensureMindSpaceEnabled(res, req)) return;
|
||
const space = await mindSpace.getSpace(req.currentUser.id);
|
||
if (!space) return sendError(res, req, 404, 'resource_not_found', '用户空间不存在');
|
||
return sendData(res, req, space);
|
||
});
|
||
|
||
api.post('/mindspace/v1/schedule/reminders/:reminderId/ignore', async (req, res) => {
|
||
if (!ensureMindSpaceEnabled(res, req) || !scheduleService) {
|
||
return sendError(res, req, 503, 'feature_disabled', '日程服务未启用');
|
||
}
|
||
try {
|
||
const reminder = await scheduleService.cancelReminder({
|
||
userId: req.currentUser.id,
|
||
reminderId: req.params.reminderId,
|
||
});
|
||
return sendData(res, req, reminder);
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : '忽略提醒失败';
|
||
return sendError(res, req, 400, 'invalid_schedule_input', message);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/schedule/reminders/bulk-delete', async (req, res) => {
|
||
if (!ensureMindSpaceEnabled(res, req) || !scheduleService) {
|
||
return sendError(res, req, 503, 'feature_disabled', '日程服务未启用');
|
||
}
|
||
try {
|
||
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(String) : [];
|
||
const deleted = await scheduleService.deleteReminders({
|
||
userId: req.currentUser.id,
|
||
reminderIds: ids,
|
||
});
|
||
return sendData(res, req, { deleted });
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : '删除提醒失败';
|
||
return sendError(res, req, 400, 'invalid_schedule_input', message);
|
||
}
|
||
});
|
||
|
||
api.get('/mindspace/v1/space/quota', async (req, res) => {
|
||
if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
const quota = await mindSpace.getQuota(req.currentUser.id);
|
||
if (!quota) return res.status(404).json({ message: '用户空间不存在' });
|
||
return res.json({ data: quota });
|
||
});
|
||
|
||
api.get('/mindspace/v1/space/categories', async (req, res) => {
|
||
if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
const categories = await mindSpace.listCategories(req.currentUser.id);
|
||
if (!categories) return res.status(404).json({ message: '用户空间不存在' });
|
||
return res.json({ data: categories });
|
||
});
|
||
|
||
api.get('/mindspace/v1/space/cleanup', async (req, res) => {
|
||
if (!mindSpaceCleanup || !ensureMindSpaceEnabled(res, req)) return;
|
||
try {
|
||
const username = req.currentUser.username ?? req.currentUser.slug;
|
||
const items = await mindSpaceCleanup.listCandidates(req.currentUser.id, username);
|
||
const totalBytes = items.reduce((sum, item) => sum + item.sizeBytes, 0);
|
||
return sendData(res, req, { items, totalBytes });
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/space/cleanup', async (req, res) => {
|
||
if (!mindSpaceCleanup || !ensureMindSpaceEnabled(res, req)) return;
|
||
try {
|
||
const username = req.currentUser.username ?? req.currentUser.slug;
|
||
const itemIds = Array.isArray(req.body?.item_ids) ? req.body.item_ids : [];
|
||
const result = await mindSpaceCleanup.runCleanup(req.currentUser.id, username, itemIds);
|
||
const quota = await mindSpace.getQuota(req.currentUser.id);
|
||
await mindSpaceAudit?.write({
|
||
userId: req.currentUser.id,
|
||
action: 'space.cleanup',
|
||
objectType: 'space',
|
||
objectId: req.currentUser.id,
|
||
ip: req.ip,
|
||
detail: { removedCount: result.removedCount, freedBytes: result.freedBytes },
|
||
});
|
||
return sendData(res, req, { ...result, quota });
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
function isPlazaPublicRead(path, method) {
|
||
if (!path.startsWith('/plaza/v1/')) return false;
|
||
if (method === 'POST' && path === '/plaza/v1/events') return true;
|
||
if (method !== 'GET') return false;
|
||
return (
|
||
path === '/plaza/v1/feed' ||
|
||
path === '/plaza/v1/categories' ||
|
||
path === '/plaza/v1/seo/sitemap' ||
|
||
/^\/plaza\/v1\/posts\/[^/]+$/.test(path) ||
|
||
/^\/plaza\/v1\/posts\/[^/]+\/comments$/.test(path) ||
|
||
/^\/plaza\/v1\/users\/[^/]+$/.test(path) ||
|
||
/^\/plaza\/v1\/users\/[^/]+\/posts$/.test(path)
|
||
);
|
||
}
|
||
|
||
const PLAZA_SID_COOKIE = 'plaza_sid';
|
||
|
||
function resolvePlazaSessionId(req, res) {
|
||
const cookies = parseCookies(req.get('cookie'));
|
||
let sessionId = cookies[PLAZA_SID_COOKIE];
|
||
if (!sessionId) {
|
||
sessionId = crypto.randomUUID();
|
||
res.append(
|
||
'Set-Cookie',
|
||
`${PLAZA_SID_COOKIE}=${sessionId}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`,
|
||
);
|
||
}
|
||
return sessionId;
|
||
}
|
||
|
||
function recordPlazaEventsAsync(req, res, events) {
|
||
if (!plazaEvents || !Array.isArray(events) || events.length === 0) return;
|
||
const sessionId = resolvePlazaSessionId(req, res);
|
||
void plazaEvents
|
||
.recordEvents({
|
||
userId: req.currentUser?.id ?? null,
|
||
sessionId,
|
||
events,
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function reactionEventType(type) {
|
||
if (type === 'like' || type === 'collect' || type === 'share') return type;
|
||
return null;
|
||
}
|
||
|
||
function ensurePlazaInteractions(res, req) {
|
||
if (!plazaInteractions) {
|
||
sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function plazaRouteError(res, req, error) {
|
||
const status = mapPlazaError(error);
|
||
const code = error?.code ?? 'internal_error';
|
||
const message = error instanceof Error ? error.message : 'Plaza 请求失败';
|
||
return sendError(res, req, status, code, message, error?.details);
|
||
}
|
||
|
||
function ensurePlazaEnabled(res, req) {
|
||
if (!plazaPosts) {
|
||
sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function plazaClientIp(req) {
|
||
const forwarded = req.headers['x-forwarded-for'];
|
||
if (typeof forwarded === 'string' && forwarded.length > 0) {
|
||
return forwarded.split(',')[0].trim();
|
||
}
|
||
return req.ip;
|
||
}
|
||
|
||
|
||
function mindSpaceError(res, req, error) {
|
||
const statusByCode = {
|
||
invalid_filename: 400,
|
||
invalid_file_size: 400,
|
||
category_not_uploadable: 400,
|
||
file_size_mismatch: 409,
|
||
invalid_upload_state: 409,
|
||
file_too_large: 413,
|
||
unsupported_file_type: 415,
|
||
category_not_found: 404,
|
||
upload_not_found: 404,
|
||
asset_not_found: 404,
|
||
asset_in_use: 409,
|
||
page_not_found: 404,
|
||
upload_expired: 410,
|
||
quota_exceeded: 429,
|
||
public_page_limit_exceeded: 429,
|
||
space_unavailable: 423,
|
||
invalid_page_input: 400,
|
||
page_content_too_large: 413,
|
||
source_message_not_found: 404,
|
||
invalid_source_message: 409,
|
||
version_conflict: 409,
|
||
slug_conflict: 409,
|
||
invalid_state_transition: 409,
|
||
invalid_publish_input: 400,
|
||
publication_not_found: 404,
|
||
security_scan_required: 422,
|
||
security_ack_required: 422,
|
||
security_risk_blocked: 422,
|
||
invalid_agent_job_input: 400,
|
||
invalid_agent_job_output: 400,
|
||
agent_job_not_found: 404,
|
||
agent_job_token_invalid: 401,
|
||
agent_job_expired: 410,
|
||
feature_disabled: 503,
|
||
cover_ai_unavailable: 503,
|
||
llm_not_configured: 503,
|
||
cover_ai_failed: 502,
|
||
cover_ai_invalid_output: 422,
|
||
thumbnail_not_supported: 422,
|
||
thumbnail_image_required: 400,
|
||
thumbnail_image_invalid: 400,
|
||
category_not_pageable: 400,
|
||
redaction_not_needed: 409,
|
||
invalid_category_code: 400,
|
||
invalid_page_path: 400,
|
||
empty_page_content: 400,
|
||
static_page_not_found: 404,
|
||
preview_not_supported: 422,
|
||
};
|
||
const code = error?.code ?? 'internal_error';
|
||
const status = statusByCode[code] ?? 500;
|
||
const message =
|
||
status === 500 && (!error?.code || code === 'internal_error')
|
||
? 'MindSpace 服务异常'
|
||
: error?.message || 'MindSpace 服务异常';
|
||
return sendError(res, req, status, code, message, error?.details);
|
||
}
|
||
|
||
function ensureMindSpaceEnabled(res, req, { upload = false, agent = false } = {}) {
|
||
try {
|
||
assertMindSpaceRoute(msFlags, upload ? 'upload' : agent ? 'agent' : undefined);
|
||
return true;
|
||
} catch (error) {
|
||
mindSpaceError(res, req, error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function bearerToken(req) {
|
||
const header = req.get('authorization') ?? '';
|
||
const [scheme, value] = header.split(/\s+/, 2);
|
||
if (scheme?.toLowerCase() !== 'bearer' || !value) return null;
|
||
return value.trim();
|
||
}
|
||
|
||
function requireInternalAgentSecret(req, res) {
|
||
if (bearerToken(req) === INTERNAL_AGENT_SECRET) return true;
|
||
sendError(res, req, 401, 'agent_job_token_invalid', '内部 Agent 凭据无效');
|
||
return false;
|
||
}
|
||
|
||
api.post('/mindspace/v1/agent/jobs', async (req, res) => {
|
||
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
|
||
try {
|
||
const job = await mindSpaceAgentJobs.createJob(req.currentUser.id, {
|
||
jobType: req.body?.job_type,
|
||
instruction: req.body?.instruction,
|
||
allowedAssetIds: req.body?.allowed_asset_ids,
|
||
outputCategoryId: req.body?.output_category_id,
|
||
outputType: req.body?.output_type,
|
||
idempotencyKey: req.body?.idempotency_key,
|
||
locale: req.body?.locale,
|
||
timezone: req.body?.timezone,
|
||
capabilities: req.body?.capabilities,
|
||
});
|
||
await mindSpaceAudit?.write({
|
||
userId: req.currentUser.id,
|
||
action: 'agent_access',
|
||
objectType: 'agent_job',
|
||
objectId: job.id,
|
||
ip: req.ip,
|
||
detail: {
|
||
jobType: job.jobType,
|
||
assetIds: job.assets.map((asset) => asset.assetId),
|
||
},
|
||
});
|
||
return sendData(res, req, job, 201);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/mindspace/v1/agent/jobs/:jobId', async (req, res) => {
|
||
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
|
||
try {
|
||
return sendData(res, req, await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId));
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
// Server-Sent Events stream of a job's progress, so long-running agent tasks can
|
||
// be dispatched async (enqueue → 202 → subscribe here) instead of holding a
|
||
// synchronous streaming connection. Polls the job (ownership enforced by getJob)
|
||
// and pushes on change; closes on terminal status or client disconnect.
|
||
api.get('/mindspace/v1/agent/jobs/:jobId/stream', async (req, res) => {
|
||
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
|
||
const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'timed_out']);
|
||
let job;
|
||
try {
|
||
job = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
res.writeHead(200, {
|
||
'Content-Type': 'text/event-stream',
|
||
'Cache-Control': 'no-cache, no-transform',
|
||
Connection: 'keep-alive',
|
||
'X-Accel-Buffering': 'no',
|
||
});
|
||
const send = (event, payload) => {
|
||
res.write(`event: ${event}\n`);
|
||
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||
};
|
||
let lastSignature = '';
|
||
const emitIfChanged = (current) => {
|
||
const signature = `${current.status}:${JSON.stringify(current.progress ?? {})}`;
|
||
if (signature !== lastSignature) {
|
||
lastSignature = signature;
|
||
send('progress', current);
|
||
}
|
||
return signature;
|
||
};
|
||
emitIfChanged(job);
|
||
if (TERMINAL.has(job.status)) {
|
||
send('done', job);
|
||
return res.end();
|
||
}
|
||
let closed = false;
|
||
const cleanup = () => {
|
||
if (closed) return;
|
||
closed = true;
|
||
clearInterval(pollTimer);
|
||
clearInterval(keepAliveTimer);
|
||
};
|
||
const pollTimer = setInterval(async () => {
|
||
if (closed) return;
|
||
try {
|
||
const current = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId);
|
||
emitIfChanged(current);
|
||
if (TERMINAL.has(current.status)) {
|
||
send('done', current);
|
||
cleanup();
|
||
res.end();
|
||
}
|
||
} catch {
|
||
// Job vanished or transient read error: end the stream rather than leak it.
|
||
cleanup();
|
||
res.end();
|
||
}
|
||
}, Math.max(500, Number(process.env.MINDSPACE_AGENT_SSE_POLL_MS ?? 1000)));
|
||
// Comment line keeps proxies from closing an idle connection.
|
||
const keepAliveTimer = setInterval(() => {
|
||
if (!closed) res.write(': keep-alive\n\n');
|
||
}, 15_000);
|
||
pollTimer.unref?.();
|
||
keepAliveTimer.unref?.();
|
||
req.on('close', cleanup);
|
||
});
|
||
|
||
api.get('/mindspace/v1/agent/jobs', async (req, res) => {
|
||
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
|
||
try {
|
||
const result = await mindSpaceAgentJobs.listJobs(req.currentUser.id, {
|
||
limit: Number(req.query.limit ?? 10),
|
||
offset: Number(req.query.offset ?? 0),
|
||
});
|
||
return res.json({
|
||
data: result.items,
|
||
page: {
|
||
total: result.total,
|
||
offset: result.offset,
|
||
limit: result.limit,
|
||
has_more: result.hasMore,
|
||
},
|
||
request_id: req.requestId,
|
||
});
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/agent/jobs/:jobId/cancel', async (req, res) => {
|
||
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
|
||
try {
|
||
return sendData(
|
||
res,
|
||
req,
|
||
await mindSpaceAgentJobs.cancelJob(req.currentUser.id, req.params.jobId),
|
||
);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/agent/jobs/:jobId/retry', async (req, res) => {
|
||
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
|
||
try {
|
||
return sendData(
|
||
res,
|
||
req,
|
||
await mindSpaceAgentJobs.retryJob(req.currentUser.id, req.params.jobId),
|
||
);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/agent/jobs/:jobId/run', async (req, res) => {
|
||
if (
|
||
!mindSpaceAgentJobs ||
|
||
!mindSpaceAgentRunner ||
|
||
!ensureMindSpaceEnabled(res, req, { agent: true })
|
||
) {
|
||
return;
|
||
}
|
||
try {
|
||
const job = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId);
|
||
if (job.status !== 'queued') {
|
||
return sendData(res, req, job);
|
||
}
|
||
void mindSpaceAgentRunner.runJob(req.params.jobId).catch((error) => {
|
||
console.error('MindSpace agent job run failed:', error);
|
||
});
|
||
return sendData(res, req, { started: true, jobId: req.params.jobId }, 202);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/internal/agent/jobs/:jobId/claim', async (req, res) => {
|
||
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
|
||
if (!requireInternalAgentSecret(req, res)) return;
|
||
try {
|
||
return sendData(res, req, await mindSpaceAgentJobs.claimJob(req.params.jobId));
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/internal/agent/jobs/:jobId/assets/:assetId', async (req, res) => {
|
||
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
|
||
try {
|
||
const asset = await mindSpaceAgentJobs.getAssetForJob(
|
||
req.params.jobId,
|
||
bearerToken(req),
|
||
req.params.assetId,
|
||
);
|
||
res.set('Content-Type', asset.mimeType);
|
||
res.set('Content-Disposition', `inline; filename="${encodeURIComponent(asset.displayName)}"`);
|
||
res.set('Cache-Control', 'private, no-store');
|
||
res.setHeader('X-Request-Id', req.requestId);
|
||
return res.send(await fs.promises.readFile(asset.path));
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/internal/agent/jobs/:jobId/heartbeat', async (req, res) => {
|
||
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
|
||
try {
|
||
return sendData(
|
||
res,
|
||
req,
|
||
await mindSpaceAgentJobs.heartbeat(req.params.jobId, bearerToken(req), {
|
||
stage: req.body?.stage,
|
||
message: req.body?.message,
|
||
}),
|
||
);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/internal/agent/jobs/:jobId/complete', async (req, res) => {
|
||
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
|
||
try {
|
||
const job = await mindSpaceAgentJobs.completeJob(req.params.jobId, bearerToken(req), {
|
||
status: req.body?.status,
|
||
errorCode: req.body?.error_code,
|
||
errorMessage: req.body?.error_message,
|
||
outputType: req.body?.output_type,
|
||
title: req.body?.title,
|
||
summary: req.body?.summary,
|
||
content: req.body?.content,
|
||
contentFormat: req.body?.content_format,
|
||
pageType: req.body?.page_type,
|
||
templateId: req.body?.template_id,
|
||
sourceAssetIds: req.body?.source_asset_ids,
|
||
});
|
||
return sendData(res, req, job);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/uploads', async (req, res) => {
|
||
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req, { upload: true })) return;
|
||
try {
|
||
const upload = await mindSpaceAssets.createUpload(req.currentUser.id, {
|
||
categoryId: req.body?.category_id,
|
||
filename: req.body?.filename,
|
||
sizeBytes: req.body?.size_bytes,
|
||
declaredMimeType: req.body?.declared_mime_type,
|
||
});
|
||
return sendData(res, req, upload, 201);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.put('/mindspace/v1/uploads/:uploadId/content', rawUploadBody, async (req, res) => {
|
||
if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const result = await mindSpaceAssets.writeUploadContent(
|
||
req.currentUser.id,
|
||
req.params.uploadId,
|
||
req.body,
|
||
);
|
||
return res.json({ data: result });
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/uploads/:uploadId/complete', async (req, res) => {
|
||
if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const asset = await mindSpaceAssets.completeUpload(
|
||
req.currentUser.id,
|
||
req.params.uploadId,
|
||
);
|
||
return res.status(201).json({ data: asset });
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.delete('/mindspace/v1/uploads/:uploadId', async (req, res) => {
|
||
if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const result = await mindSpaceAssets.cancelUpload(
|
||
req.currentUser.id,
|
||
req.params.uploadId,
|
||
);
|
||
return res.json({ data: result });
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/mindspace/v1/assets', async (req, res) => {
|
||
if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const assets = await mindSpaceAssets.listAssets(req.currentUser.id, {
|
||
categoryId: typeof req.query.category_id === 'string' ? req.query.category_id : undefined,
|
||
categoryCode:
|
||
typeof req.query.category_code === 'string' ? req.query.category_code : undefined,
|
||
});
|
||
return res.json({ data: assets, page: { next_cursor: null, has_more: false } });
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/mindspace/v1/authorize-image', async (req, res) => {
|
||
if (!mindSpacePublications) {
|
||
return res.status(503).json({ error: 'Service unavailable' });
|
||
}
|
||
const assetId = String(req.query.asset_id ?? '');
|
||
if (!assetId) {
|
||
return res.status(400).json({ error: 'Missing asset_id parameter' });
|
||
}
|
||
try {
|
||
const [refs] = await pool.query(
|
||
`SELECT pr.access_mode, pr.expires_at
|
||
FROM h5_publication_asset_refs refs
|
||
JOIN h5_publish_records pr ON refs.publication_id = pr.id
|
||
WHERE refs.asset_id = ? AND pr.status = 'online'
|
||
ORDER BY CASE
|
||
WHEN pr.access_mode = 'public' THEN 0
|
||
WHEN pr.access_mode = 'time_limited' THEN 1
|
||
ELSE 2
|
||
END,
|
||
pr.expires_at DESC
|
||
LIMIT 1`,
|
||
[assetId],
|
||
);
|
||
|
||
if (!refs[0]) {
|
||
return res.status(403).json({ error: 'Forbidden' });
|
||
}
|
||
|
||
const publication = refs[0];
|
||
const now = Date.now();
|
||
|
||
if (publication.access_mode === 'public') {
|
||
res.set('Cache-Control', 'public, max-age=31536000, immutable');
|
||
return res.status(200).json({ ok: true });
|
||
}
|
||
|
||
if (
|
||
publication.access_mode === 'time_limited' &&
|
||
publication.expires_at &&
|
||
Number(publication.expires_at) > now
|
||
) {
|
||
res.set('Cache-Control', 'public, max-age=60');
|
||
return res.status(200).json({ ok: true });
|
||
}
|
||
|
||
return res.status(403).json({ error: 'Forbidden' });
|
||
} catch (error) {
|
||
console.error('[authorize-image]', error instanceof Error ? error.message : error);
|
||
return res.status(500).json({ error: 'Internal server error' });
|
||
}
|
||
});
|
||
|
||
api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => {
|
||
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
|
||
try {
|
||
const assetId = req.params.assetId;
|
||
const currentUserId = req.currentUser?.id ?? null;
|
||
let allowedByPublication = false;
|
||
let publicationAccessMode = null;
|
||
if (!currentUserId) {
|
||
const referrer = String(req.get('referer') ?? req.get('referrer') ?? '');
|
||
const isPublicPageReferrer = (() => {
|
||
if (!referrer) return false;
|
||
try {
|
||
return new URL(referrer, resolveRequestOrigin(req) || 'http://localhost').pathname.includes('/public/');
|
||
} catch {
|
||
return referrer.includes('/public/');
|
||
}
|
||
})();
|
||
const [refs] = await authPool.query(
|
||
`SELECT pr.access_mode, pr.expires_at
|
||
FROM h5_publication_asset_refs refs
|
||
JOIN h5_publish_records pr ON refs.publication_id = pr.id
|
||
WHERE refs.asset_id = ? AND pr.status = 'online'
|
||
ORDER BY CASE
|
||
WHEN pr.access_mode = 'public' THEN 0
|
||
WHEN pr.access_mode = 'time_limited' THEN 1
|
||
ELSE 2
|
||
END,
|
||
pr.expires_at DESC
|
||
LIMIT 1`,
|
||
[assetId],
|
||
);
|
||
const publication = refs[0] ?? null;
|
||
if (publication?.access_mode === 'public') {
|
||
allowedByPublication = true;
|
||
publicationAccessMode = 'public';
|
||
} else if (
|
||
publication?.access_mode === 'time_limited' &&
|
||
publication.expires_at &&
|
||
Number(publication.expires_at) > Date.now()
|
||
) {
|
||
allowedByPublication = true;
|
||
publicationAccessMode = 'time_limited';
|
||
}
|
||
if (!allowedByPublication && isPublicPageReferrer) {
|
||
allowedByPublication = true;
|
||
publicationAccessMode = 'public-page-referrer';
|
||
}
|
||
if (!allowedByPublication) {
|
||
return res.status(401).json({ message: '未授权,请重新登录' });
|
||
}
|
||
}
|
||
const { asset, path: assetPath } = currentUserId
|
||
? await mindSpaceAssets.readAsset(currentUserId, assetId)
|
||
: await mindSpaceAssets.readPublicAsset(assetId);
|
||
await mindSpaceAudit?.write({
|
||
userId: req.currentUser?.id ?? null,
|
||
action: 'asset.download',
|
||
objectType: 'asset',
|
||
objectId: assetId,
|
||
ip: req.ip,
|
||
riskLevel: asset.riskLevel,
|
||
detail: allowedByPublication ? { accessMode: publicationAccessMode } : undefined,
|
||
});
|
||
res.type(asset.mimeType);
|
||
const inline =
|
||
req.query.inline === '1' ||
|
||
req.query.disposition === 'inline' ||
|
||
req.get('sec-fetch-dest') === 'iframe';
|
||
if (inline && asset.mimeType.startsWith('image/') && wantsInlineImageViewer(req)) {
|
||
const downloadUrl = `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download?inline=1`;
|
||
const html = renderImageAssetViewerHtml({ asset, downloadUrl });
|
||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||
res.set('Cache-Control', 'private, no-store');
|
||
res.setHeader('X-Request-Id', req.requestId);
|
||
return res.send(html);
|
||
}
|
||
res.set(
|
||
'Content-Disposition',
|
||
inline
|
||
? `inline; filename*=UTF-8''${encodeURIComponent(asset.filename)}`
|
||
: `attachment; filename*=UTF-8''${encodeURIComponent(asset.filename)}`,
|
||
);
|
||
res.setHeader('X-Request-Id', req.requestId);
|
||
return res.sendFile(assetPath);
|
||
} catch (error) {
|
||
await mindSpaceAudit?.write({
|
||
userId: req.currentUser?.id ?? null,
|
||
action: 'asset.download',
|
||
objectType: 'asset',
|
||
objectId: req.params.assetId,
|
||
ip: req.ip,
|
||
result: 'denied',
|
||
riskLevel: error?.code === 'security_risk_blocked' ? 'high' : null,
|
||
});
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/mindspace/v1/assets/:assetId/thumbnail', async (req, res) => {
|
||
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
|
||
try {
|
||
const svg = await mindSpaceAssets.renderAssetThumbnail(req.currentUser.id, req.params.assetId);
|
||
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
||
res.set('Cache-Control', 'private, max-age=300');
|
||
res.setHeader('X-Request-Id', req.requestId);
|
||
return res.send(svg);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/mindspace/v1/assets/:assetId/preview', async (req, res) => {
|
||
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
|
||
try {
|
||
const html = await mindSpaceAssets.renderAssetPreview(req.currentUser.id, req.params.assetId);
|
||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||
res.set('Cache-Control', 'private, no-store');
|
||
res.setHeader('X-Request-Id', req.requestId);
|
||
return res.send(html);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages/from-asset', async (req, res) => {
|
||
if (!mindSpacePages || !mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const assetId = req.body?.asset_id;
|
||
const existingPage = await mindSpacePages.findPageBySourceAsset(req.currentUser.id, assetId);
|
||
if (existingPage) {
|
||
return sendData(res, req, { kind: 'page', categoryCode: existingPage.categoryCode ?? 'draft', page: existingPage });
|
||
}
|
||
const { asset, path: assetPath } = await mindSpaceAssets.readAsset(
|
||
req.currentUser.id,
|
||
assetId,
|
||
);
|
||
if (asset.mimeType === 'text/html') {
|
||
const relativePath = normalizeWorkspaceRelativePath(
|
||
String(asset.originalFilename ?? asset.original_filename ?? '').includes('/')
|
||
? asset.originalFilename ?? asset.original_filename
|
||
: `public/${asset.originalFilename ?? asset.original_filename ?? ''}`,
|
||
);
|
||
const existingByPath = relativePath
|
||
? await mindSpacePages.findPageByRelativePath(req.currentUser.id, relativePath).catch(() => null)
|
||
: null;
|
||
if (existingByPath) {
|
||
return sendData(res, req, {
|
||
kind: 'page',
|
||
categoryCode: existingByPath.categoryCode ?? 'draft',
|
||
page: existingByPath,
|
||
});
|
||
}
|
||
}
|
||
const content = await fs.promises.readFile(assetPath, 'utf8');
|
||
const contentFormat = asset.mimeType === 'text/html' ? 'html' : 'markdown';
|
||
const htmlRelativePath =
|
||
contentFormat === 'html'
|
||
? normalizeWorkspaceRelativePath(
|
||
String(asset.originalFilename ?? asset.original_filename ?? '').includes('/')
|
||
? asset.originalFilename ?? asset.original_filename
|
||
: `public/${asset.originalFilename ?? asset.original_filename ?? ''}`,
|
||
)
|
||
: null;
|
||
const page = await mindSpacePages.createFromChat(
|
||
req.currentUser.id,
|
||
{
|
||
title: req.body?.title || asset.displayName,
|
||
summary: req.body?.summary,
|
||
content,
|
||
contentFormat,
|
||
templateId: req.body?.template_id,
|
||
categoryCode: 'draft',
|
||
},
|
||
{
|
||
assetId: asset.id,
|
||
snapshot: {
|
||
source_asset_id: asset.id,
|
||
source_category: asset.categoryCode,
|
||
content_mode: contentFormat,
|
||
...(htmlRelativePath ? { relative_path: htmlRelativePath } : {}),
|
||
},
|
||
},
|
||
);
|
||
return res.status(201).json({ data: { kind: 'page', categoryCode: 'draft', page } });
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.delete('/mindspace/v1/assets/:assetId', async (req, res) => {
|
||
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
|
||
try {
|
||
const result = await mindSpaceAssets.deleteAsset(req.currentUser.id, req.params.assetId);
|
||
await mindSpaceAudit?.write({
|
||
userId: req.currentUser.id,
|
||
action: 'asset.delete',
|
||
objectType: 'asset',
|
||
objectId: req.params.assetId,
|
||
ip: req.ip,
|
||
});
|
||
return sendData(res, req, result);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
function messageText(message) {
|
||
return (message?.content ?? [])
|
||
.filter((item) => item?.type === 'text' && typeof item.text === 'string')
|
||
.map((item) => item.text)
|
||
.join('')
|
||
.trim();
|
||
}
|
||
|
||
async function resolveOwnedAssistantMessage(userId, sessionId, messageId) {
|
||
if (!sessionId || !messageId) {
|
||
throw Object.assign(new Error('缺少来源会话或消息'), {
|
||
code: 'invalid_page_input',
|
||
});
|
||
}
|
||
if (!(await userAuth.ownsSession(userId, sessionId))) {
|
||
throw Object.assign(new Error('来源会话不存在'), { code: 'source_message_not_found' });
|
||
}
|
||
const upstream = await tkmindProxy.apiFetch(`/sessions/${encodeURIComponent(sessionId)}`, {
|
||
method: 'GET',
|
||
});
|
||
if (!upstream.ok) {
|
||
throw Object.assign(new Error('无法读取来源会话'), { code: 'source_message_not_found' });
|
||
}
|
||
const session = await upstream.json();
|
||
const message = (session.conversation ?? []).find((item) => item.id === messageId);
|
||
if (!message) {
|
||
throw Object.assign(new Error('来源消息不存在'), { code: 'source_message_not_found' });
|
||
}
|
||
const content = messageText(message);
|
||
if (message.role !== 'assistant' || !message.metadata?.userVisible || !content) {
|
||
throw Object.assign(new Error('只有可见的 AI 文本消息可以保存为页面'), {
|
||
code: 'invalid_source_message',
|
||
});
|
||
}
|
||
return { session, message, content };
|
||
}
|
||
|
||
async function resolveExistingSavedPage(userId, { sessionId, messageId, relativePath } = {}) {
|
||
if (!mindSpacePages || !userId) return null;
|
||
const byMessage = await mindSpacePages
|
||
.findPageBySourceMessage(userId, sessionId, messageId)
|
||
.catch(() => null);
|
||
if (byMessage) return byMessage;
|
||
const normalizedPath = normalizeWorkspaceRelativePath(relativePath);
|
||
if (!normalizedPath) return null;
|
||
return mindSpacePages.findPageByRelativePath(userId, normalizedPath).catch(() => null);
|
||
}
|
||
|
||
const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']);
|
||
|
||
const pageSyncInFlight = new Map();
|
||
|
||
async function syncUserGeneratedPages(userId) {
|
||
if (!mindSpacePages || !authPool || !userId) return;
|
||
|
||
let inFlight = pageSyncInFlight.get(userId);
|
||
if (!inFlight) {
|
||
inFlight = syncGeneratedPagesFromPublicAssets({
|
||
pool: authPool,
|
||
pageService: mindSpacePages,
|
||
assetService: mindSpaceAssets,
|
||
userId,
|
||
publishDir: resolvePublishDir(__dirname, { id: userId }),
|
||
syncWorkspaceAssets:
|
||
mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED
|
||
? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options)
|
||
: null,
|
||
})
|
||
.catch((error) => {
|
||
console.warn('[MindSpace] page sync failed:', error?.message ?? error);
|
||
})
|
||
.finally(() => {
|
||
if (pageSyncInFlight.get(userId) === inFlight) {
|
||
pageSyncInFlight.delete(userId);
|
||
}
|
||
});
|
||
pageSyncInFlight.set(userId, inFlight);
|
||
}
|
||
await inFlight;
|
||
}
|
||
|
||
async function resolveChatSaveBundle(user, h5Root, input = {}) {
|
||
const sessionId = input.sessionId ?? input.session_id;
|
||
const messageId = input.messageId ?? input.message_id;
|
||
const selectedLinkIndex = Number(input.selectedLinkIndex ?? input.selected_link_index ?? 0);
|
||
const previewTitle = String(input.previewTitle ?? input.preview_title ?? '').trim();
|
||
const previewSummary = String(input.previewSummary ?? input.preview_summary ?? '').trim();
|
||
|
||
const source = await resolveOwnedAssistantMessage(user.id, sessionId, messageId);
|
||
let { analysis, resolvedHtml } = await resolveChatSaveAnalysis({
|
||
content: source.content,
|
||
userId: user.id,
|
||
username: user.username,
|
||
h5Root,
|
||
selectedLinkIndex,
|
||
});
|
||
|
||
if (resolvedHtml?.content && resolvedHtml.relativePath && authPool) {
|
||
const storageRoot =
|
||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
|
||
let htmlContent = resolvedHtml.content;
|
||
|
||
const repaired = await repairMissingHtmlAssetReferences({
|
||
pool: authPool,
|
||
userId: user.id,
|
||
sourceContent: source.content,
|
||
html: htmlContent,
|
||
}).catch(() => ({ html: htmlContent, changed: false }));
|
||
if (repaired.changed) {
|
||
htmlContent = repaired.html;
|
||
}
|
||
|
||
const materialized = await materializePrivateAssetsInWorkspaceHtml({
|
||
pool: authPool,
|
||
storageRoot,
|
||
h5Root,
|
||
userId: user.id,
|
||
html: htmlContent,
|
||
htmlRelativePath: resolvedHtml.relativePath,
|
||
writeBack: false,
|
||
}).catch(() => ({ html: htmlContent, count: 0, changed: false }));
|
||
if (materialized.changed) {
|
||
htmlContent = materialized.html;
|
||
}
|
||
|
||
if (htmlContent !== resolvedHtml.content) {
|
||
const publishDir = resolvePublishDir(h5Root, { id: user.id });
|
||
await fsPromises.writeFile(
|
||
path.join(publishDir, resolvedHtml.relativePath),
|
||
htmlContent,
|
||
'utf8',
|
||
);
|
||
resolvedHtml = { ...resolvedHtml, content: htmlContent };
|
||
}
|
||
}
|
||
|
||
return {
|
||
source,
|
||
analysis,
|
||
resolvedHtml,
|
||
selectedLinkIndex,
|
||
previewTitle,
|
||
previewSummary,
|
||
previewFrameUrl:
|
||
resolvedHtml != null
|
||
? buildChatSavePreviewFrameUrl({ sessionId, messageId, selectedLinkIndex })
|
||
: null,
|
||
thumbnailUrl:
|
||
resolvedHtml != null
|
||
? buildChatSaveThumbnailUrl({
|
||
sessionId,
|
||
messageId,
|
||
selectedLinkIndex,
|
||
previewTitle,
|
||
previewSummary,
|
||
})
|
||
: null,
|
||
localPreviewUrl:
|
||
resolvedHtml?.relativePath != null
|
||
? buildWorkspaceAssetUrl(user.id, resolvedHtml.relativePath)
|
||
: null,
|
||
localThumbnailUrl:
|
||
resolvedHtml?.relativePath != null
|
||
? buildWorkspaceThumbnailUrl(user.id, resolvedHtml.relativePath)
|
||
: null,
|
||
};
|
||
}
|
||
|
||
api.get('/mindspace/v1/pages/chat-save-preview', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.query);
|
||
if (!bundle.resolvedHtml) {
|
||
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
|
||
}
|
||
const baseHref = buildWorkspaceBaseHref(req.currentUser.id, bundle.resolvedHtml.relativePath);
|
||
const html = injectHtmlBaseHref(bundle.resolvedHtml.content, baseHref);
|
||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||
res.set('Cache-Control', 'private, no-store');
|
||
res.setHeader('X-Request-Id', req.requestId);
|
||
return res.send(html);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.query);
|
||
if (!bundle.resolvedHtml) {
|
||
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
|
||
}
|
||
const publishDir = resolvePublishDir(__dirname, req.currentUser);
|
||
const thumbRel = workspaceThumbnailRelativePath(bundle.resolvedHtml.relativePath);
|
||
const title =
|
||
bundle.previewTitle ||
|
||
bundle.resolvedHtml.suggestedTitle ||
|
||
bundle.analysis.suggestedTitle;
|
||
const subtitle =
|
||
bundle.previewSummary ||
|
||
bundle.resolvedHtml.suggestedSummary ||
|
||
bundle.analysis.suggestedSummary;
|
||
await ensureWorkspaceHtmlThumbnail(
|
||
publishDir,
|
||
bundle.resolvedHtml.relativePath,
|
||
bundle.resolvedHtml.content,
|
||
{ title, subtitle, force: true },
|
||
).catch(() => {});
|
||
const storageRoot =
|
||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
|
||
const resolveAssetDataUri = createAssetDataUriResolver(
|
||
authPool,
|
||
storageRoot,
|
||
req.currentUser.id,
|
||
);
|
||
const svg = await generateHtmlThumbnail(publishDir, thumbRel, bundle.resolvedHtml.content, {
|
||
title,
|
||
subtitle,
|
||
contentBaseDir: path.dirname(bundle.resolvedHtml.absolute),
|
||
resolveAssetDataUri,
|
||
force: true,
|
||
});
|
||
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
||
res.set('Cache-Control', 'private, no-store');
|
||
res.setHeader('X-Request-Id', req.requestId);
|
||
return res.send(svg);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const sessionId = req.body?.session_id;
|
||
const messageId = req.body?.message_id;
|
||
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
|
||
const {
|
||
source,
|
||
analysis,
|
||
resolvedHtml,
|
||
previewTitle,
|
||
previewSummary,
|
||
previewFrameUrl,
|
||
thumbnailUrl,
|
||
localPreviewUrl,
|
||
localThumbnailUrl,
|
||
} = bundle;
|
||
let thumbnailReady = false;
|
||
if (resolvedHtml?.content && resolvedHtml.relativePath) {
|
||
const publishDir = resolvePublishDir(__dirname, req.currentUser);
|
||
try {
|
||
await ensureWorkspaceHtmlThumbnail(publishDir, resolvedHtml.relativePath, resolvedHtml.content, {
|
||
title: previewTitle || resolvedHtml.suggestedTitle,
|
||
subtitle: previewSummary || resolvedHtml.suggestedSummary,
|
||
force: Boolean(previewTitle || previewSummary),
|
||
});
|
||
thumbnailReady = true;
|
||
} catch {
|
||
thumbnailReady = false;
|
||
}
|
||
}
|
||
const existingPage = await resolveExistingSavedPage(req.currentUser.id, {
|
||
sessionId,
|
||
messageId,
|
||
relativePath: resolvedHtml?.relativePath ?? analysis.relativePath,
|
||
});
|
||
return sendData(res, req, {
|
||
contentMode: analysis.contentMode,
|
||
links: analysis.links,
|
||
selectedLinkIndex: analysis.selectedLink
|
||
? analysis.links.findIndex((link) => link.publicUrl === analysis.selectedLink.publicUrl)
|
||
: -1,
|
||
suggestedTitle: resolvedHtml?.suggestedTitle ?? analysis.suggestedTitle,
|
||
suggestedSummary: resolvedHtml?.suggestedSummary ?? analysis.suggestedSummary,
|
||
previewUrl: analysis.previewUrl,
|
||
previewFrameUrl,
|
||
localPreviewUrl,
|
||
localThumbnailUrl,
|
||
thumbnailUrl: resolvedHtml ? thumbnailUrl : null,
|
||
thumbnailReady,
|
||
relativePath: resolvedHtml?.relativePath ?? analysis.relativePath,
|
||
filename: resolvedHtml?.filename ?? analysis.filename,
|
||
hasHtmlContent: Boolean(resolvedHtml),
|
||
privacyScan: scanContent(resolvedHtml?.content ?? source.content),
|
||
existingPage: existingPage
|
||
? { id: existingPage.id, title: existingPage.title, categoryCode: existingPage.categoryCode }
|
||
: null,
|
||
});
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
|
||
console.log('[quick-share] bundle resolved, hasHtml:', Boolean(bundle.resolvedHtml));
|
||
if (!bundle.resolvedHtml) {
|
||
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
|
||
}
|
||
|
||
const storageRoot =
|
||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
|
||
const { html: localizedHtml } = await inlinePrivateAssetsInHtml(
|
||
authPool,
|
||
storageRoot,
|
||
req.currentUser.id,
|
||
bundle.resolvedHtml.content,
|
||
);
|
||
|
||
const publishDir = resolvePublishDir(__dirname, req.currentUser);
|
||
const sharedDir = path.join(publishDir, PUBLIC_ZONE_DIR, 'shared');
|
||
await fsPromises.mkdir(sharedDir, { recursive: true });
|
||
|
||
const basename = bundle.resolvedHtml.filename.replace(/\.html$/i, '');
|
||
const filename = `${basename}-${crypto.randomUUID().slice(0, 8)}.html`;
|
||
const sharedRelativePath = `${PUBLIC_ZONE_DIR}/shared/${filename}`;
|
||
const sharedHtml = rewriteWorkspacePublicAssetReferences(localizedHtml, sharedRelativePath);
|
||
const destPath = path.join(sharedDir, filename);
|
||
await fsPromises.writeFile(destPath, sharedHtml, 'utf8');
|
||
|
||
const publishKey = req.currentUser.id;
|
||
const publicUrl = `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}/${sharedRelativePath.split('/').map((part) => encodeURIComponent(part)).join('/')}`;
|
||
|
||
return res.status(201).json({ data: { publicUrl, filename } });
|
||
} catch (error) {
|
||
console.error('[quick-share] error:', error);
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
|
||
if (!mindSpacePages || !mindSpaceAssets) {
|
||
return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
}
|
||
try {
|
||
const categoryCode = String(req.body?.category_code ?? 'draft');
|
||
if (!SAVE_TARGET_CATEGORIES.has(categoryCode)) {
|
||
throw Object.assign(new Error('无效的保存目标'), { code: 'invalid_category_code' });
|
||
}
|
||
const source = await resolveOwnedAssistantMessage(
|
||
req.currentUser.id,
|
||
req.body?.session_id,
|
||
req.body?.message_id,
|
||
);
|
||
const analysis = analyzeChatMessageForSave({
|
||
content: source.content,
|
||
userId: req.currentUser.id,
|
||
username: req.currentUser.username,
|
||
h5Root: __dirname,
|
||
selectedLinkIndex: Number(req.body?.selected_link_index ?? 0),
|
||
});
|
||
const snapshot = {
|
||
session_name: source.session.name,
|
||
message_created: source.message.created,
|
||
role: source.message.role,
|
||
content_mode: analysis.contentMode,
|
||
public_url: analysis.previewUrl,
|
||
relative_path: analysis.relativePath
|
||
? normalizeWorkspaceRelativePath(analysis.relativePath)
|
||
: analysis.relativePath,
|
||
};
|
||
|
||
let resolvedHtml = null;
|
||
if (analysis.contentMode === 'static_html') {
|
||
resolvedHtml = await resolveStaticHtmlContent(analysis).catch(() => null);
|
||
}
|
||
const privacyScan = scanContent(resolvedHtml?.content ?? source.content);
|
||
|
||
if (categoryCode !== 'draft') {
|
||
let buffer;
|
||
let filename;
|
||
let displayName = req.body?.title;
|
||
if (analysis.contentMode === 'static_html') {
|
||
if (!resolvedHtml) {
|
||
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
|
||
}
|
||
buffer = Buffer.from(resolvedHtml.content, 'utf8');
|
||
filename = resolvedHtml.filename;
|
||
displayName = displayName || resolvedHtml.suggestedTitle;
|
||
} else {
|
||
buffer = Buffer.from(source.content, 'utf8');
|
||
filename = `${String(displayName || 'chat-export')
|
||
.replace(/[^\w\u4e00-\u9fff-]+/g, '-')
|
||
.slice(0, 48) || 'chat-export'}.md`;
|
||
}
|
||
const asset = await mindSpaceAssets.createChatAsset(req.currentUser.id, {
|
||
categoryCode,
|
||
buffer,
|
||
filename,
|
||
displayName,
|
||
sourceType: 'chat',
|
||
});
|
||
return res.status(201).json({
|
||
data: {
|
||
kind: 'asset',
|
||
categoryCode,
|
||
asset,
|
||
},
|
||
});
|
||
}
|
||
|
||
let pageInput = {
|
||
title: req.body?.title,
|
||
summary: req.body?.summary,
|
||
templateId: req.body?.template_id,
|
||
pageType: req.body?.page_type,
|
||
categoryCode: 'draft',
|
||
};
|
||
if (analysis.contentMode === 'static_html') {
|
||
if (!resolvedHtml) {
|
||
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
|
||
}
|
||
pageInput = {
|
||
...pageInput,
|
||
title: req.body?.title || resolvedHtml.suggestedTitle,
|
||
summary: req.body?.summary || resolvedHtml.suggestedSummary,
|
||
content: resolvedHtml.content,
|
||
contentFormat: 'html',
|
||
pageType: 'html',
|
||
};
|
||
} else {
|
||
pageInput = {
|
||
...pageInput,
|
||
content: source.content,
|
||
contentFormat: 'markdown',
|
||
};
|
||
}
|
||
|
||
if (analysis.contentMode === 'static_html' && resolvedHtml?.content && analysis.relativePath) {
|
||
const publishDir = resolvePublishDir(__dirname, req.currentUser);
|
||
await ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, {
|
||
title: pageInput.title,
|
||
subtitle: pageInput.summary,
|
||
}).catch(() => {});
|
||
}
|
||
|
||
const saveAsNew = Boolean(req.body?.save_as_new);
|
||
let replacePageId = req.body?.replace_page_id
|
||
? String(req.body.replace_page_id).trim()
|
||
: null;
|
||
if (!replacePageId && !saveAsNew && analysis.contentMode === 'static_html' && analysis.relativePath) {
|
||
const existingByPath = await mindSpacePages
|
||
.findPageByRelativePath(req.currentUser.id, analysis.relativePath)
|
||
.catch(() => null);
|
||
if (existingByPath) replacePageId = existingByPath.id;
|
||
}
|
||
let page;
|
||
if (replacePageId) {
|
||
const existingPage = await mindSpacePages.getPage(req.currentUser.id, replacePageId);
|
||
page = await mindSpacePages.updatePage(req.currentUser.id, replacePageId, {
|
||
...pageInput,
|
||
expectedVersion: existingPage.versionNo,
|
||
});
|
||
} else {
|
||
page = await mindSpacePages.createFromChat(req.currentUser.id, pageInput, {
|
||
sessionId: req.body.session_id,
|
||
messageId: req.body.message_id,
|
||
snapshot,
|
||
});
|
||
}
|
||
return res.status(201).json({ data: { kind: 'page', categoryCode: 'draft', page } });
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const page = await mindSpacePages.createPage(req.currentUser.id, {
|
||
title: req.body?.title,
|
||
summary: req.body?.summary,
|
||
content: req.body?.content,
|
||
contentFormat: req.body?.content_format === 'html' ? 'html' : undefined,
|
||
templateId: req.body?.template_id,
|
||
pageType: req.body?.page_type,
|
||
categoryCode: req.body?.category_code,
|
||
});
|
||
return res.status(201).json({ data: page });
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/mindspace/v1/pages', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
await syncUserGeneratedPages(req.currentUser.id);
|
||
const limit = Number.parseInt(String(req.query.limit ?? ''), 10);
|
||
const offset = Number.parseInt(String(req.query.offset ?? ''), 10);
|
||
const result = await mindSpacePages.listPages(req.currentUser.id, {
|
||
status: typeof req.query.status === 'string' ? req.query.status : undefined,
|
||
categoryCode: typeof req.query.category_code === 'string' ? req.query.category_code : undefined,
|
||
...(Number.isFinite(limit) ? { limit } : {}),
|
||
...(Number.isFinite(offset) ? { offset } : {}),
|
||
});
|
||
return res.json({
|
||
data: result.items,
|
||
page: {
|
||
total: result.total,
|
||
limit: result.limit,
|
||
offset: result.offset,
|
||
has_more: result.hasMore,
|
||
next_cursor: null,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/mindspace/v1/pages/:pageId', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const [page, versions, publication] = await Promise.all([
|
||
mindSpacePages.getPage(req.currentUser.id, req.params.pageId),
|
||
mindSpacePages.listVersions(req.currentUser.id, req.params.pageId),
|
||
mindSpacePublications?.getCurrent(req.currentUser.id, req.params.pageId) ?? null,
|
||
]);
|
||
return res.json({ data: { ...page, versions, publication } });
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.delete('/mindspace/v1/pages/:pageId', async (req, res) => {
|
||
if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return;
|
||
try {
|
||
const removeFromPlaza =
|
||
String(req.query?.remove_from_plaza ?? req.body?.remove_from_plaza ?? '').toLowerCase() ===
|
||
'true' ||
|
||
req.query?.remove_from_plaza === '1' ||
|
||
req.body?.remove_from_plaza === true;
|
||
const result = await mindSpacePages.deletePage(req.currentUser.id, req.params.pageId, {
|
||
removeFromPlaza,
|
||
});
|
||
const quota = await mindSpace.getQuota(req.currentUser.id);
|
||
await mindSpaceAudit?.write({
|
||
userId: req.currentUser.id,
|
||
action: 'page.delete',
|
||
objectType: 'page',
|
||
objectId: req.params.pageId,
|
||
ip: req.ip,
|
||
detail: {
|
||
offlinedPublicationCount: result.offlinedPublicationCount,
|
||
hiddenPlazaPostCount: result.hiddenPlazaPostCount,
|
||
deletedAssetCount: result.deletedAssetCount,
|
||
freedBytes: result.freedBytes,
|
||
},
|
||
});
|
||
return sendData(res, req, { ...result, quota });
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/mindspace/v1/pages/:pageId/delete-preview', async (req, res) => {
|
||
if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return;
|
||
try {
|
||
return sendData(res, req, await mindSpacePages.getDeletePreview(req.currentUser.id, req.params.pageId));
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.put('/mindspace/v1/pages/:pageId', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const page = await mindSpacePages.updatePage(req.currentUser.id, req.params.pageId, {
|
||
expectedVersion: req.body?.expected_version,
|
||
title: req.body?.title,
|
||
summary: req.body?.summary,
|
||
content: req.body?.content,
|
||
templateId: req.body?.template_id,
|
||
pageType: req.body?.page_type,
|
||
changeNote: req.body?.change_note,
|
||
});
|
||
return res.json({ data: page });
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages/:pageId/live-edit/bind', async (req, res) => {
|
||
if (!mindSpacePageLiveEdit || !mindSpacePages) {
|
||
return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
}
|
||
try {
|
||
const sessionId = String(req.body?.session_id ?? '').trim();
|
||
if (!sessionId) {
|
||
return sendError(res, req, 400, 'invalid_request', '缺少 session_id');
|
||
}
|
||
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
|
||
if (!owns) {
|
||
return sendError(res, req, 403, 'forbidden', '无权绑定该 Agent 会话');
|
||
}
|
||
await mindSpacePages.getPage(req.currentUser.id, req.params.pageId);
|
||
const parentSessionId = String(req.body?.parent_session_id ?? '').trim() || null;
|
||
return sendData(
|
||
res,
|
||
req,
|
||
mindSpacePageLiveEdit.bindSession({
|
||
userId: req.currentUser.id,
|
||
sessionId,
|
||
pageId: req.params.pageId,
|
||
parentSessionId,
|
||
}),
|
||
);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages/:pageId/live-edit/fork-session', async (req, res) => {
|
||
if (!mindSpacePageEditSession || !mindSpacePages) {
|
||
return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
}
|
||
try {
|
||
const parentSessionId = String(req.body?.parent_session_id ?? '').trim();
|
||
if (!parentSessionId) {
|
||
return sendError(res, req, 400, 'invalid_request', '缺少 parent_session_id');
|
||
}
|
||
const gate = await userAuth.canUseChat(req.currentUser.id);
|
||
if (!gate.ok) {
|
||
return sendError(res, req, 402, gate.code ?? 'insufficient_balance', gate.message);
|
||
}
|
||
return sendData(
|
||
res,
|
||
req,
|
||
await mindSpacePageEditSession.forkSession({
|
||
userId: req.currentUser.id,
|
||
pageId: req.params.pageId,
|
||
parentSessionId,
|
||
h5ApiBase: String(req.body?.h5_api_base ?? req.body?.h5ApiBase ?? '').trim() || null,
|
||
}),
|
||
);
|
||
} catch (error) {
|
||
if (error?.code === 'forbidden') {
|
||
return sendError(res, req, 403, error.code, error.message);
|
||
}
|
||
if (error?.code === 'invalid_request') {
|
||
return sendError(res, req, 400, error.code, error.message);
|
||
}
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages/:pageId/live-edit/close-session', async (req, res) => {
|
||
if (!mindSpacePageEditSession || !mindSpacePages) {
|
||
return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
}
|
||
try {
|
||
const sessionId = String(req.body?.session_id ?? '').trim();
|
||
if (!sessionId) {
|
||
return sendError(res, req, 400, 'invalid_request', '缺少 session_id');
|
||
}
|
||
return sendData(
|
||
res,
|
||
req,
|
||
await mindSpacePageEditSession.closeSession({
|
||
userId: req.currentUser.id,
|
||
pageId: req.params.pageId,
|
||
sessionId,
|
||
parentSessionId: String(req.body?.parent_session_id ?? '').trim() || null,
|
||
summary: String(req.body?.summary ?? ''),
|
||
}),
|
||
);
|
||
} catch (error) {
|
||
if (error?.code === 'forbidden' || error?.code === 'page_binding_mismatch') {
|
||
return sendError(res, req, 403, error.code, error.message);
|
||
}
|
||
if (error?.code === 'invalid_request') {
|
||
return sendError(res, req, 400, error.code, error.message);
|
||
}
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/mindspace/v1/pages/:pageId/live-edit/revision', async (req, res) => {
|
||
if (!mindSpacePageLiveEdit || !mindSpacePages) {
|
||
return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
}
|
||
try {
|
||
return sendData(
|
||
res,
|
||
req,
|
||
await mindSpacePageLiveEdit.getRevisionSnapshot(req.currentUser.id, req.params.pageId),
|
||
);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/agent/mindspace_page_patch', async (req, res) => {
|
||
if (!mindSpacePageLiveEdit) {
|
||
return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
}
|
||
try {
|
||
const result = await mindSpacePageLiveEdit.applyAgentPatch(req.body ?? {});
|
||
return res.json({
|
||
data: {
|
||
pageId: result.page.id,
|
||
title: result.page.title,
|
||
summary: result.page.summary,
|
||
versionNo: result.page.versionNo,
|
||
updatedAt: result.page.updatedAt,
|
||
liveRevision: result.liveRevision,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
if (error?.code === 'forbidden' || error?.code === 'page_binding_mismatch') {
|
||
return sendError(res, req, 403, error.code, error.message);
|
||
}
|
||
if (error?.code === 'invalid_request') {
|
||
return sendError(res, req, 400, error.code, error.message);
|
||
}
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/agent/mindspace_asset_delete', async (req, res) => {
|
||
if (!mindSpaceAssetAgent) {
|
||
return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
}
|
||
try {
|
||
const result = await mindSpaceAssetAgent.applyAgentDelete(req.body ?? {});
|
||
await mindSpaceAudit?.write({
|
||
userId: result.userId ?? null,
|
||
action: 'asset.delete',
|
||
objectType: 'asset',
|
||
objectId: result.assetId,
|
||
ip: req.ip,
|
||
metadata: { via: 'agent' },
|
||
});
|
||
return sendData(res, req, result);
|
||
} catch (error) {
|
||
if (error?.code === 'forbidden') {
|
||
return sendError(res, req, 403, error.code, error.message);
|
||
}
|
||
if (error?.code === 'invalid_request' || error?.code === 'confirmation_required') {
|
||
return sendError(res, req, 400, error.code, error.message);
|
||
}
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/mindspace/v1/pages/:pageId/thumbnail', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const svg = await mindSpacePages.renderThumbnail(req.currentUser.id, req.params.pageId);
|
||
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
||
res.set('Cache-Control', 'private, max-age=300');
|
||
res.setHeader('X-Request-Id', req.requestId);
|
||
return res.send(svg);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages/:pageId/thumbnail/upload', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const result = await mindSpacePages.uploadThumbnail(req.currentUser.id, req.params.pageId, {
|
||
imageBase64: req.body?.image_base64,
|
||
mimeType: req.body?.mime_type,
|
||
title: req.body?.title,
|
||
summary: req.body?.summary,
|
||
html: req.body?.html,
|
||
});
|
||
return sendData(res, req, result);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages/:pageId/thumbnail/regenerate', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const result = await mindSpacePages.regenerateThumbnail(
|
||
req.currentUser.id,
|
||
req.params.pageId,
|
||
{
|
||
html: req.body?.html,
|
||
title: req.body?.title,
|
||
summary: req.body?.summary,
|
||
useAi: Boolean(req.body?.use_ai),
|
||
instruction: req.body?.instruction,
|
||
},
|
||
{
|
||
suggestCoverMeta: req.body?.use_ai
|
||
? (input) => {
|
||
if (!authPool) {
|
||
throw Object.assign(new Error('LLM 服务未就绪'), { code: 'llm_not_configured' });
|
||
}
|
||
return suggestCoverMetaWithAi(authPool, {
|
||
...input,
|
||
encryptionKey:
|
||
process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY,
|
||
});
|
||
}
|
||
: null,
|
||
},
|
||
);
|
||
return sendData(res, req, result);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages/:pageId/rewrite-download-links', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const content = String(req.body?.content ?? '').trim();
|
||
if (!content) {
|
||
throw Object.assign(new Error('缺少页面内容'), { code: 'invalid_page_input' });
|
||
}
|
||
const html = await mindSpacePages.rewriteHtmlDownloadLinksForPage(
|
||
req.currentUser.id,
|
||
req.params.pageId,
|
||
content,
|
||
);
|
||
return sendData(res, req, { html });
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/mindspace/v1/pages/:pageId/preview', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const { html, contentFormat } = await mindSpacePages.renderPreview(
|
||
req.currentUser.id,
|
||
req.params.pageId,
|
||
);
|
||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||
res.set(
|
||
'Content-Security-Policy',
|
||
pageInternals.previewContentSecurityPolicy(contentFormat),
|
||
);
|
||
res.set('Cache-Control', 'private, no-store');
|
||
res.set('X-Content-Type-Options', 'nosniff');
|
||
return res.send(html);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages/:pageId/preview-draft', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const { html, contentFormat } = await mindSpacePages.renderDraftPreview(
|
||
req.currentUser.id,
|
||
req.params.pageId,
|
||
{
|
||
title: req.body?.title,
|
||
summary: req.body?.summary,
|
||
content: req.body?.content,
|
||
templateId: req.body?.template_id,
|
||
},
|
||
);
|
||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||
res.set(
|
||
'Content-Security-Policy',
|
||
pageInternals.previewContentSecurityPolicy(contentFormat),
|
||
);
|
||
res.set('Cache-Control', 'private, no-store');
|
||
res.set('X-Content-Type-Options', 'nosniff');
|
||
return res.send(html);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages/:pageId/publish-check', async (req, res) => {
|
||
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const result = await mindSpacePublications.check(req.currentUser.id, req.params.pageId, {
|
||
pageVersionId: req.body?.page_version_id,
|
||
accessMode: req.body?.access_mode,
|
||
urlSlug: req.body?.url_slug,
|
||
password: req.body?.password,
|
||
expiresAt: req.body?.expires_at,
|
||
});
|
||
return sendData(res, req, result);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages/:pageId/redact', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const result = await mindSpacePages.redactPage(req.currentUser.id, req.params.pageId, {
|
||
pageVersionId: req.body?.page_version_id,
|
||
expectedVersion: req.body?.expected_version,
|
||
title: req.body?.title,
|
||
summary: req.body?.summary,
|
||
content: req.body?.content,
|
||
});
|
||
await mindSpaceAudit?.write({
|
||
userId: req.currentUser.id,
|
||
action: 'page.redact',
|
||
objectType: 'page',
|
||
objectId: result.page.id,
|
||
ip: req.ip,
|
||
riskLevel: result.originalScan.riskLevel,
|
||
});
|
||
return sendData(res, req, result);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages/:pageId/publish-fix', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const result = await mindSpacePages.localizePrivateResources(req.currentUser.id, req.params.pageId, {
|
||
pageVersionId: req.body?.page_version_id,
|
||
expectedVersion: req.body?.expected_version,
|
||
title: req.body?.title,
|
||
summary: req.body?.summary,
|
||
content: req.body?.content,
|
||
});
|
||
await mindSpaceAudit?.write({
|
||
userId: req.currentUser.id,
|
||
action: 'page.publish_fix',
|
||
objectType: 'page',
|
||
objectId: result.page.id,
|
||
ip: req.ip,
|
||
riskLevel: result.originalScan.riskLevel,
|
||
});
|
||
return sendData(res, req, result);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages/:pageId/redacted-copy', async (req, res) => {
|
||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const result = await mindSpacePages.redactPage(req.currentUser.id, req.params.pageId, {
|
||
pageVersionId: req.body?.page_version_id,
|
||
expectedVersion: req.body?.expected_version,
|
||
title: req.body?.title,
|
||
summary: req.body?.summary,
|
||
content: req.body?.content,
|
||
});
|
||
await mindSpaceAudit?.write({
|
||
userId: req.currentUser.id,
|
||
action: 'page.redact',
|
||
objectType: 'page',
|
||
objectId: result.page.id,
|
||
ip: req.ip,
|
||
riskLevel: result.originalScan.riskLevel,
|
||
});
|
||
return sendData(res, req, result);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/pages/:pageId/publish', async (req, res) => {
|
||
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const publication = await mindSpacePublications.publish(
|
||
req.currentUser.id,
|
||
req.params.pageId,
|
||
{
|
||
pageVersionId: req.body?.page_version_id,
|
||
accessMode: req.body?.access_mode,
|
||
urlSlug: req.body?.url_slug,
|
||
password: req.body?.password,
|
||
expiresAt: req.body?.expires_at,
|
||
acknowledgedFindingIds: req.body?.acknowledged_finding_ids,
|
||
},
|
||
);
|
||
await mindSpaceAudit?.write({
|
||
userId: req.currentUser.id,
|
||
action: 'page.publish',
|
||
objectType: 'publication',
|
||
objectId: publication.id,
|
||
ip: req.ip,
|
||
});
|
||
return sendData(res, req, publication, 201);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/publications/:publicationId/update-status', async (req, res) => {
|
||
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const publication = await mindSpacePublications.updatePublicationStatus(
|
||
req.currentUser.id,
|
||
req.params.publicationId,
|
||
{
|
||
accessMode: req.body?.access_mode,
|
||
expiresAt: req.body?.expires_at,
|
||
},
|
||
);
|
||
await mindSpaceAudit?.write({
|
||
userId: req.currentUser.id,
|
||
action: 'publication.status_updated',
|
||
objectType: 'publication',
|
||
objectId: req.params.publicationId,
|
||
ip: req.ip,
|
||
});
|
||
return sendData(res, req, publication);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/mindspace/v1/publications/:publicationId/offline', async (req, res) => {
|
||
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
const publication = await mindSpacePublications.offline(
|
||
req.currentUser.id,
|
||
req.params.publicationId,
|
||
);
|
||
await mindSpaceAudit?.write({
|
||
userId: req.currentUser.id,
|
||
action: 'page.offline',
|
||
objectType: 'publication',
|
||
objectId: req.params.publicationId,
|
||
ip: req.ip,
|
||
});
|
||
return sendData(res, req, publication);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/mindspace/v1/publications/:publicationId/stats', async (req, res) => {
|
||
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||
try {
|
||
return sendData(
|
||
res,
|
||
req,
|
||
await mindSpacePublications.getStats(
|
||
req.currentUser.id,
|
||
req.params.publicationId,
|
||
),
|
||
);
|
||
} catch (error) {
|
||
return mindSpaceError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/plaza/v1/categories', async (req, res) => {
|
||
if (!ensurePlazaEnabled(res, req)) return;
|
||
try {
|
||
return sendData(res, req, { categories: await plazaPosts.listCategories() });
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/plaza/v1/seo/sitemap', async (req, res) => {
|
||
if (!ensurePlazaEnabled(res, req)) return;
|
||
if (!plazaSeo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza SEO 未启用');
|
||
try {
|
||
const data = await plazaSeo.listSitemapData({
|
||
postLimit: req.query.post_limit,
|
||
userLimit: req.query.user_limit,
|
||
});
|
||
return sendData(res, req, data);
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/plaza/v1/attribution/events', async (req, res) => {
|
||
if (!plazaSeo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
|
||
try {
|
||
const result = await plazaSeo.recordAttribution(req.body ?? {}, plazaClientIp(req));
|
||
return sendData(res, req, result, 201);
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/plaza/v1/posts/:id/reports', async (req, res) => {
|
||
if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
|
||
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
||
try {
|
||
const report = await plazaOps.createReport(req.currentUser.id, {
|
||
target_type: 'post',
|
||
target_id: req.params.id,
|
||
reason: req.body?.reason,
|
||
detail: req.body?.detail,
|
||
});
|
||
return sendData(res, req, { report }, 201);
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/plaza/v1/comments/:id/reports', async (req, res) => {
|
||
if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
|
||
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
||
try {
|
||
const report = await plazaOps.createReport(req.currentUser.id, {
|
||
target_type: 'comment',
|
||
target_id: req.params.id,
|
||
reason: req.body?.reason,
|
||
detail: req.body?.detail,
|
||
});
|
||
return sendData(res, req, { report }, 201);
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/plaza/v1/feed', async (req, res) => {
|
||
if (!ensurePlazaEnabled(res, req)) return;
|
||
try {
|
||
const sessionId = resolvePlazaSessionId(req, res);
|
||
const feed = await plazaPosts.listFeed({
|
||
sort: req.query.sort,
|
||
categorySlug: req.query.category ?? null,
|
||
cursor: req.query.cursor ?? null,
|
||
limit: req.query.limit,
|
||
viewerId: req.currentUser?.id ?? null,
|
||
sessionId,
|
||
});
|
||
return sendData(res, req, feed);
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/plaza/v1/events', async (req, res) => {
|
||
if (!ensurePlazaEnabled(res, req)) return;
|
||
if (!plazaEvents) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
|
||
try {
|
||
const sessionId =
|
||
String(req.body?.session_id ?? '').trim() || resolvePlazaSessionId(req, res);
|
||
const result = await plazaEvents.recordEvents({
|
||
userId: req.currentUser?.id ?? null,
|
||
sessionId,
|
||
events: req.body?.events ?? [],
|
||
});
|
||
return sendData(res, req, { ...result, session_id: sessionId }, 201);
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/plaza/v1/posts/:id/reactions', async (req, res) => {
|
||
if (!ensurePlazaInteractions(res, req)) return;
|
||
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
||
try {
|
||
const result = await plazaInteractions.addReaction(
|
||
req.currentUser.id,
|
||
req.params.id,
|
||
req.body?.type,
|
||
);
|
||
const eventType = reactionEventType(result.type);
|
||
if (eventType) {
|
||
recordPlazaEventsAsync(req, res, [{ event_type: eventType, post_id: req.params.id }]);
|
||
}
|
||
return sendData(res, req, result);
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.delete('/plaza/v1/posts/:id/reactions/:type', async (req, res) => {
|
||
if (!ensurePlazaInteractions(res, req)) return;
|
||
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
||
try {
|
||
const result = await plazaInteractions.removeReaction(
|
||
req.currentUser.id,
|
||
req.params.id,
|
||
req.params.type,
|
||
);
|
||
return sendData(res, req, result);
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/plaza/v1/posts/:id/comments', async (req, res) => {
|
||
if (!ensurePlazaInteractions(res, req)) return;
|
||
try {
|
||
const comments = await plazaInteractions.listComments(req.params.id, {
|
||
cursor: req.query.cursor ?? null,
|
||
limit: req.query.limit,
|
||
parentId: req.query.parent_id ?? null,
|
||
viewerId: req.currentUser?.id ?? null,
|
||
});
|
||
return sendData(res, req, comments);
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/plaza/v1/posts/:id/comments', async (req, res) => {
|
||
if (!ensurePlazaInteractions(res, req)) return;
|
||
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
||
try {
|
||
const comment = await plazaInteractions.createComment(req.currentUser.id, req.params.id, req.body ?? {});
|
||
recordPlazaEventsAsync(req, res, [{ event_type: 'comment', post_id: req.params.id }]);
|
||
return sendData(res, req, { comment }, 201);
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.delete('/plaza/v1/comments/:id', async (req, res) => {
|
||
if (!ensurePlazaInteractions(res, req)) return;
|
||
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
||
try {
|
||
const comment = await plazaInteractions.deleteComment(req.currentUser.id, req.params.id);
|
||
return sendData(res, req, { comment });
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/plaza/v1/comments/:id/reactions', async (req, res) => {
|
||
if (!ensurePlazaInteractions(res, req)) return;
|
||
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
||
try {
|
||
const liked = req.body?.liked !== false;
|
||
const result = await plazaInteractions.toggleCommentLike(req.currentUser.id, req.params.id, liked);
|
||
return sendData(res, req, result);
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/plaza/v1/users/:slug', async (req, res) => {
|
||
if (!ensurePlazaInteractions(res, req)) return;
|
||
try {
|
||
const profile = await plazaInteractions.getUserProfile(
|
||
req.params.slug,
|
||
req.currentUser?.id ?? null,
|
||
);
|
||
return sendData(res, req, profile);
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/plaza/v1/users/:slug/posts', async (req, res) => {
|
||
if (!ensurePlazaInteractions(res, req)) return;
|
||
try {
|
||
const feed = await plazaInteractions.listUserPosts(req.params.slug, {
|
||
cursor: req.query.cursor ?? null,
|
||
limit: req.query.limit,
|
||
viewerId: req.currentUser?.id ?? null,
|
||
});
|
||
return sendData(res, req, feed);
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/plaza/v1/users/:slug/follow', async (req, res) => {
|
||
if (!ensurePlazaInteractions(res, req)) return;
|
||
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
||
try {
|
||
const result = await plazaInteractions.followUser(req.currentUser.id, req.params.slug);
|
||
return sendData(res, req, result);
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.delete('/plaza/v1/users/:slug/follow', async (req, res) => {
|
||
if (!ensurePlazaInteractions(res, req)) return;
|
||
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
||
try {
|
||
const result = await plazaInteractions.unfollowUser(req.currentUser.id, req.params.slug);
|
||
return sendData(res, req, result);
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.get('/plaza/v1/posts/:id', async (req, res) => {
|
||
if (!ensurePlazaEnabled(res, req)) return;
|
||
try {
|
||
const post = await plazaPosts.getPostById(req.params.id, {
|
||
viewerId: req.currentUser?.id ?? null,
|
||
});
|
||
void plazaRedis.recordView(req.params.id, plazaClientIp(req)).catch(() => {});
|
||
recordPlazaEventsAsync(req, res, [{ event_type: 'view', post_id: req.params.id }]);
|
||
return sendData(res, req, { post });
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.post('/plaza/v1/posts', async (req, res) => {
|
||
if (!ensurePlazaEnabled(res, req)) return;
|
||
if (!req.currentUser) {
|
||
return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
||
}
|
||
try {
|
||
const post = await plazaPosts.createPost(req.currentUser.id, req.body ?? {});
|
||
return sendData(res, req, { post }, 201);
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.patch('/plaza/v1/posts/:id', async (req, res) => {
|
||
if (!ensurePlazaEnabled(res, req)) return;
|
||
if (!req.currentUser) {
|
||
return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
||
}
|
||
try {
|
||
const post = await plazaPosts.updatePost(req.currentUser.id, req.params.id, req.body ?? {});
|
||
return sendData(res, req, { post });
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
api.delete('/plaza/v1/posts/:id', async (req, res) => {
|
||
if (!ensurePlazaEnabled(res, req)) return;
|
||
if (!req.currentUser) {
|
||
return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
|
||
}
|
||
try {
|
||
const post = await plazaPosts.hidePost(req.currentUser.id, req.params.id);
|
||
return sendData(res, req, { post });
|
||
} catch (error) {
|
||
return plazaRouteError(res, req, error);
|
||
}
|
||
});
|
||
|
||
function runHandlerChain(chain, req, res, next) {
|
||
let index = 0;
|
||
const run = (err) => {
|
||
if (err) return next(err);
|
||
const layer = chain[index++];
|
||
if (!layer) return;
|
||
layer(req, res, (error) => run(error));
|
||
};
|
||
run();
|
||
}
|
||
|
||
api.post('/llm/apply-local-fallback', async (req, res) => {
|
||
// Client calls after creditsExhausted or relay 500 (see useTKMindChat).
|
||
await userAuthReady;
|
||
if (!userAuth || !llmProviderService || !tkmindProxy) {
|
||
return res.status(503).json({ message: '未启用 LLM 配置' });
|
||
}
|
||
const me = await userAuth.getMe(userToken(req));
|
||
if (!me) return res.status(401).json({ message: '未登录' });
|
||
const sessionId = String(req.body?.session_id ?? '').trim();
|
||
if (!sessionId) return res.status(400).json({ message: '缺少 session_id' });
|
||
const owns = await userAuth.ownsSession(me.id, sessionId);
|
||
if (!owns) return res.status(403).json({ message: '无权访问该会话' });
|
||
try {
|
||
const result = await tkmindProxy.applyLocalFallbackForSession(sessionId);
|
||
if (!result.ok) return res.status(503).json(result);
|
||
res.json(result);
|
||
} catch (err) {
|
||
res.status(500).json({
|
||
message: err instanceof Error ? err.message : '切换本地 LLM 失败',
|
||
});
|
||
}
|
||
});
|
||
|
||
api.post('/agent/start', async (req, res, next) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !tkmindProxy) return next();
|
||
return runHandlerChain(tkmindProxy.handlers['POST /agent/start'], req, res, next);
|
||
});
|
||
|
||
api.post('/agent/runs', async (req, res, next) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !tkmindProxy || !agentRunGateway) return next();
|
||
return runHandlerChain(
|
||
[
|
||
tkmindProxy.requireUser,
|
||
tkmindProxy.ensureChatAllowed,
|
||
createPostAgentRunsHandler({ userAuth, agentRunGateway }),
|
||
],
|
||
req,
|
||
res,
|
||
next,
|
||
);
|
||
});
|
||
|
||
api.get('/agent/runs/:runId', async (req, res, next) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !tkmindProxy || !agentRunGateway) return next();
|
||
return runHandlerChain(
|
||
[
|
||
tkmindProxy.requireUser,
|
||
createGetAgentRunHandler({ agentRunGateway }),
|
||
],
|
||
req,
|
||
res,
|
||
next,
|
||
);
|
||
});
|
||
|
||
api.get('/agent/runs/:runId/events', async (req, res, next) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !tkmindProxy || !agentRunGateway) return next();
|
||
return runHandlerChain(
|
||
[
|
||
tkmindProxy.requireUser,
|
||
createAgentRunEventsHandler({ agentRunGateway }),
|
||
],
|
||
req,
|
||
res,
|
||
next,
|
||
);
|
||
});
|
||
|
||
api.post('/agent/resume', async (req, res, next) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !tkmindProxy) return next();
|
||
return runHandlerChain(tkmindProxy.handlers['POST /agent/resume'], req, res, next);
|
||
});
|
||
|
||
api.get('/sessions', async (req, res, next) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !tkmindProxy) return next();
|
||
return runHandlerChain(tkmindProxy.handlers['GET /sessions'], req, res, next);
|
||
});
|
||
|
||
// Session detail — serve from DB snapshot cache when fresh, fall through to Goose on miss.
|
||
api.get('/sessions/:sessionId', async (req, res, next) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !tkmindProxy) return next();
|
||
const sessionId = req.params.sessionId;
|
||
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
|
||
if (!owns) {
|
||
return res.status(403).json({ message: '无权访问该会话' });
|
||
}
|
||
// Hints from the client (session list already has these values).
|
||
const hintMc = req.query.hint_mc ? Number(req.query.hint_mc) : null;
|
||
const hintUa = req.query.hint_ua ? String(req.query.hint_ua) : null;
|
||
|
||
try {
|
||
if (sessionSnapshotService?.isEnabled()) {
|
||
const snapshot = await sessionSnapshotService.get(sessionId);
|
||
if (snapshot) {
|
||
// REGRESSION GUARD: without both hints, stale snapshot can wipe mid-turn chat.
|
||
const canUseSnapshotCache = hintMc != null && hintUa != null;
|
||
const mcMatch = snapshot.meta.synced_msg_count === hintMc;
|
||
const uaMatch = snapshot.meta.source_updated_at === hintUa;
|
||
if (canUseSnapshotCache && mcMatch && uaMatch) {
|
||
const sanitizedMessages = sanitizeSessionConversationPublicHtmlLinks(
|
||
snapshot.messages,
|
||
req.currentUser,
|
||
);
|
||
// Cache hit — reconstruct a Goose-compatible session response.
|
||
const cachedGooseSession = {
|
||
...snapshot.session,
|
||
// Embed only userVisible messages so getSession callers still work.
|
||
conversation: sanitizedMessages,
|
||
};
|
||
return res.json(cachedGooseSession);
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// Snapshot read error: fall through silently to Goose.
|
||
}
|
||
|
||
// Cache miss — proxy to Goose and write-through on success.
|
||
try {
|
||
const target = await tkmindProxy.resolveTarget(sessionId);
|
||
const upstream = await tkmindProxy.apiFetchTo(
|
||
target,
|
||
`/sessions/${encodeURIComponent(sessionId)}`,
|
||
{ method: 'GET' },
|
||
);
|
||
if (!upstream.ok) {
|
||
const text = await upstream.text().catch(() => '');
|
||
return res.status(upstream.status).send(text);
|
||
}
|
||
const gooseSession = await upstream.json();
|
||
if (Array.isArray(gooseSession.conversation)) {
|
||
gooseSession.conversation = sanitizeSessionConversationPublicHtmlLinks(
|
||
gooseSession.conversation,
|
||
req.currentUser,
|
||
);
|
||
}
|
||
// Write-through: persist snapshot async, don't block the response.
|
||
if (sessionSnapshotService?.isEnabled()) {
|
||
const messages = (gooseSession.conversation ?? [])
|
||
.filter((m) => m.metadata?.userVisible);
|
||
if (messages.length > 0) {
|
||
void sessionSnapshotService
|
||
.save(sessionId, req.currentUser.id, gooseSession, messages)
|
||
.catch(() => {});
|
||
}
|
||
}
|
||
return res.json(gooseSession);
|
||
} catch (err) {
|
||
return res.status(502).json({ message: err instanceof Error ? err.message : '读取会话失败' });
|
||
}
|
||
});
|
||
|
||
api.delete('/sessions/:sessionId', async (req, res, next) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !tkmindProxy) return next();
|
||
const sessionId = req.params.sessionId;
|
||
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
|
||
if (!owns) {
|
||
return res.status(403).json({ message: '无权访问该会话' });
|
||
}
|
||
try {
|
||
const deleteTarget = await tkmindProxy.resolveTarget(sessionId);
|
||
const upstream = await tkmindProxy.apiFetchTo(deleteTarget, `/sessions/${encodeURIComponent(sessionId)}`, {
|
||
method: 'DELETE',
|
||
});
|
||
if (!upstream.ok && upstream.status !== 404) {
|
||
const text = await upstream.text().catch(() => '');
|
||
return res.status(upstream.status).send(text || '删除会话失败');
|
||
}
|
||
await userAuth.unregisterAgentSession(req.currentUser.id, sessionId);
|
||
// Remove snapshot so it doesn't linger after deletion.
|
||
void sessionSnapshotService?.remove(sessionId).catch(() => {});
|
||
return res.status(204).end();
|
||
} catch (err) {
|
||
return res.status(500).json({ message: err instanceof Error ? err.message : '删除会话失败' });
|
||
}
|
||
});
|
||
|
||
api.get('/sessions/:sessionId/events', async (req, res, next) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !tkmindProxy) return next();
|
||
const sessionId = req.params.sessionId;
|
||
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
|
||
if (!owns) {
|
||
return res.status(403).json({ message: '无权访问该会话' });
|
||
}
|
||
const publishDir = resolvePublishDir(__dirname, { id: req.currentUser.id });
|
||
const syncPublicHtmlDuringStream = (event) => {
|
||
materializePublicHtmlWritesFromSessionEvent(event, { publishDir });
|
||
};
|
||
// After Finish, refresh the snapshot and persist any newly generated public
|
||
// workspace HTML into the asset store before a later restart rebuilds the
|
||
// workspace from DB-backed assets only.
|
||
const onAfterFinish = async (sid, uid) => {
|
||
const apiFetchFn = async (pathname, init) => {
|
||
const target = await tkmindProxy.resolveTarget(sid);
|
||
return tkmindProxy.apiFetchTo(target, pathname, init);
|
||
};
|
||
let messages = null;
|
||
if (sessionSnapshotService?.isEnabled()) {
|
||
await sessionSnapshotService.refresh(sid, uid, apiFetchFn);
|
||
messages = (await sessionSnapshotService.get(sid))?.messages ?? null;
|
||
} else {
|
||
try {
|
||
const upstream = await apiFetchFn(`/sessions/${encodeURIComponent(sid)}`, { method: 'GET' });
|
||
if (upstream.ok) {
|
||
const payload = await upstream.json().catch(() => null);
|
||
messages = Array.isArray(payload?.conversation)
|
||
? payload.conversation.filter((message) => message?.metadata?.userVisible)
|
||
: null;
|
||
}
|
||
} catch {
|
||
messages = null;
|
||
}
|
||
}
|
||
const syncResult = await syncPublicHtmlAfterFinish({
|
||
messages,
|
||
currentUser: req.currentUser,
|
||
publishDir,
|
||
syncWorkspaceAssets:
|
||
WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets
|
||
? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options)
|
||
: null,
|
||
});
|
||
if (Array.isArray(syncResult?.docxSync?.missing) && syncResult.docxSync.missing.length > 0) {
|
||
console.warn(
|
||
`[MindSpace] missing public download files after finish for user ${uid}: ${syncResult.docxSync.missing.join(', ')}`,
|
||
);
|
||
}
|
||
await syncUserGeneratedPages(uid);
|
||
};
|
||
return tkmindProxy.proxySessionEvents(req, res, sessionId, {
|
||
onAfterFinish,
|
||
onEvent: syncPublicHtmlDuringStream,
|
||
});
|
||
});
|
||
|
||
api.use(async (req, res, next) => {
|
||
await userAuthReady;
|
||
if (!userAuth || !tkmindProxy) return next();
|
||
|
||
if (isNativeH5ApiPath(req.path)) {
|
||
return sendError(res, req, 404, 'not_found', `接口不存在:${req.method} ${req.path}`);
|
||
}
|
||
|
||
if (req.method === 'GET' && /^\/agent\/runs\/[^/]+\/events$/.test(req.path)) {
|
||
return res.status(503).json({
|
||
message: 'Agent Run SSE 接口需在 Portal 本地处理,请确认 server.mjs 已更新并重启后端',
|
||
code: 'AGENT_RUNS_NATIVE_REQUIRED',
|
||
});
|
||
}
|
||
|
||
const sessionMatch = req.path.match(/^\/sessions\/([^/]+)/);
|
||
if (sessionMatch) {
|
||
const sessionId = sessionMatch[1];
|
||
if (req.path.endsWith('/events') && req.method === 'GET') {
|
||
return next();
|
||
}
|
||
if (req.path.endsWith('/reply') && req.method === 'POST') {
|
||
return res.status(410).json({
|
||
message: '聊天提交入口已统一为 POST /agent/runs',
|
||
code: 'AGENT_RUNS_REQUIRED',
|
||
});
|
||
}
|
||
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
|
||
if (!owns) {
|
||
return res.status(403).json({ message: '无权访问该会话' });
|
||
}
|
||
}
|
||
|
||
if (req.method === 'POST' && req.path === '/agent/resume' && req.body?.session_id) {
|
||
const owns = await userAuth.ownsSession(req.currentUser.id, req.body.session_id);
|
||
if (!owns) {
|
||
return res.status(403).json({ message: '无权访问该会话' });
|
||
}
|
||
}
|
||
|
||
if (req.body?.working_dir) {
|
||
const allowed = await userAuth.isPathAllowed(req.currentUser.id, req.body.working_dir);
|
||
if (!allowed) {
|
||
return res.status(403).json({ message: '工作目录不在授权范围内' });
|
||
}
|
||
}
|
||
|
||
return tkmindProxy.proxyFallback(req, res);
|
||
});
|
||
|
||
|
||
api.use(
|
||
createProxyMiddleware({
|
||
target: API_TARGET,
|
||
changeOrigin: true,
|
||
secure: false,
|
||
pathRewrite: { '^/api': '' },
|
||
on: {
|
||
proxyReq: (proxyReq) => {
|
||
proxyReq.setHeader('X-Secret-Key', API_SECRET);
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
|
||
app.use('/api', api);
|
||
|
||
function scriptSrcDirective({ inline = false, urls = [], hashes = [] } = {}) {
|
||
const parts = [];
|
||
if (inline) parts.push("'unsafe-inline'");
|
||
for (const hash of hashes) parts.push(`'sha256-${hash}'`);
|
||
for (const url of urls) parts.push(url);
|
||
return parts.length ? `script-src ${parts.join(' ')}` : "script-src 'none'";
|
||
}
|
||
|
||
function publishedPageCsp(html, { embed = false, raw = false, wechatShare = false, scriptHashes = [] } = {}) {
|
||
const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
|
||
if (embed && isFullHtml) {
|
||
return publishedPageCspForEmbed(true);
|
||
}
|
||
if (wechatShare && isFullHtml) {
|
||
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline' https://res.wx.qq.com";
|
||
}
|
||
if (raw && isFullHtml) {
|
||
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline'";
|
||
}
|
||
if (isFullHtml) {
|
||
return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrcDirective({ hashes: scriptHashes })}`;
|
||
}
|
||
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'";
|
||
}
|
||
|
||
const PUBLIC_FILE_SHARE_SCRIPT = `(function(){var root=document.querySelector('[data-mindspace-public-share]');if(!root)return;var button=root.querySelector('button');var status=root.querySelector('small');var timer=null;function setStatus(text,err){if(!status)return;status.textContent=text||'';status.classList.toggle('is-error',!!err);if(timer)clearTimeout(timer);if(text)timer=setTimeout(function(){status.textContent='';status.classList.remove('is-error');},2200);}function fallbackCopy(text){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();var ok=document.execCommand('copy');ta.remove();if(!ok)throw new Error('复制失败');}async function copyText(text){if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(text);return;}catch(e){}}fallbackCopy(text);}button&&button.addEventListener('click',async function(){var url=location.href.split('#')[0];var title=document.title||'MindSpace';try{if(navigator.share){await navigator.share({title:title,url:url});setStatus('已打开分享');return;}await copyText(url);setStatus('链接已复制');}catch(e){setStatus(e&&e.message?e.message:'分享失败',true);}});})();`;
|
||
const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto
|
||
.createHash('sha256')
|
||
.update(PUBLIC_FILE_SHARE_SCRIPT)
|
||
.digest('base64');
|
||
|
||
function injectPublicFileShareButton(html) {
|
||
const source = String(html ?? '');
|
||
if (!source || source.includes('data-mindspace-public-share')) {
|
||
return { html: source, scriptHashes: [] };
|
||
}
|
||
const markup = `
|
||
<style id="mindspace-public-share-style">
|
||
[data-mindspace-public-share]{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;gap:6px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||
[data-mindspace-public-share] button{border:0;border-radius:999px;padding:10px 15px;background:#2f6f57;color:#fff;font-size:14px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.18);cursor:pointer}
|
||
[data-mindspace-public-share] button:active{transform:translateY(1px)}
|
||
[data-mindspace-public-share] small{min-height:18px;max-width:180px;border-radius:999px;padding:4px 9px;background:rgba(255,255,255,.92);color:#245845;font-size:12px;text-align:right;box-shadow:0 4px 14px rgba(0,0,0,.1)}
|
||
[data-mindspace-public-share] small:empty{display:none}
|
||
[data-mindspace-public-share] small.is-error{color:#8b2d20}
|
||
@media(max-width:640px){[data-mindspace-public-share]{right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px))}[data-mindspace-public-share] button{padding:9px 13px;font-size:13px}}
|
||
</style>
|
||
<div data-mindspace-public-share><small aria-live="polite"></small><button type="button">公开分享</button></div>
|
||
<script>${PUBLIC_FILE_SHARE_SCRIPT}</script>`;
|
||
if (/<\/body>/i.test(source)) {
|
||
return {
|
||
html: source.replace(/<\/body>/i, `${markup}</body>`),
|
||
scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH],
|
||
};
|
||
}
|
||
return {
|
||
html: `${source}${markup}`,
|
||
scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH],
|
||
};
|
||
}
|
||
|
||
function extractOgImageUrl(html) {
|
||
return (
|
||
String(html ?? '').match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)?.[1] ||
|
||
String(html ?? '').match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i)?.[1] ||
|
||
''
|
||
);
|
||
}
|
||
|
||
function extractShareMetaFromPageHtml(html) {
|
||
const signals = extractCoverSignals(String(html ?? ''));
|
||
return {
|
||
title: detectPublishedPageTitle(html),
|
||
subtitle: signals.subtitle,
|
||
};
|
||
}
|
||
|
||
function appendQueryParam(url, key, value) {
|
||
const separator = url.includes('?') ? '&' : '?';
|
||
return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||
}
|
||
|
||
function resolveRequestOrigin(req) {
|
||
const host = (req.headers['x-forwarded-host'] || req.headers.host || '').toString().split(',')[0].trim();
|
||
if (!host) return '';
|
||
const isLocalHost = /^(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|100\.)/i.test(host);
|
||
const fwdProto = (req.headers['x-forwarded-proto'] || '').toString().split(',')[0].trim();
|
||
const proto = isLocalHost ? fwdProto || req.protocol || 'http' : 'https';
|
||
return `${proto}://${host}`;
|
||
}
|
||
|
||
function detectPublishedPageTitle(html) {
|
||
const match = String(html ?? '').match(/<title[^>]*>([^<]+)<\/title>/i);
|
||
return match?.[1]?.replace(/\s+/g, ' ').trim() || 'MindSpace 页面';
|
||
}
|
||
|
||
function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
|
||
const iframeSrc = escapePublicHtml(iframeUrl);
|
||
const safeShareUrl = escapePublicHtml(shareUrl);
|
||
const safeTitle = escapePublicHtml(title);
|
||
const serializedShareUrl = JSON.stringify(shareUrl).replace(/</g, '\\u003c');
|
||
const serializedTitle = JSON.stringify(title).replace(/</g, '\\u003c');
|
||
return `<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'">
|
||
<title>${safeTitle}</title>
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
html, body { margin: 0; min-height: 100%; background: #f6f1e7; }
|
||
body { font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif; color: #18211d; }
|
||
.publication-shell { position: relative; min-height: 100vh; }
|
||
.publication-frame { display: block; width: 100%; min-height: 100vh; border: 0; background: #fff; }
|
||
.publication-share-fab {
|
||
position: fixed;
|
||
right: 18px;
|
||
bottom: calc(18px + env(safe-area-inset-bottom, 0px));
|
||
z-index: 50;
|
||
border: 0;
|
||
border-radius: 999px;
|
||
padding: 14px 18px;
|
||
background: linear-gradient(135deg, #2f6f57, #1f4c3c);
|
||
color: #fffdf7;
|
||
font: 700 14px/1 ui-sans-serif, sans-serif;
|
||
letter-spacing: .04em;
|
||
box-shadow: 0 20px 48px rgba(24, 33, 29, .24);
|
||
cursor: pointer;
|
||
}
|
||
.publication-share-sheet[hidden] { display: none; }
|
||
.publication-share-sheet {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 60;
|
||
display: grid;
|
||
place-items: end center;
|
||
padding: 16px;
|
||
background: rgba(7, 12, 10, 0.56);
|
||
}
|
||
.publication-share-panel {
|
||
width: min(480px, 100%);
|
||
padding: 18px 18px 22px;
|
||
border-radius: 24px 24px 20px 20px;
|
||
background: #fffaf2;
|
||
box-shadow: 0 24px 80px rgba(24, 33, 29, 0.24);
|
||
}
|
||
.publication-share-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.publication-share-header p {
|
||
margin: 0;
|
||
color: #9b6518;
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
letter-spacing: .16em;
|
||
text-transform: uppercase;
|
||
}
|
||
.publication-share-header h2 {
|
||
margin: 6px 0 0;
|
||
font: 500 28px/1.1 Georgia, 'Times New Roman', serif;
|
||
color: #18211d;
|
||
}
|
||
.publication-share-header strong {
|
||
display: block;
|
||
margin-top: 6px;
|
||
color: #68716c;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
.publication-share-close {
|
||
width: 36px;
|
||
height: 36px;
|
||
border: 0;
|
||
border-radius: 999px;
|
||
background: rgba(24, 33, 29, 0.08);
|
||
color: #18211d;
|
||
font-size: 24px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
}
|
||
.publication-share-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
.publication-share-option {
|
||
display: grid;
|
||
gap: 8px;
|
||
justify-items: center;
|
||
padding: 14px 10px;
|
||
border: 1px solid rgba(24, 33, 29, 0.08);
|
||
border-radius: 18px;
|
||
background: rgba(255, 255, 255, 0.72);
|
||
color: #18211d;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
}
|
||
.publication-share-option:disabled {
|
||
opacity: .6;
|
||
cursor: wait;
|
||
}
|
||
.publication-share-option span {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 14px;
|
||
color: #fff;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
.publication-share-option[data-action="wechat"] span { background: linear-gradient(145deg, #07c160, #06ad56); }
|
||
.publication-share-option[data-action="copy"] span { background: linear-gradient(145deg, #2f6f57, #245845); }
|
||
.publication-share-option[data-action="capture"] span { background: linear-gradient(145deg, #8f6b2f, #6f5121); }
|
||
.publication-share-link, .publication-share-message {
|
||
margin-top: 16px;
|
||
padding: 12px 14px;
|
||
border-radius: 14px;
|
||
font-size: 13px;
|
||
}
|
||
.publication-share-link {
|
||
background: rgba(24, 33, 29, 0.05);
|
||
}
|
||
.publication-share-link code {
|
||
display: block;
|
||
margin-top: 6px;
|
||
word-break: break-all;
|
||
color: #445049;
|
||
font-size: 12px;
|
||
}
|
||
.publication-share-message {
|
||
display: none;
|
||
color: #245845;
|
||
background: rgba(47, 111, 87, 0.12);
|
||
}
|
||
.publication-share-message.is-visible {
|
||
display: block;
|
||
}
|
||
@media (max-width: 480px) {
|
||
.publication-share-sheet {
|
||
padding: 0;
|
||
place-items: end stretch;
|
||
}
|
||
.publication-share-panel {
|
||
width: 100%;
|
||
border-radius: 24px 24px 0 0;
|
||
}
|
||
.publication-share-fab {
|
||
right: 14px;
|
||
bottom: calc(14px + env(safe-area-inset-bottom, 0px));
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="publication-shell">
|
||
<!-- Game-like publications need script execution, but not same-origin sandbox escape. -->
|
||
<iframe class="publication-frame" title="${safeTitle}" src="${iframeSrc}" sandbox="allow-scripts allow-downloads"></iframe>
|
||
</div>
|
||
<button type="button" class="publication-share-fab" id="publication-share-fab">分享</button>
|
||
<div class="publication-share-sheet" id="publication-share-sheet" hidden>
|
||
<section class="publication-share-panel" role="dialog" aria-modal="true" aria-labelledby="publication-share-title">
|
||
<header class="publication-share-header">
|
||
<div>
|
||
<p>Share</p>
|
||
<h2 id="publication-share-title">分享页面</h2>
|
||
<strong>${safeTitle}</strong>
|
||
</div>
|
||
<button type="button" class="publication-share-close" id="publication-share-close" aria-label="关闭">×</button>
|
||
</header>
|
||
<div class="publication-share-grid">
|
||
<button type="button" class="publication-share-option" data-action="wechat"><span>微</span>微信</button>
|
||
<button type="button" class="publication-share-option" data-action="copy"><span>链</span>复制链接</button>
|
||
<button type="button" class="publication-share-option" data-action="capture"><span>图</span>保存长图</button>
|
||
</div>
|
||
<div class="publication-share-link">
|
||
分享链接
|
||
<code>${safeShareUrl}</code>
|
||
</div>
|
||
<div class="publication-share-message" id="publication-share-message" role="status"></div>
|
||
</section>
|
||
</div>
|
||
<script>
|
||
(function () {
|
||
var shareUrl = ${serializedShareUrl};
|
||
var shareTitle = ${serializedTitle};
|
||
var fab = document.getElementById('publication-share-fab');
|
||
var sheet = document.getElementById('publication-share-sheet');
|
||
var close = document.getElementById('publication-share-close');
|
||
var message = document.getElementById('publication-share-message');
|
||
var frame = document.querySelector('.publication-frame');
|
||
var actionButtons = sheet ? Array.prototype.slice.call(sheet.querySelectorAll('.publication-share-option')) : [];
|
||
var html2canvasLoader = null;
|
||
function setBusy(busy) {
|
||
actionButtons.forEach(function (button) {
|
||
button.disabled = !!busy;
|
||
button.setAttribute('aria-busy', busy ? 'true' : 'false');
|
||
});
|
||
}
|
||
function setMessage(text, isError) {
|
||
if (!message) return;
|
||
message.textContent = text || '';
|
||
message.classList.toggle('is-visible', Boolean(text));
|
||
message.style.color = isError ? '#8b2d20' : '#245845';
|
||
message.style.background = isError ? 'rgba(139, 45, 32, 0.12)' : 'rgba(47, 111, 87, 0.12)';
|
||
}
|
||
function openSheet() {
|
||
if (sheet) sheet.hidden = false;
|
||
setMessage('');
|
||
}
|
||
function closeSheet() {
|
||
if (sheet) sheet.hidden = true;
|
||
}
|
||
async function copyText(text) {
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
return;
|
||
} catch {
|
||
// Fall back to execCommand for browsers where clipboard API exists but is blocked.
|
||
}
|
||
}
|
||
var textarea = document.createElement('textarea');
|
||
textarea.value = text;
|
||
textarea.style.position = 'fixed';
|
||
textarea.style.opacity = '0';
|
||
textarea.style.left = '-9999px';
|
||
document.body.appendChild(textarea);
|
||
textarea.focus();
|
||
textarea.select();
|
||
var copied = document.execCommand('copy');
|
||
textarea.remove();
|
||
if (!copied) throw new Error('复制失败,请手动复制链接');
|
||
}
|
||
function loadHtml2Canvas() {
|
||
if (window.html2canvas) return Promise.resolve(window.html2canvas);
|
||
if (html2canvasLoader) return html2canvasLoader;
|
||
html2canvasLoader = new Promise(function (resolve, reject) {
|
||
var script = document.createElement('script');
|
||
script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
|
||
script.async = true;
|
||
script.onload = function () {
|
||
if (!window.html2canvas) {
|
||
reject(new Error('无法加载页面截图能力'));
|
||
return;
|
||
}
|
||
resolve(window.html2canvas);
|
||
};
|
||
script.onerror = function () { reject(new Error('无法加载页面截图能力')); };
|
||
document.body.appendChild(script);
|
||
}).catch(function (error) {
|
||
html2canvasLoader = null;
|
||
throw error;
|
||
});
|
||
return html2canvasLoader;
|
||
}
|
||
async function saveLongImage() {
|
||
if (!frame || !frame.contentDocument || !frame.contentDocument.body) {
|
||
throw new Error('页面内容暂时不可访问');
|
||
}
|
||
var renderer = await loadHtml2Canvas();
|
||
var doc = frame.contentDocument;
|
||
var body = doc.body;
|
||
var width = Math.max(doc.documentElement ? doc.documentElement.scrollWidth : 0, body.scrollWidth, 320);
|
||
var height = Math.max(doc.documentElement ? doc.documentElement.scrollHeight : 0, body.scrollHeight, 320);
|
||
var canvas = await renderer(body, {
|
||
useCORS: true,
|
||
scale: 2,
|
||
backgroundColor: '#ffffff',
|
||
width: width,
|
||
height: height,
|
||
windowWidth: width,
|
||
windowHeight: height,
|
||
x: 0,
|
||
y: 0,
|
||
scrollX: 0,
|
||
scrollY: 0
|
||
});
|
||
var link = document.createElement('a');
|
||
var timestamp = new Date().toISOString().replace(/[T:]/g, '-').replace(/\..+/, '');
|
||
link.href = canvas.toDataURL('image/png');
|
||
link.download = 'mindspace-public-page-' + timestamp + '.png';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
}
|
||
if (fab) fab.addEventListener('click', openSheet);
|
||
if (close) close.addEventListener('click', closeSheet);
|
||
if (sheet) {
|
||
sheet.addEventListener('click', function (event) {
|
||
if (event.target === sheet) closeSheet();
|
||
});
|
||
actionButtons.forEach(function (button) {
|
||
button.addEventListener('click', async function () {
|
||
var action = button.getAttribute('data-action');
|
||
try {
|
||
setBusy(true);
|
||
setMessage('');
|
||
if (action === 'wechat') {
|
||
await copyText([shareTitle, shareUrl].filter(Boolean).join('\\n'));
|
||
setMessage('文案已复制,请打开微信粘贴分享');
|
||
return;
|
||
}
|
||
if (action === 'copy') {
|
||
await copyText(shareUrl);
|
||
setMessage('链接已复制');
|
||
return;
|
||
}
|
||
if (action === 'capture') {
|
||
setMessage('正在生成长图,请稍候');
|
||
await saveLongImage();
|
||
setMessage('长图已保存');
|
||
}
|
||
} catch (error) {
|
||
setMessage(error && error.message ? error.message : '操作失败,请稍后再试', true);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
function syncFrameHeight() {
|
||
if (!frame) return;
|
||
var doc = frame.contentDocument;
|
||
var body = doc && doc.body;
|
||
var root = doc && doc.documentElement;
|
||
var height = Math.max(
|
||
body ? body.scrollHeight : 0,
|
||
body ? body.offsetHeight : 0,
|
||
root ? root.scrollHeight : 0,
|
||
root ? root.offsetHeight : 0,
|
||
window.innerHeight,
|
||
320
|
||
);
|
||
frame.style.height = height + 'px';
|
||
}
|
||
if (frame) {
|
||
frame.addEventListener('load', function () {
|
||
syncFrameHeight();
|
||
window.setTimeout(syncFrameHeight, 300);
|
||
});
|
||
}
|
||
window.addEventListener('message', function (event) {
|
||
if (!event || !event.data || event.data.type !== 'plaza:embed-section' || !frame) return;
|
||
if (event.data.height) {
|
||
frame.style.height = Math.max(Number(event.data.height) || 0, window.innerHeight, 320) + 'px';
|
||
}
|
||
});
|
||
window.addEventListener('resize', syncFrameHeight);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) {
|
||
let html = result.html;
|
||
const origin = resolveRequestOrigin(req);
|
||
const pageUrl = req.originalUrl ? new URL(req.originalUrl, origin || 'http://localhost').toString().split('#')[0] : '';
|
||
const pageDirUrl = pageUrl ? `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}` : '';
|
||
const wechatShare = !embed && isWechatUserAgent(req.get('user-agent') || '');
|
||
if (embed) {
|
||
html = preparePublicationHtmlForEmbed(html);
|
||
allowPlazaEmbedFrame(res);
|
||
} else if (raw) {
|
||
html = stripPublicationHtmlCspMeta(html);
|
||
}
|
||
if (!embed) {
|
||
try {
|
||
html = injectOgTags(html, { origin, pageUrl, pageDirUrl });
|
||
if (wechatShare) html = injectWechatShareBridge(html, { pageUrl });
|
||
} catch {
|
||
// Never block public page delivery on share metadata injection.
|
||
}
|
||
}
|
||
const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
|
||
const canWrapWithShell = !embed && !raw && isFullHtml && result.publication?.accessMode !== 'password';
|
||
if (canWrapWithShell) {
|
||
const title = detectPublishedPageTitle(html);
|
||
const rawUrl = appendQueryParam(req.originalUrl || req.url || '', 'view', 'raw');
|
||
let shellHtml = publishedPageShellHtml({
|
||
iframeUrl: rawUrl,
|
||
shareUrl: pageUrl,
|
||
title,
|
||
});
|
||
try {
|
||
shellHtml = injectOgTags(shellHtml, {
|
||
origin,
|
||
pageUrl,
|
||
pageDirUrl,
|
||
fallbackImageUrl: extractOgImageUrl(html),
|
||
meta: extractShareMetaFromPageHtml(html),
|
||
});
|
||
if (wechatShare) shellHtml = injectWechatShareBridge(shellHtml, { pageUrl });
|
||
} catch {
|
||
// Keep the share shell usable even if metadata injection fails.
|
||
}
|
||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||
res.set(
|
||
'Content-Security-Policy',
|
||
wechatShare
|
||
? "default-src 'none'; style-src 'unsafe-inline'; img-src data: https:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net https://res.wx.qq.com; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"
|
||
: "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
|
||
);
|
||
res.set(
|
||
'Cache-Control',
|
||
result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store',
|
||
);
|
||
return res.send(shellHtml);
|
||
}
|
||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||
res.set('Content-Security-Policy', publishedPageCsp(html, { embed, raw, wechatShare }));
|
||
res.set(
|
||
'Cache-Control',
|
||
result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store',
|
||
);
|
||
return res.send(html);
|
||
}
|
||
|
||
function passwordGateHtml(action) {
|
||
return `<!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(req, res, result, {
|
||
embed: isPlazaEmbedRequest(req.query),
|
||
raw: String(req.query.view ?? '').toLowerCase() === 'raw',
|
||
});
|
||
} catch (error) {
|
||
if (error?.code === 'publication_password_required') {
|
||
return res.status(password ? 403 : 200).send(passwordGateHtml(req.originalUrl));
|
||
}
|
||
if (error?.code === 'publication_login_required') {
|
||
return res.status(401).send('请先登录 TKMind 后再访问此页面');
|
||
}
|
||
if (error?.code === 'publication_not_found') return res.status(404).send('页面不存在或已下线');
|
||
return res.status(500).send('页面加载失败');
|
||
}
|
||
}
|
||
|
||
app.use(async (req, res, next) => {
|
||
const thumbnailMatch = /^\/u\/([^/]+)\/pages\/([^/]+)\.thumbnail\.png$/.exec(req.path);
|
||
if (!thumbnailMatch) return next();
|
||
const ownerSlug = thumbnailMatch[1];
|
||
const urlSlug = thumbnailMatch[2];
|
||
await userAuthReady;
|
||
if (!authPool || !mindSpacePages) return res.status(503).send('MindSpace 未启用');
|
||
try {
|
||
const [rows] = await authPool.query(
|
||
`SELECT pr.user_id, pr.page_id
|
||
FROM h5_publish_records pr
|
||
JOIN h5_users u ON u.id = pr.user_id
|
||
WHERE COALESCE(u.slug, u.username) = ?
|
||
AND pr.url_slug = ?
|
||
AND pr.status = 'online'
|
||
ORDER BY pr.published_at DESC
|
||
LIMIT 1`,
|
||
[ownerSlug, urlSlug],
|
||
);
|
||
const row = rows[0];
|
||
if (!row) return res.status(404).send('缩略图不存在');
|
||
const svg = await mindSpacePages.renderThumbnail(row.user_id, row.page_id);
|
||
res.set('Content-Type', 'image/png');
|
||
res.set('Cache-Control', 'public, max-age=300');
|
||
return res.send(rasterizeThumbnailSvgToPng(svg));
|
||
} catch (error) {
|
||
return res.status(500).send('缩略图加载失败');
|
||
}
|
||
});
|
||
|
||
app.get('/u/:ownerSlug/pages/:urlSlug', async (req, res) => {
|
||
return resolvePublishedRoute(req, res);
|
||
});
|
||
|
||
app.get('/u/:ownerSlug', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用');
|
||
try {
|
||
const data = await mindSpacePublications.getPublicHomepage(req.params.ownerSlug);
|
||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||
res.set(
|
||
'Content-Security-Policy',
|
||
"default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
|
||
);
|
||
res.set('Cache-Control', 'public, max-age=60');
|
||
return res.send(publicHomepageHtml(data));
|
||
} catch (error) {
|
||
if (error?.code === 'publication_not_found') return res.status(404).send('主页不存在');
|
||
return res.status(500).send('主页加载失败');
|
||
}
|
||
});
|
||
|
||
app.post(
|
||
'/u/:ownerSlug/pages/:urlSlug',
|
||
express.urlencoded({ extended: false, limit: '2kb' }),
|
||
async (req, res) => resolvePublishedRoute(req, res, req.body?.password),
|
||
);
|
||
|
||
app.get('/s/:token', async (req, res) => {
|
||
await userAuthReady;
|
||
if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用');
|
||
try {
|
||
const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null;
|
||
return sendPublishedPage(
|
||
req,
|
||
res,
|
||
await mindSpacePublications.resolvePrivateLink(req.params.token, viewer?.id, {
|
||
userAgent: req.get('user-agent'),
|
||
referrer: req.get('referer'),
|
||
}),
|
||
{
|
||
embed: isPlazaEmbedRequest(req.query),
|
||
raw: String(req.query.view ?? '').toLowerCase() === 'raw',
|
||
},
|
||
);
|
||
} catch (error) {
|
||
if (error?.code === 'publication_not_found') return res.status(404).send('页面不存在或已下线');
|
||
return res.status(500).send('页面加载失败');
|
||
}
|
||
});
|
||
|
||
const USERNAME_SLUG = /^[a-z0-9_]{2,32}$/;
|
||
|
||
async function resolvePublishDirKey(segment) {
|
||
const lower = String(segment ?? '').trim().toLowerCase();
|
||
if (!lower) return null;
|
||
if (PUBLISH_KEY_UUID.test(lower)) return lower;
|
||
if (USERNAME_SLUG.test(lower)) {
|
||
if (authPool) {
|
||
const [rows] = await authPool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [lower]);
|
||
if (rows[0]?.id) return String(rows[0].id).toLowerCase();
|
||
}
|
||
return lower;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Send a file, injecting Open Graph tags for .html so forwarded links unfurl with a cover.
|
||
* Non-HTML files (assets, etc.) are streamed unchanged via res.sendFile.
|
||
*/
|
||
function sendPublishFile(req, res, filePath) {
|
||
if (!filePath.toLowerCase().endsWith('.html')) {
|
||
res.sendFile(filePath, (err) => {
|
||
if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' });
|
||
});
|
||
return;
|
||
}
|
||
let html;
|
||
try {
|
||
html = fs.readFileSync(filePath, 'utf8');
|
||
} catch {
|
||
res.status(404).json({ message: '文件不存在' });
|
||
return;
|
||
}
|
||
const embed = isPlazaEmbedRequest(req.query);
|
||
if (embed) {
|
||
html = preparePublicationHtmlForEmbed(html);
|
||
allowPlazaEmbedFrame(res);
|
||
res.set('Content-Security-Policy', publishedPageCsp(html, { embed }));
|
||
}
|
||
const host = (req.headers['x-forwarded-host'] || req.headers.host || '').toString().split(',')[0].trim();
|
||
// Share cards (esp. WeChat) require https og:image. The edge only serves public domains
|
||
// over https, but the proxy chain forwards X-Forwarded-Proto: http to the node — so for a
|
||
// public host we force https and ignore the (wrong) forwarded scheme. Loopback/LAN stays http.
|
||
const isLocalHost = /^(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|100\.)/i.test(host);
|
||
const fwdProto = (req.headers['x-forwarded-proto'] || '').toString().split(',')[0].trim();
|
||
const proto = isLocalHost ? fwdProto || req.protocol || 'http' : 'https';
|
||
const origin = host ? `${proto}://${host}` : '';
|
||
const cleanPath = req.originalUrl.split('?')[0].split('#')[0];
|
||
// Distinguish an explicit file URL (.../space.html) from a directory that resolves to
|
||
// index.html (.../<uuid> or .../<uuid>/) so relative covers and the thumbnail resolve correctly.
|
||
const servedName = path.basename(filePath);
|
||
const urlLast = decodeURIComponent(cleanPath.split('/').filter(Boolean).pop() ?? '');
|
||
const isImplicitIndex = urlLast.toLowerCase() !== servedName.toLowerCase();
|
||
const pageUrl = origin
|
||
? `${origin}${isImplicitIndex && !cleanPath.endsWith('/') ? `${cleanPath}/` : cleanPath}`
|
||
: '';
|
||
const pageDirUrl = !origin
|
||
? ''
|
||
: isImplicitIndex
|
||
? `${origin}${cleanPath.endsWith('/') ? cleanPath : `${cleanPath}/`}`
|
||
: `${origin}${cleanPath.slice(0, cleanPath.lastIndexOf('/') + 1)}`;
|
||
// Guaranteed fallback cover: the page's feed thumbnail, served on demand as PNG.
|
||
let fallbackImageUrl = '';
|
||
const svgSibling = filePath.replace(/\.[^./]+$/, '.thumbnail.svg');
|
||
if (pageDirUrl && fs.existsSync(svgSibling)) {
|
||
const pngName = path.basename(thumbnailPngPathForSvg(svgSibling));
|
||
fallbackImageUrl = `${pageDirUrl}${pngName}`;
|
||
}
|
||
try {
|
||
html = injectOgTags(html, { origin, pageUrl, pageDirUrl, fallbackImageUrl });
|
||
const wechatShare = !embed && isWechatUserAgent(req.get('user-agent') || '');
|
||
if (wechatShare) {
|
||
html = injectWechatShareBridge(html, { pageUrl });
|
||
}
|
||
const shareInjection = !embed
|
||
? injectPublicFileShareButton(html)
|
||
: { html, scriptHashes: [] };
|
||
html = shareInjection.html;
|
||
res.set('Content-Security-Policy', publishedPageCsp(html, {
|
||
embed,
|
||
wechatShare,
|
||
scriptHashes: shareInjection.scriptHashes,
|
||
}));
|
||
} catch {
|
||
// On any parse failure, fall back to the original HTML — never break page delivery.
|
||
res.set('Content-Security-Policy', publishedPageCsp(html, { embed }));
|
||
}
|
||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||
res.send(html);
|
||
}
|
||
|
||
const MISPLACED_PUBLIC_HTML_NAME = /^[a-z0-9][a-z0-9._-]{0,127}\.html$/i;
|
||
|
||
async function recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest) {
|
||
if (rest.length !== 2 || rest[0] !== PUBLIC_ZONE_DIR) return null;
|
||
const filename = rest[1];
|
||
if (!MISPLACED_PUBLIC_HTML_NAME.test(filename)) return null;
|
||
|
||
const destination = path.resolve(targetDir, PUBLIC_ZONE_DIR, filename);
|
||
if (!destination.startsWith(`${resolvedRoot}${path.sep}`)) return null;
|
||
const candidates = [
|
||
path.resolve(__dirname, filename),
|
||
path.resolve(__dirname, PUBLIC_ZONE_DIR, filename),
|
||
];
|
||
for (const candidate of candidates) {
|
||
if (candidate === destination) continue;
|
||
if (!candidate.startsWith(`${__dirname}${path.sep}`)) continue;
|
||
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) continue;
|
||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||
fs.copyFileSync(candidate, destination);
|
||
await ensureWorkspaceHtmlThumbnail(targetDir, `${PUBLIC_ZONE_DIR}/${filename}`).catch(() => {});
|
||
console.warn(
|
||
`[MindSpace] recovered misplaced public HTML ${path.relative(__dirname, candidate)} -> ${path.relative(__dirname, destination)}`,
|
||
);
|
||
return destination;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function decodePathSegment(segment) {
|
||
try {
|
||
return decodeURIComponent(segment);
|
||
} catch {
|
||
return segment;
|
||
}
|
||
}
|
||
|
||
async function serveUserPublishFile(req, res, next) {
|
||
const parts = req.path.split('/').filter(Boolean);
|
||
if (parts.length < 1) {
|
||
res.status(404).json({ message: '未找到页面' });
|
||
return;
|
||
}
|
||
|
||
const dirKey = await resolvePublishDirKey(parts[0]);
|
||
if (!dirKey) {
|
||
res.status(404).json({ message: '未找到页面' });
|
||
return;
|
||
}
|
||
|
||
if (parts[0].toLowerCase() !== dirKey && USERNAME_SLUG.test(parts[0])) {
|
||
const rest = parts.slice(1).map(encodeURIComponent).join('/');
|
||
const target = `/${PUBLISH_ROOT_DIR}/${dirKey}${rest ? `/${rest}` : '/'}`;
|
||
res.redirect(301, target);
|
||
return;
|
||
}
|
||
|
||
const [username, ...rest] = [dirKey, ...parts.slice(1).map(decodePathSegment)];
|
||
const targetDir = path.join(__dirname, PUBLISH_ROOT_DIR, username);
|
||
const resolvedRoot = path.resolve(targetDir);
|
||
const filePath = path.join(targetDir, ...rest);
|
||
const resolvedPath = path.resolve(filePath);
|
||
|
||
if (!resolvedPath.startsWith(`${resolvedRoot}${path.sep}`) && resolvedPath !== resolvedRoot) {
|
||
res.status(403).json({ message: '禁止访问' });
|
||
return;
|
||
}
|
||
|
||
if (!fs.existsSync(targetDir)) {
|
||
res.status(404).json({ message: '用户不存在' });
|
||
return;
|
||
}
|
||
|
||
// On-demand cover: rasterize <base>.thumbnail.svg → .thumbnail.png the first time a
|
||
// forwarded link's og:image is fetched (and refresh it when the SVG changes).
|
||
if (/\.thumbnail\.png$/i.test(resolvedPath)) {
|
||
const svgSibling = resolvedPath.replace(/\.png$/i, '.svg');
|
||
if (fs.existsSync(svgSibling)) {
|
||
const pngPath = ensureThumbnailPng(svgSibling);
|
||
if (pngPath && fs.existsSync(pngPath)) {
|
||
res.set('Cache-Control', 'public, max-age=300');
|
||
res.sendFile(pngPath);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!fs.existsSync(resolvedPath)) {
|
||
if (rest.length === 1 && rest[0].toLowerCase().endsWith('.html')) {
|
||
const publicFallback = path.resolve(targetDir, PUBLIC_ZONE_DIR, rest[0]);
|
||
if (
|
||
publicFallback.startsWith(`${resolvedRoot}${path.sep}`) &&
|
||
fs.existsSync(publicFallback) &&
|
||
fs.statSync(publicFallback).isFile()
|
||
) {
|
||
const canonical = `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(username)}/${PUBLIC_ZONE_DIR}/${encodeURIComponent(rest[0])}`;
|
||
res.redirect(301, canonical);
|
||
return;
|
||
}
|
||
}
|
||
if (rest.length === 2 && rest[0] === PUBLIC_ZONE_DIR && rest[1].toLowerCase().endsWith('.html')) {
|
||
const similarRelativePath = await resolveClosestHtmlRelativePath(targetDir, `${PUBLIC_ZONE_DIR}/${rest[1]}`);
|
||
if (similarRelativePath) {
|
||
const canonical = `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(username)}/${similarRelativePath
|
||
.split('/')
|
||
.map((part) => encodeURIComponent(part))
|
||
.join('/')}`;
|
||
res.redirect(301, canonical);
|
||
return;
|
||
}
|
||
}
|
||
const recoveredPublicHtml = await recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest);
|
||
if (recoveredPublicHtml) {
|
||
sendPublishFile(req, res, recoveredPublicHtml);
|
||
return;
|
||
}
|
||
res.status(404).json({ message: '文件不存在' });
|
||
return;
|
||
}
|
||
|
||
if (fs.statSync(resolvedPath).isDirectory()) {
|
||
const indexPath = path.join(resolvedPath, 'index.html');
|
||
if (fs.existsSync(indexPath)) {
|
||
sendPublishFile(req, res, indexPath);
|
||
return;
|
||
}
|
||
res.status(404).json({ message: '目录中没有 index.html' });
|
||
return;
|
||
}
|
||
|
||
sendPublishFile(req, res, resolvedPath);
|
||
}
|
||
|
||
app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => {
|
||
await userAuthReady;
|
||
return serveUserPublishFile(req, res, next);
|
||
});
|
||
|
||
app.use('/temp', (req, res) => {
|
||
res.redirect(301, `/${PUBLISH_ROOT_DIR}${req.url}`);
|
||
});
|
||
|
||
// Serve user public pages: /user/<username>/path/to/file
|
||
// These are public-facing pages (like travel guides) stored in user directories
|
||
// Accessed via URL like https://goo.tkmind.cn/user/john/vietnam-guide/
|
||
app.use('/user', async (req, res, next) => {
|
||
await userAuthReady;
|
||
// Path format: /user/<username>/<rest...>
|
||
const parts = req.path.split('/').filter(Boolean);
|
||
if (parts.length < 1) return next();
|
||
|
||
const [username, ...rest] = parts;
|
||
const targetDir = path.join(USERS_ROOT, username);
|
||
|
||
// Security: ensure resolved path is within the user's directory
|
||
const filePath = path.join(targetDir, ...rest);
|
||
const resolvedRoot = path.resolve(targetDir);
|
||
const resolvedPath = path.resolve(filePath);
|
||
|
||
if (!resolvedPath.startsWith(resolvedRoot + path.sep) && resolvedPath !== resolvedRoot) {
|
||
return res.status(403).json({ message: '禁止访问' });
|
||
}
|
||
|
||
// Check if the user directory exists
|
||
if (!fs.existsSync(targetDir)) {
|
||
return res.status(404).json({ message: '用户不存在' });
|
||
}
|
||
|
||
if (!fs.existsSync(resolvedPath)) {
|
||
return res.status(404).json({ message: '文件不存在' });
|
||
}
|
||
|
||
// If it's a directory, serve index.html
|
||
if (fs.statSync(resolvedPath).isDirectory()) {
|
||
const indexPath = path.join(resolvedPath, 'index.html');
|
||
if (fs.existsSync(indexPath)) {
|
||
return res.sendFile(indexPath);
|
||
}
|
||
return res.status(404).json({ message: '目录中没有 index.html' });
|
||
}
|
||
|
||
res.sendFile(resolvedPath, (err) => {
|
||
if (err) res.status(404).json({ message: '文件不存在' });
|
||
});
|
||
});
|
||
|
||
app.get(`/${PUBLISH_ROOT_DIR}/wiki/*`, (_req, res) => {
|
||
res.sendFile(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki', 'index.html'));
|
||
});
|
||
|
||
app.get('/temp/wiki/*', (req, res) => {
|
||
res.redirect(301, `/${PUBLISH_ROOT_DIR}/wiki${req.url.slice('/temp/wiki'.length)}`);
|
||
});
|
||
|
||
app.use(
|
||
'/plaza-covers',
|
||
express.static(path.join(__dirname, 'public/plaza-covers'), { maxAge: '7d' }),
|
||
);
|
||
app.use('/brand', express.static(path.join(__dirname, 'public/brand'), { maxAge: '7d' }));
|
||
|
||
app.get('/dev/wechat-share-preview', async (req, res) => {
|
||
try {
|
||
const rawTarget = String(req.query.url ?? req.query.path ?? '').trim();
|
||
if (!rawTarget) {
|
||
return res.status(400).type('text/plain; charset=utf-8').send('缺少 url 参数,例如 ?url=/MindSpace/john/public/demo.html');
|
||
}
|
||
const origin = resolveRequestOrigin(req) || `http://${HOST}:${PORT}`;
|
||
const targetUrl = rawTarget.startsWith('http') ? new URL(rawTarget) : new URL(rawTarget.startsWith('/') ? rawTarget : `/${rawTarget}`, origin);
|
||
const upstream = await fetch(targetUrl.toString(), {
|
||
headers: {
|
||
'user-agent': req.get('user-agent') || 'tkmind-wechat-share-preview',
|
||
accept: 'text/html,*/*',
|
||
},
|
||
redirect: 'follow',
|
||
});
|
||
if (!upstream.ok) {
|
||
return res.status(upstream.status).type('text/plain; charset=utf-8').send(`页面请求失败:HTTP ${upstream.status}`);
|
||
}
|
||
let html = await upstream.text();
|
||
const pageUrl = targetUrl.toString().split('#')[0];
|
||
const pageDirUrl = `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}`;
|
||
html = injectOgTags(html, { origin: targetUrl.origin, pageUrl, pageDirUrl });
|
||
const preview = extractSharePreviewMeta(html, {
|
||
origin: targetUrl.origin,
|
||
pageUrl,
|
||
pageDirUrl,
|
||
});
|
||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||
res.set('Cache-Control', 'no-store');
|
||
return res.send(
|
||
renderWechatSharePreviewHtml(preview, {
|
||
note: '此预览读取页面最终 HTML 中的 Open Graph 标签,可用来对照微信里粘贴链接后的卡片效果。',
|
||
}),
|
||
);
|
||
} catch (err) {
|
||
return res.status(500).type('text/plain; charset=utf-8').send(String(err?.message ?? err));
|
||
}
|
||
});
|
||
app.use('/dev', express.static(path.join(__dirname, 'public/dev'), { maxAge: 0 }));
|
||
|
||
app.get(/^\/MP_verify_[A-Za-z0-9]+\.txt$/, (req, res) => {
|
||
const fileName = path.basename(req.path);
|
||
const filePath = path.join(__dirname, 'public', fileName);
|
||
if (!fs.existsSync(filePath)) return res.status(404).end();
|
||
res.type('text/plain').sendFile(filePath);
|
||
});
|
||
|
||
app.use('/auth', (_req, res) => {
|
||
res.status(404).json({ message: '接口不存在,请重启后端服务(node server.mjs 或 pnpm dev)' });
|
||
});
|
||
app.use('/admin-api', (_req, res) => {
|
||
res.status(404).json({ message: '接口不存在,请重启后端服务(node server.mjs 或 pnpm dev)' });
|
||
});
|
||
|
||
app.use(express.static(path.join(__dirname, 'dist'), { index: 'index.html' }));
|
||
app.get('*', (_req, res) => {
|
||
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
||
});
|
||
|
||
userAuthReady.then((enabled) => {
|
||
if (isDatabaseConfigured() && !enabled && process.env.NODE_ENV === 'production') {
|
||
console.error('Refusing to start portal without user auth while database is configured');
|
||
process.exit(1);
|
||
}
|
||
app.listen(PORT, HOST, () => {
|
||
console.log(`TKMind H5 @ http://${HOST}:${PORT}`);
|
||
console.log(`Proxy -> ${API_TARGETS.join(', ')}`);
|
||
console.log(`Auth -> ${enabled ? 'multi-user (MySQL)' : legacyAuth ? 'legacy password' : 'disabled'}`);
|
||
console.log(`Wiki @ http://${HOST}:${PORT}/${PUBLISH_ROOT_DIR}/wiki`);
|
||
});
|
||
});
|