feat: finalize mindspace service extraction phase a
This commit is contained in:
+182
-464
@@ -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();
|
||||
// 无状态前端节点(如 105,MindSpace 经 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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user