merge: 0629001 into 0629002
合并反馈、语音 ASR、MindSpace 修复等 0629001 发布改动,并与 Agent Runs 网关改动完成冲突解决。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+432
-62
@@ -14,7 +14,7 @@ import {
|
||||
} from './auth.mjs';
|
||||
import { createDbPool, initSchema, isDatabaseConfigured } from './db.mjs';
|
||||
import { createAgentRunGateway } from './agent-run-gateway.mjs';
|
||||
import { createTkmindProxy } from './tkmind-proxy.mjs';
|
||||
import { createTkmindProxy, sanitizeSessionConversationPublicHtmlLinks } from './tkmind-proxy.mjs';
|
||||
import {
|
||||
clearUserSessionCookie,
|
||||
createUserAuth,
|
||||
@@ -38,7 +38,7 @@ import { createPageService, pageInternals, inlinePrivateAssetsInHtml } from './m
|
||||
import { createPageLiveEditService } from './mindspace-page-live-edit.mjs';
|
||||
import { createPageEditSessionService } from './mindspace-page-edit-session.mjs';
|
||||
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
|
||||
import { createPublicationService } from './mindspace-publications.mjs';
|
||||
import { 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';
|
||||
@@ -78,8 +78,10 @@ import {
|
||||
resolveChatSaveAnalysis,
|
||||
resolveStaticHtmlContent,
|
||||
} from './mindspace-chat-save.mjs';
|
||||
import { syncPublicHtmlAfterFinish } from './mindspace-public-finish-sync.mjs';
|
||||
import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs';
|
||||
import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
|
||||
import { injectOgTags } from './mindspace-og-tags.mjs';
|
||||
import { injectOgTags, injectWechatShareBridge } from './mindspace-og-tags.mjs';
|
||||
import {
|
||||
ensureThumbnailPng,
|
||||
rasterizeThumbnailSvgToPng,
|
||||
@@ -96,7 +98,9 @@ import {
|
||||
} 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';
|
||||
@@ -203,16 +207,13 @@ const rawUploadBody = express.raw({
|
||||
type: 'application/octet-stream',
|
||||
limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
|
||||
});
|
||||
const rawUploadBodyImage = express.raw({
|
||||
type: 'application/octet-stream',
|
||||
limit: 2 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const wikiAuth = createWikiAuth(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki-db'));
|
||||
|
||||
let legacyAuth = null;
|
||||
if (ACCESS_PASSWORD) {
|
||||
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;
|
||||
@@ -243,6 +244,7 @@ let wechatPayClient = null;
|
||||
let wechatOAuthService = null;
|
||||
let wechatMpService = null;
|
||||
let scheduleService = null;
|
||||
let feedbackService = null;
|
||||
let scheduleReminderWorker = null;
|
||||
let llmProviderService = null;
|
||||
let wordFilterService = null;
|
||||
@@ -260,6 +262,7 @@ async function bootstrapUserAuth() {
|
||||
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),
|
||||
@@ -612,6 +615,12 @@ async function bootstrapUserAuth() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -668,6 +677,13 @@ app.get('/auth/status', async (req, res) => {
|
||||
unrestricted: capabilityState.unrestricted,
|
||||
});
|
||||
}
|
||||
if (isDatabaseConfigured()) {
|
||||
return res.status(503).json({
|
||||
authenticated: false,
|
||||
mode: 'unavailable',
|
||||
message: '用户认证服务不可用,请稍后重试',
|
||||
});
|
||||
}
|
||||
if (legacyAuth) {
|
||||
return res.json({
|
||||
authenticated: legacyAuth.verify(legacySessionToken(req)),
|
||||
@@ -782,15 +798,11 @@ app.get('/auth/wechat/js-sdk-signature', async (req, res) => {
|
||||
return res.status(400).json({ message: '缺少 url' });
|
||||
}
|
||||
try {
|
||||
const publicHost = WECHAT_MP_CONFIG.publicBaseUrl
|
||||
? new URL(WECHAT_MP_CONFIG.publicBaseUrl).host
|
||||
: null;
|
||||
const target = new URL(pageUrl);
|
||||
const requestHost = req.get('x-forwarded-host') || req.get('host') || '';
|
||||
if (publicHost && target.host !== publicHost && target.host !== requestHost) {
|
||||
return res.status(400).json({ message: 'url 不属于当前 H5 域名' });
|
||||
}
|
||||
const payload = await wechatMpService.createJsSdkSignature(pageUrl);
|
||||
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 签名失败';
|
||||
@@ -799,6 +811,29 @@ app.get('/auth/wechat/js-sdk-signature', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
@@ -1066,6 +1101,83 @@ app.get('/auth/usage', async (req, res) => {
|
||||
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) {
|
||||
@@ -1634,6 +1746,115 @@ api.get('/status', async (_req, res, next) => {
|
||||
return next();
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -2110,7 +2331,7 @@ api.post('/mindspace/v1/uploads', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
api.put('/mindspace/v1/uploads/:uploadId/content', rawUploadBodyImage, async (req, res) => {
|
||||
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(
|
||||
@@ -2433,25 +2654,23 @@ async function resolveOwnedAssistantMessage(userId, sessionId, messageId) {
|
||||
return { session, message, content };
|
||||
}
|
||||
|
||||
const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'private', 'public']);
|
||||
const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']);
|
||||
|
||||
function assertPrivateSaveAllowed(categoryCode, privacyScan, acknowledgedFindingIds) {
|
||||
if (categoryCode !== 'private') return;
|
||||
if (!privacyScan.allowed) {
|
||||
throw Object.assign(new Error('内容含阻断级敏感信息,不能保存到私人区'), {
|
||||
code: 'security_risk_blocked',
|
||||
details: { findings: privacyScan.findings },
|
||||
});
|
||||
}
|
||||
if (privacyScan.findings.length === 0) return;
|
||||
const acknowledged = new Set((acknowledgedFindingIds ?? []).map(String));
|
||||
const missing = privacyScan.findings.filter((finding) => !acknowledged.has(finding.id));
|
||||
if (missing.length > 0) {
|
||||
throw Object.assign(new Error('保存到私人区前需确认敏感信息提示'), {
|
||||
code: 'private_ack_required',
|
||||
details: { findings: missing },
|
||||
});
|
||||
}
|
||||
async function syncUserGeneratedPages(userId) {
|
||||
if (!mindSpacePages || !authPool || !userId) return;
|
||||
await 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);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveChatSaveBundle(user, h5Root, input = {}) {
|
||||
@@ -2692,11 +2911,13 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
|
||||
|
||||
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, localizedHtml, 'utf8');
|
||||
await fsPromises.writeFile(destPath, sharedHtml, 'utf8');
|
||||
|
||||
const publishKey = req.currentUser.id;
|
||||
const publicUrl = `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}/${PUBLIC_ZONE_DIR}/shared/${encodeURIComponent(filename)}`;
|
||||
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) {
|
||||
@@ -2740,7 +2961,6 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
|
||||
resolvedHtml = await resolveStaticHtmlContent(analysis).catch(() => null);
|
||||
}
|
||||
const privacyScan = scanContent(resolvedHtml?.content ?? source.content);
|
||||
assertPrivateSaveAllowed(categoryCode, privacyScan, req.body?.acknowledged_finding_ids);
|
||||
|
||||
if (categoryCode !== 'draft') {
|
||||
let buffer;
|
||||
@@ -2854,6 +3074,7 @@ api.post('/mindspace/v1/pages', async (req, res) => {
|
||||
api.get('/mindspace/v1/pages', async (req, res) => {
|
||||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||||
try {
|
||||
await syncUserGeneratedPages(req.currentUser.id);
|
||||
const pages = await mindSpacePages.listPages(req.currentUser.id, {
|
||||
status: typeof req.query.status === 'string' ? req.query.status : undefined,
|
||||
});
|
||||
@@ -3786,11 +4007,15 @@ api.get('/sessions/:sessionId', async (req, res, next) => {
|
||||
const mcMatch = hintMc == null || snapshot.meta.synced_msg_count === hintMc;
|
||||
const uaMatch = hintUa == null || snapshot.meta.source_updated_at === hintUa;
|
||||
if (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: snapshot.messages,
|
||||
conversation: sanitizedMessages,
|
||||
};
|
||||
return res.json(cachedGooseSession);
|
||||
}
|
||||
@@ -3813,6 +4038,12 @@ api.get('/sessions/:sessionId', async (req, res, next) => {
|
||||
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 ?? [])
|
||||
@@ -3863,14 +4094,42 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
|
||||
if (!owns) {
|
||||
return res.status(403).json({ message: '无权访问该会话' });
|
||||
}
|
||||
// After Finish, async-refresh snapshot so next open is a cache hit.
|
||||
const onAfterFinish = sessionSnapshotService?.isEnabled()
|
||||
? (sid, uid) =>
|
||||
sessionSnapshotService.refresh(sid, uid, async (pathname, init) => {
|
||||
const target = await tkmindProxy.resolveTarget(sid);
|
||||
return tkmindProxy.apiFetchTo(target, pathname, init);
|
||||
})
|
||||
: null;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
await syncPublicHtmlAfterFinish({
|
||||
messages,
|
||||
currentUser: req.currentUser,
|
||||
publishDir: resolvePublishDir(__dirname, { id: uid }),
|
||||
syncWorkspaceAssets:
|
||||
WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets
|
||||
? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options)
|
||||
: null,
|
||||
});
|
||||
await syncUserGeneratedPages(uid);
|
||||
};
|
||||
return tkmindProxy.proxySessionEvents(req, res, sessionId, { onAfterFinish });
|
||||
});
|
||||
|
||||
@@ -3934,20 +4193,74 @@ api.use(
|
||||
|
||||
app.use('/api', api);
|
||||
|
||||
function publishedPageCsp(html, { embed = false, raw = false } = {}) {
|
||||
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 data: https:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline'";
|
||||
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 data: https:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'none'";
|
||||
return `default-src 'none'; style-src 'unsafe-inline' 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 appendQueryParam(url, key, value) {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
@@ -4333,36 +4646,60 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
|
||||
|
||||
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),
|
||||
});
|
||||
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',
|
||||
"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'",
|
||||
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(
|
||||
publishedPageShellHtml({
|
||||
iframeUrl: rawUrl,
|
||||
shareUrl: req.originalUrl ? new URL(req.originalUrl, resolveRequestOrigin(req) || 'http://localhost').toString() : '',
|
||||
title,
|
||||
}),
|
||||
);
|
||||
return res.send(shellHtml);
|
||||
}
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.set('Content-Security-Policy', publishedPageCsp(html, { embed, raw }));
|
||||
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',
|
||||
@@ -4669,8 +5006,22 @@ function sendPublishFile(req, res, filePath) {
|
||||
}
|
||||
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);
|
||||
@@ -4704,6 +5055,14 @@ async function recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest) {
|
||||
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) {
|
||||
@@ -4724,7 +5083,7 @@ async function serveUserPublishFile(req, res, next) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [username, ...rest] = [dirKey, ...parts.slice(1)];
|
||||
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);
|
||||
@@ -4873,12 +5232,23 @@ app.get(/^\/MP_verify_[A-Za-z0-9]+\.txt$/, (req, res) => {
|
||||
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(', ')}`);
|
||||
|
||||
Reference in New Issue
Block a user