feat: finalize mindspace service extraction phase a

This commit is contained in:
john
2026-07-03 08:40:28 +08:00
parent 506a551438
commit ec5b1a6e3f
32 changed files with 2834 additions and 490 deletions
+182 -464
View File
@@ -31,33 +31,52 @@ import {
} from './user-auth.mjs';
import { createWikiAuth } from './wiki-auth.mjs';
import { isLocalDevHostname } from './scripts/local-test-config.mjs';
import { buildPublicUrl, PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir, resolvePublicBaseUrl } from './user-publish.mjs';
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR } 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 { createMindSpaceService } 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 { createConversationPackageRegistry } from './mindspace-conversation-package-registry.mjs';
import { pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
import {
createDownloadConversationPackageArtifactHandler,
createDownloadConversationPackageManifestHandler,
createGetConversationPackageHandler,
} from './mindspace-conversation-package-routes.mjs';
import { backfillConversationPackageArtifacts } from './mindspace-conversation-package-backfill.mjs';
import { createConversationPackageStore } from './mindspace-conversation-package-store.mjs';
import { createMindSpaceServiceFacade } from './mindspace-service.mjs';
import { createLocalMindSpaceStorageAdapter } from './mindspace-storage-adapter.mjs';
import { createPageLiveEditService } from './mindspace-page-live-edit.mjs';
import { createAssetAgentService } from './mindspace-asset-agent.mjs';
import { registerPublicHtmlArtifactsForConversationPackage } from './mindspace-conversation-package-public-html.mjs';
import {
assertMindSpaceServerAdapterContract,
createMindSpaceServerAdapter,
} from './mindspace-server-adapter.mjs';
import {
resolveMindSpacePublicRequest,
} from './mindspace-public-route.mjs';
import {
buildPublishedHtmlViewContext,
resolvePublicRequestOrigin,
} from './mindspace-public-page-context.mjs';
import {
appendPublicAssetTokens,
verifyPublicAssetToken,
} from './mindspace-public-asset-token.mjs';
import {
decorateMindSpacePublishedHtml,
handleMindSpaceLongImageDownload,
} from './mindspace-public-delivery.mjs';
import {
buildMindSpacePublicRoutePath,
buildMindSpacePublicUrlForUser,
resolveMindSpaceRuntimeConfig,
resolveMindSpacePublishRoot,
resolveMindSpaceServerRuntimeOptions,
resolveMindSpaceUserPublishDir,
} from './mindspace-runtime-config.mjs';
import { createPageEditSessionService } from './mindspace-page-edit-session.mjs';
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
import {
createPublicationService,
publicationInternals,
rewriteWorkspacePublicAssetReferences,
} from './mindspace-publications.mjs';
@@ -82,8 +101,6 @@ import {
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,
@@ -101,7 +118,6 @@ import {
resolveStaticHtmlContent,
} from './mindspace-chat-save.mjs';
import {
collectOwnPublicHtmlArtifactRefs,
materializePublicHtmlWritesFromSessionEvent,
normalizePublicHtmlRelativePath,
syncPublicHtmlAfterFinish,
@@ -188,6 +204,7 @@ 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 mindSpaceServerRuntime = resolveMindSpaceServerRuntimeOptions(__dirname, process.env);
const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD;
const WECHAT_MP_CONFIG = loadWechatMpConfig();
// 无状态前端节点(如 105MindSpace 经 rclone 挂载)需设 MEMIND_WORKSPACE_MAINTENANCE=0
@@ -249,9 +266,9 @@ const jsonUnlessMultipart = (req, res, next) => {
};
const rawUploadBody = express.raw({
type: 'application/octet-stream',
limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
limit: mindSpaceServerRuntime.maxFileBytes,
});
const wikiAuth = createWikiAuth(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki-db'));
const wikiAuth = createWikiAuth(path.join(resolveMindSpacePublishRoot(__dirname), 'wiki-db'));
let legacyAuth = null;
if (ACCESS_PASSWORD && !isDatabaseConfigured()) {
@@ -313,43 +330,12 @@ async function bootstrapUserAuth() {
});
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),
maxFileBytes: mindSpaceServerRuntime.maxFileBytes,
aiDailyLimit: mindSpaceServerRuntime.aiDailyLimit,
publicPageLimit: mindSpaceServerRuntime.publicPageLimit,
monthlyViewLimit: mindSpaceServerRuntime.monthlyViewLimit,
scheduleService,
});
const mindSpaceStorageRoot =
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
mindSpaceServiceFacade = createMindSpaceServiceFacade({
storageAdapter: createLocalMindSpaceStorageAdapter(mindSpaceStorageRoot),
publicBaseUrl: resolvePublicBaseUrl(),
conversationPackageBackfill: ({ user, sessionId }) =>
backfillConversationPackageArtifacts({
pool,
registry: mindSpaceConversationPackageRegistry,
storageRoot: mindSpaceStorageRoot,
h5Root: __dirname,
user,
sessionId,
}),
conversationPackagePublicHtmlHydrator: hydratePublicHtmlArtifactsForConversationPackage,
});
mindSpaceConversationPackageRegistry = createConversationPackageRegistry({
store: createConversationPackageStore(pool),
service: mindSpaceServiceFacade,
});
mindSpaceAssets = createAssetService(pool, {
h5Root: __dirname,
storageRoot: mindSpaceStorageRoot,
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
conversationPackageRegistry: mindSpaceConversationPackageRegistry,
});
mindSpacePages = createPageService(pool, {
h5Root: __dirname,
storageRoot: mindSpaceStorageRoot,
conversationPackageRegistry: mindSpaceConversationPackageRegistry,
});
const resolveUserIdForAgentSession = async (sessionId) => {
const [rows] = await pool.query(
`SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`,
@@ -357,30 +343,36 @@ async function bootstrapUserAuth() {
);
return rows[0]?.user_id ?? null;
};
mindSpacePageLiveEdit = createPageLiveEditService({
pageService: mindSpacePages,
resolveUserIdForAgentSession,
});
mindSpaceAssetAgent = createAssetAgentService({
assetService: mindSpaceAssets,
resolveUserIdForAgentSession,
});
mindSpacePublications = createPublicationService(pool, {
const mindSpaceRuntimeAdapter = assertMindSpaceServerAdapterContract(
createMindSpaceServerAdapter({
pool,
h5Root: __dirname,
storageRoot: mindSpaceStorageRoot,
publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5),
conversationPackageRegistry: mindSpaceConversationPackageRegistry,
});
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);
env: process.env,
maxFileBytes: mindSpaceServerRuntime.maxFileBytes,
publicPageLimit: mindSpaceServerRuntime.publicPageLimit,
resolveUserIdForAgentSession,
resolveSessionSnapshot: (sessionId) => sessionSnapshotService?.get?.(sessionId),
registerPublicHtmlArtifactsForConversation,
remote: mindSpaceServerRuntime.remote,
logger: console,
}),
);
mindSpaceRuntimeAdapter.assertReady?.();
mindSpaceServiceFacade = mindSpaceRuntimeAdapter.serviceFacade;
mindSpaceConversationPackageRegistry = mindSpaceRuntimeAdapter.conversationPackageRegistry;
mindSpaceAssets = mindSpaceRuntimeAdapter.assetService;
mindSpacePages = mindSpaceRuntimeAdapter.pageService;
mindSpacePageLiveEdit = mindSpaceRuntimeAdapter.pageLiveEditService;
mindSpaceAssetAgent = mindSpaceRuntimeAdapter.assetAgentService;
mindSpacePublications = mindSpaceRuntimeAdapter.publicationService;
const resolveUserIdByDirKey = async (dirKey) => {
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;
}
}, 60 * 1000);
return userId ?? null;
};
await ensureAlgorithmConfig(pool);
const plazaAlgorithmConfig = await loadAlgorithmConfig(pool);
plazaRedis = await createPlazaRedis(process.env.PLAZA_REDIS_URL, pool);
@@ -423,11 +415,7 @@ async function bootstrapUserAuth() {
recalculateHotScores,
writebackPublications,
});
mindSpaceCleanup = createCleanupService(pool, {
storageRoot:
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
h5Root: __dirname,
});
mindSpaceCleanup = mindSpaceRuntimeAdapter.cleanupService;
await ensurePlanCatalogSchema(pool);
const planCatalogService = createPlanCatalogService(pool);
subscriptionService = createSubscriptionService(pool, {
@@ -452,12 +440,7 @@ async function bootstrapUserAuth() {
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),
});
mindSpaceAgentJobs = mindSpaceRuntimeAdapter.agentJobService;
// 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
@@ -465,7 +448,7 @@ async function bootstrapUserAuth() {
// 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 (mindSpaceServerRuntime.experienceEnabled) {
if (process.env.EXPERIENCE_PG_URL) {
try {
const { createPgExperienceService } = await import('./experience-service-pg.mjs');
@@ -497,88 +480,21 @@ async function bootstrapUserAuth() {
// 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)');
}
mindSpaceRuntimeAdapter.startBackgroundJobs({
publicationCleanupIntervalMs: 60 * 1000,
agentWorker: mindSpaceServerRuntime.agentWorker,
agentRunner: mindSpaceAgentRunner,
workspaceMaintenanceEnabled: WORKSPACE_MAINTENANCE_ENABLED,
publishRoot: mindSpaceServerRuntime.publishRoot,
startWorkspaceThumbnailWatcher,
startWorkspaceAssetSyncWatcher,
syncUserWorkspaceByDirKey: async (dirKey, options) => {
const userId = await resolveUserIdByDirKey(dirKey);
if (!userId) return;
await mindSpaceAssets.syncWorkspaceAssets(userId, options);
},
expireStaleUploadsIntervalMs: 5 * 60 * 1000,
});
await userAuth.ensureAdminUser();
llmProviderService = createLlmProviderService(pool, {
apiTarget: API_TARGET,
@@ -2369,7 +2285,7 @@ api.get('/mindspace/v1/agent/jobs/:jobId/stream', async (req, res) => {
cleanup();
res.end();
}
}, Math.max(500, Number(process.env.MINDSPACE_AGENT_SSE_POLL_MS ?? 1000)));
}, mindSpaceServerRuntime.agentWorker.ssePollMs);
// Comment line keeps proxies from closing an idle connection.
const keepAliveTimer = setInterval(() => {
if (!closed) res.write(': keep-alive\n\n');
@@ -2693,7 +2609,7 @@ 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;
const currentUserId = req.currentUser?.id ?? req.userSession?.userId ?? null;
let allowedByPublication = false;
let publicationAccessMode = null;
if (!currentUserId) {
@@ -2732,6 +2648,10 @@ api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => {
allowedByPublication = true;
publicationAccessMode = 'time_limited';
}
if (!allowedByPublication && verifyPublicAssetToken(assetId, req.query.public_token, INTERNAL_AGENT_SECRET)) {
allowedByPublication = true;
publicationAccessMode = 'signed-public-token';
}
if (!allowedByPublication && isPublicPageReferrer) {
allowedByPublication = true;
publicationAccessMode = 'public-page-referrer';
@@ -2744,7 +2664,7 @@ api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => {
? await mindSpaceAssets.readAsset(currentUserId, assetId)
: await mindSpaceAssets.readPublicAsset(assetId);
await mindSpaceAudit?.write({
userId: req.currentUser?.id ?? null,
userId: currentUserId,
action: 'asset.download',
objectType: 'asset',
objectId: assetId,
@@ -2949,6 +2869,7 @@ const pageSyncInFlight = new Map();
async function syncUserGeneratedPages(userId) {
if (!mindSpacePages || !authPool || !userId) return;
if (mindSpaceServerRuntime.adapterKind === 'remote') return;
let inFlight = pageSyncInFlight.get(userId);
if (!inFlight) {
@@ -2957,7 +2878,7 @@ async function syncUserGeneratedPages(userId) {
pageService: mindSpacePages,
assetService: mindSpaceAssets,
userId,
publishDir: resolvePublishDir(__dirname, { id: userId }),
publishDir: resolveMindSpaceUserPublishDir(__dirname, { id: userId }),
syncWorkspaceAssets:
mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED
? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options)
@@ -2993,8 +2914,7 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) {
});
if (resolvedHtml?.content && resolvedHtml.relativePath && authPool) {
const storageRoot =
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
const { storageRoot } = resolveMindSpaceRuntimeConfig(h5Root, process.env);
let htmlContent = resolvedHtml.content;
const repaired = await repairMissingHtmlAssetReferences({
@@ -3021,7 +2941,7 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) {
}
if (htmlContent !== resolvedHtml.content) {
const publishDir = resolvePublishDir(h5Root, { id: user.id });
const publishDir = resolveMindSpaceUserPublishDir(h5Root, { id: user.id });
await fsPromises.writeFile(
path.join(publishDir, resolvedHtml.relativePath),
htmlContent,
@@ -3133,100 +3053,15 @@ async function registerPublicHtmlArtifactsForConversation({
relativePaths = [],
artifactRefs = [],
}) {
if (
!mindSpaceConversationPackageRegistry ||
!user?.id ||
!publishDir ||
!sessionId ||
((!Array.isArray(relativePaths) || relativePaths.length === 0) &&
(!Array.isArray(artifactRefs) || artifactRefs.length === 0))
) {
return [];
}
try {
const publishRoot = path.resolve(publishDir);
const packageTitle = String(title ?? '').trim() || null;
const existingManifest = await mindSpaceConversationPackageRegistry
.readManifestForSession({ userId: user.id, sessionId })
.catch(() => null);
const packageRecord = existingManifest?.packageId && existingManifest.title
? { id: existingManifest.packageId }
: await mindSpaceConversationPackageRegistry.ensurePackage({
userId: user.id,
sessionId,
title: packageTitle ?? existingManifest?.title ?? null,
});
const recorded = [];
const now = Date.now();
const refs =
Array.isArray(artifactRefs) && artifactRefs.length > 0
? artifactRefs
: relativePaths.map((relativePath) => ({ relativePath }));
for (const ref of refs) {
const relativePath = typeof ref === 'string' ? ref : ref?.relativePath;
const normalized = normalizePublicHtmlRelativePath(relativePath);
if (!normalized || !normalized.toLowerCase().endsWith('.html')) continue;
const absolutePath = path.resolve(publishRoot, normalized);
if (absolutePath !== publishRoot && !absolutePath.startsWith(`${publishRoot}${path.sep}`)) continue;
let stat = null;
try {
stat = await fsPromises.stat(absolutePath);
} catch {
continue;
}
if (!stat.isFile()) continue;
const hash = crypto
.createHash('sha256')
.update(`${user.id}:${sessionId}:${normalized}`)
.digest('hex')
.slice(0, 16);
const artifactId = `ca_public_html_${hash}`;
await mindSpaceConversationPackageRegistry.recordArtifact({
id: artifactId,
packageId: packageRecord.id,
artifactKind: 'public_html',
role: 'assistant',
messageId: typeof ref?.messageId === 'string' && ref.messageId.trim() ? ref.messageId.trim() : null,
displayName: path.posix.basename(normalized),
mimeType: 'text/html',
sizeBytes: stat.size,
canonicalUrl: buildPublicUrl(resolvePublicBaseUrl(), user.id, normalized),
sortOrder: Number.isFinite(stat.mtimeMs) ? Math.round(stat.mtimeMs) : now,
now,
});
recorded.push({ artifactId, relativePath: normalized });
}
if (recorded.length > 0) {
await mindSpaceConversationPackageRegistry.writeManifestForSession({
userId: user.id,
sessionId,
});
}
return recorded;
} catch (error) {
console.warn('[MindSpace] failed to record public html artifacts:', error?.message ?? error);
return [];
}
}
async function hydratePublicHtmlArtifactsForConversationPackage({
user,
sessionId,
}) {
if (!sessionSnapshotService?.isEnabled?.() || !user?.id || !sessionId) return [];
const publishDir = resolvePublishDir(__dirname, { id: user.id });
const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null);
const messages = Array.isArray(snapshot?.messages) ? snapshot.messages : [];
const artifactRefs = collectOwnPublicHtmlArtifactRefs({
messages,
currentUser: user,
publishDir,
});
return registerPublicHtmlArtifactsForConversation({
return registerPublicHtmlArtifactsForConversationPackage({
conversationPackageRegistry: mindSpaceConversationPackageRegistry,
h5Root: __dirname,
env: process.env,
user,
publishDir,
sessionId,
title: snapshot?.session?.name,
title,
relativePaths,
artifactRefs,
});
}
@@ -3256,7 +3091,7 @@ api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => {
if (!bundle.resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
const publishDir = resolvePublishDir(__dirname, req.currentUser);
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
const thumbRel = workspaceThumbnailRelativePath(bundle.resolvedHtml.relativePath);
const title =
bundle.previewTitle ||
@@ -3272,8 +3107,7 @@ api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => {
bundle.resolvedHtml.content,
{ title, subtitle, force: true },
).catch(() => {});
const storageRoot =
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
const { storageRoot } = resolveMindSpaceRuntimeConfig(__dirname, process.env);
const resolveAssetDataUri = createAssetDataUriResolver(
authPool,
storageRoot,
@@ -3314,7 +3148,7 @@ api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => {
} = bundle;
let thumbnailReady = false;
if (resolvedHtml?.content && resolvedHtml.relativePath) {
const publishDir = resolvePublishDir(__dirname, req.currentUser);
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
try {
await ensureWorkspaceHtmlThumbnail(publishDir, resolvedHtml.relativePath, resolvedHtml.content, {
title: previewTitle || resolvedHtml.suggestedTitle,
@@ -3367,8 +3201,7 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
const storageRoot =
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
const { storageRoot } = resolveMindSpaceRuntimeConfig(__dirname, process.env);
const { html: localizedHtml } = await inlinePrivateAssetsInHtml(
authPool,
storageRoot,
@@ -3376,7 +3209,7 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
bundle.resolvedHtml.content,
);
const publishDir = resolvePublishDir(__dirname, req.currentUser);
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
const sharedDir = path.join(publishDir, PUBLIC_ZONE_DIR, 'shared');
await fsPromises.mkdir(sharedDir, { recursive: true });
@@ -3388,7 +3221,12 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
await fsPromises.writeFile(destPath, sharedHtml, 'utf8');
const publishKey = req.currentUser.id;
const publicUrl = buildPublicUrl(resolvePublicBaseUrl(), publishKey, sharedRelativePath);
const publicUrl = buildMindSpacePublicUrlForUser({
h5Root: __dirname,
env: process.env,
user: publishKey,
relativePath: sharedRelativePath,
});
return res.status(201).json({ data: { publicUrl, filename } });
} catch (error) {
@@ -3540,7 +3378,7 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
}
if (analysis.contentMode === 'static_html' && resolvedHtml?.content && analysis.relativePath) {
const publishDir = resolvePublishDir(__dirname, req.currentUser);
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
await ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, {
title: pageInput.title,
subtitle: pageInput.summary,
@@ -4650,7 +4488,7 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
const publishDir = resolvePublishDir(__dirname, { id: req.currentUser.id });
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: req.currentUser.id });
const syncPublicHtmlDuringStream = (event) => {
materializePublicHtmlWritesFromSessionEvent(event, { publishDir });
};
@@ -4878,12 +4716,11 @@ function removeQueryParam(url, key) {
}
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}`;
return resolvePublicRequestOrigin({
hostHeader: req.headers['x-forwarded-host'] || req.headers.host || '',
forwardedProto: req.headers['x-forwarded-proto'] || '',
protocol: req.protocol,
});
}
function detectPublishedPageTitle(html) {
@@ -5532,42 +5369,22 @@ app.get('/s/:token', async (req, res) => {
}
});
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.
*/
async function sendLongImageDownloadIfRequested(req, res, filePath) {
if (!isLongImageDownloadRequest(req.query)) return false;
const longImagePath = longImagePathForHtml(filePath);
try {
await renderLongImage({ htmlPath: filePath, outputPath: longImagePath });
res.set('Cache-Control', 'no-store');
res.download(longImagePath, path.basename(longImagePath), (err) => {
if (err && !res.headersSent) res.status(404).json({ message: '长图文件不存在' });
});
} catch (error) {
res
.status(500)
.type('text/plain; charset=utf-8')
.send(`长图生成失败:${error?.message || '未知错误'}`);
}
return true;
const origin = resolveRequestOrigin(req);
const currentUrl = origin ? `${origin}${req.originalUrl || req.url || ''}` : null;
return handleMindSpaceLongImageDownload({
query: req.query,
filePath,
pageUrl: currentUrl ? removeQueryParam(removeQueryParam(currentUrl, 'download'), 'export') : null,
res,
isLongImageDownloadRequest,
longImagePathForHtml,
renderLongImage,
});
}
async function sendPublishFile(req, res, filePath) {
@@ -5585,138 +5402,82 @@ async function sendPublishFile(req, res, filePath) {
res.status(404).json({ message: '文件不存在' });
return;
}
html = appendPublicAssetTokens(html, INTERNAL_AGENT_SECRET);
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 }));
const context = buildPublishedHtmlViewContext({
origin: resolveRequestOrigin(req),
requestPath: req.originalUrl || req.url || '',
filePath,
thumbnailPngPathForSvg,
});
const decorated = decorateMindSpacePublishedHtml({
html,
embed,
context,
userAgent: req.get('user-agent') || '',
preparePublicationHtmlForEmbed,
injectOgTags,
injectWechatShareBridge,
injectPublicFileShareButton,
publishedPageCsp,
isWechatUserAgent,
});
html = decorated.html;
if (decorated.allowEmbedFrame) {
allowPlazaEmbedFrame(res);
}
res.set('Content-Security-Policy', decorated.csp);
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: '未找到页面' });
const result = await resolveMindSpacePublicRequest({
h5Root: __dirname,
requestPath: req.path,
resolveUsernameToUserId: async (username) => {
if (!authPool) return null;
const [rows] = await authPool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [username]);
return rows[0]?.id ? String(rows[0].id) : null;
},
resolveClosestHtmlRelativePath,
ensureThumbnail: async (publishDir, relativePath) => {
await ensureWorkspaceHtmlThumbnail(publishDir, relativePath).catch(() => {});
},
logger: console,
});
if (result.action === 'redirect') {
res.redirect(result.status ?? 301, result.location);
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) {
if (result.action === 'forbidden') {
res.status(403).json({ message: '禁止访问' });
return;
}
if (!fs.existsSync(targetDir)) {
res.status(404).json({ message: '用户不存在' });
if (result.action === 'not_found') {
if (result.reason === 'missing_owner_dir') {
res.status(404).json({ message: '用户不存在' });
return;
}
if (result.reason === 'missing_directory_index') {
res.status(404).json({ message: '目录中没有 index.html' });
return;
}
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).
const resolvedPath = result.filePath;
if (/\.thumbnail\.png$/i.test(resolvedPath)) {
const svgSibling = resolvedPath.replace(/\.png$/i, '.svg');
if (fs.existsSync(svgSibling)) {
@@ -5729,49 +5490,6 @@ async function serveUserPublishFile(req, res, next) {
}
}
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) {
await 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)) {
await sendPublishFile(req, res, indexPath);
return;
}
res.status(404).json({ message: '目录中没有 index.html' });
return;
}
await sendPublishFile(req, res, resolvedPath);
}