From d3239ff292f1563360e96d898b0384d2c87ebff6 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 5 Jul 2026 07:57:04 +0800 Subject: [PATCH] fix: MindSpace remote sync, thumbnail fallback, and Plaza URL guards Ensure page sync runs via pageSyncService in remote mode, fall back to workspace HTML when storage assets are missing, and prevent production Portal from linking to loopback Plaza URLs baked in at build time. Co-authored-by: Cursor --- .env.example | 7 +- AGENTS.md | 9 ++ docs/plaza-local.md | 24 +++++ docs/regression-guards/README.md | 1 + ...indspace-remote-page-sync-and-thumbnail.md | 100 ++++++++++++++++++ mindspace-local-runtime-services.mjs | 11 ++ mindspace-local-runtime-services.test.mjs | 1 + mindspace-local-server-adapter.mjs | 8 +- mindspace-page-sync-service.mjs | 44 ++++++++ mindspace-page-sync-service.test.mjs | 88 +++++++++++++++ mindspace-pages.mjs | 57 +++++++++- mindspace-pages.test.mjs | 89 +++++++++++++++- mindspace-server-adapter-contract.mjs | 1 + package.json | 3 +- public-site-bases.test.mjs | 64 +++++++++++ scripts/build-portal-runtime.mjs | 18 +++- scripts/verify-mindspace-page-sync-guards.mjs | 68 ++++++++++++ server.mjs | 36 ++----- src/utils/public-site-bases.mjs | 43 ++++++++ src/utils/publicUrl.ts | 32 +++--- 20 files changed, 646 insertions(+), 58 deletions(-) create mode 100644 docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md create mode 100644 mindspace-page-sync-service.mjs create mode 100644 mindspace-page-sync-service.test.mjs create mode 100644 public-site-bases.test.mjs create mode 100644 scripts/verify-mindspace-page-sync-guards.mjs create mode 100644 src/utils/public-site-bases.mjs diff --git a/.env.example b/.env.example index 9c6f52b..435f18d 100644 --- a/.env.example +++ b/.env.example @@ -167,10 +167,11 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # 历史列表每次展示/加载条数(默认 25,与 desktop 一致) # VITE_SESSION_PAGE_SIZE=25 -# Plaza 发现广场(本机为主,105 已停用) -# PLAZA_AUTO_APPROVE=true +# Plaza 发现广场(独立域名 plaza.tkmind.cn;Portal 前端链接见 VITE_PLAZA_BASE) +# PLAZA_PUBLIC_BASE=https://plaza.tkmind.cn +# 本地 dev 脚本会自动注入 VITE_PLAZA_BASE=http://127.0.0.1:3001;勿把该值留进生产构建用的 .env # VITE_PLAZA_BASE=http://127.0.0.1:3001 -# 文档:docs/plaza-local.md | 本地:http://127.0.0.1:3001/plaza +# 文档:docs/plaza-local.md | 本地:http://127.0.0.1:3001/plaza | 生产:https://plaza.tkmind.cn/plaza # MindSpace(默认启用;子功能需按需开启) # MINDSPACE_ENABLED=false diff --git a/AGENTS.md b/AGENTS.md index c319690..1a4a92c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ bash scripts/check-release-ready.sh | 主题 | 文档 | |------|------| | MindSpace 公开页 `edit_file` 落盘 + 聊天 Finish 不丢消息 | [docs/regression-guards/mindspace-publish-and-chat-finish.md](docs/regression-guards/mindspace-publish-and-chat-finish.md) | +| MindSpace remote 页面 sync + storage 缺失缩略图 fallback | [docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md](docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md) | 索引:[docs/regression-guards/README.md](docs/regression-guards/README.md) @@ -32,6 +33,7 @@ bash scripts/check-release-ready.sh ```bash npm run verify:mindspace-publish-guards npm run verify:mindspace-publish-guards:full +npm run verify:mindspace-page-sync-guards ``` 发版脚本(`scripts/release-portal-runtime-prod.sh`)在未 `--skip-tests` 时也会执行相关 verify。 @@ -43,6 +45,8 @@ npm run verify:mindspace-publish-guards:full - `conversation-display.mjs` + `src/utils/message.ts` - 用户消息不得展示 routing/skill 内部前缀 - `src/hooks/useTKMindChat.ts` - `syncSessionMessages` merge + 重试 - `server.mjs` - session snapshot 需 `hint_mc` 且 `hint_ua` 才走缓存 +- `mindspace-pages.mjs` - storage 缺失时 HTML 页回退读 workspace `relative_path` +- `mindspace-page-sync-service.mjs` + `server.mjs` - remote 模式也必须 sync public HTML 代码内搜索 `REGRESSION GUARD` 可定位所有内联说明。 @@ -52,6 +56,11 @@ npm run verify:mindspace-publish-guards:full - `chat-finish-sync.test.mjs` - `conversation-display.test.mjs` +### MindSpace page sync + thumbnail + +- `mindspace-page-sync-service.test.mjs` +- `mindspace-pages.test.mjs` + ## Cursor 专用规则(其它工具请读上文文档) Cursor 额外加载:`.cursor/rules/mindspace-publish-chat-finish-guards.mdc` diff --git a/docs/plaza-local.md b/docs/plaza-local.md index 57e81e8..fd2002e 100644 --- a/docs/plaza-local.md +++ b/docs/plaza-local.md @@ -104,6 +104,30 @@ pnpm setup:plaza-tunnel | `H5_PORT` | 8081 | Portal / API 端口 | | `PLAZA_LOCAL_HOST` | plaza.tkmind.cn | 本地域名 | | `PLAZA_PUBLIC_BASE` | https://plaza.tkmind.cn | 公开 URL 前缀 | +| `VITE_PLAZA_BASE` | (见下) | Portal 前端「逛 Plaza」链接;**编译期**写入 dist | + +### Portal「逛 Plaza」链接(MindSpace / 发布成功页) + +Plaza 是独立域名 `plaza.tkmind.cn`,与 Portal `m.tkmind.cn` 不同。前端通过 `src/utils/publicUrl.ts` → `public-site-bases.mjs` 解析: + +| 环境 | 期望链接 | +|------|----------| +| 本机 `127.0.0.1` / `localhost` | `http://127.0.0.1:3001/plaza` | +| 生产 `m.tkmind.cn` | `https://plaza.tkmind.cn/plaza` | + +**为何生产曾出现 127?** `VITE_*` 在 `vite build` 时写入 bundle。若本机 `.env` 含 `VITE_PLAZA_BASE=http://127.0.0.1:3001`,未覆盖就直接打生产包,会把 127 带进 `m.tkmind.cn`。 + +**彻底修复(三层):** + +1. **运行时守卫**:在 `m.tkmind.cn` 等公网 host 上,即使 bundle 内是 loopback,也强制用 `https://plaza.tkmind.cn`(见 `public-site-bases.mjs`)。 +2. **构建默认值**:`scripts/build-portal-runtime.mjs` 在未显式设置时注入 `VITE_PLAZA_BASE=https://plaza.tkmind.cn`。 +3. **勿在生产构建用本地 .env**:发 Portal runtime 前确认 `npm run build` / `build-portal-runtime.mjs` 未读取带 127 的 `VITE_PLAZA_BASE`。 + +验证: + +```bash +node --test public-site-bases.test.mjs +``` ## 已弃用 diff --git a/docs/regression-guards/README.md b/docs/regression-guards/README.md index 2be27e2..5f156eb 100644 --- a/docs/regression-guards/README.md +++ b/docs/regression-guards/README.md @@ -7,6 +7,7 @@ | 文档 | 保护内容 | |------|----------| | [mindspace-publish-and-chat-finish.md](./mindspace-publish-and-chat-finish.md) | ① `edit_file` 覆盖 `public/*.html` ② Finish 后聊天不清空、不暴露 agent 内部前缀 | +| [mindspace-remote-page-sync-and-thumbnail.md](./mindspace-remote-page-sync-and-thumbnail.md) | ① remote 模式 public HTML 入库 sync ② storage 缺失时缩略图/读页回退 workspace HTML | ## 自动化 diff --git a/docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md b/docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md new file mode 100644 index 0000000..2320b81 --- /dev/null +++ b/docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md @@ -0,0 +1,100 @@ +# MindSpace Remote 页面同步与缩略图回归守卫 + +日期:2026-07-05 + +## 事故症状 + +1. **缩略图 500**:`GET /api/mindspace/v1/pages/:pageId/thumbnail` 返回 500;日志/storage 为 `ENOENT`,但 `MindSpace//public/*.html` 仍存在。 +2. **页面列表缺新生成页**:103 生产 `MINDSPACE_SERVER_ADAPTER=remote` 时,Agent 新生成的 `public/*.html` 不出现在「最近页面 / 全部页面」。 +3. **误判**:容易把缺页当成「MindSpace 路径解析错误」或「改回 local adapter 就能修缩略图」——两者根因不同。 + +## 根因 + +### 缩略图 500 + +- `h5_page_records` / `h5_asset_versions.storage_key` 指向 `data/mindspace/users/.../*.md`。 +- 磁盘上该 storage 文件可能已丢失或未写入(历史 sync、清理、quota、迁移)。 +- 页面 snapshot 仍保留 `relative_path: public/index.html`,工作区 HTML 是真实内容来源。 +- `loadPageThumbnailContext` / `getPage` 若**只读 storage**,会在 remote 8082 上直接抛错 → Portal 返回 500。 + +### Remote 下缺页 + +- Portal `GET /mindspace/v1/pages` 会调用 `syncUserGeneratedPages()`,将 `public/*.html` 入库。 +- 曾存在 `if (adapterKind === 'remote') return;`,导致 **8082 独立部署后 sync 永不执行**。 +- 测试环境多为 `local`,sync 正常 → 生产/测试行为不一致。 + +## 必须保留的行为 + +### 1. Workspace HTML fallback(读内容) + +在 `mindspace-pages.mjs` 中,读取页面 content / 缩略图 HTML 时: + +1. 优先读 `storage_key` 对应文件; +2. 若 `ENOENT` 且 `page_type === 'html'` 且 snapshot 有 `relative_path` → 回退读 `resolvePublishDir(h5Root, userId)` 下对应 HTML; +3. `getPage` 与 `renderThumbnail` **共用** `readPageContentWithWorkspaceFallback`,禁止只修一处。 + +### 2. Remote 也执行 page sync + +1. 抽出 `mindspace-page-sync-service.mjs` → `pageSyncService.syncUserGeneratedPages(userId)`; +2. 写入 `mindspace-server-adapter-contract.mjs`:`pageSyncService` binding; +3. **Local**:8082 / Portal 内直接跑 `syncGeneratedPagesFromPublicAssets`; +4. **Remote**:Portal 通过 RPC 调 8082 的 `pageSyncService.syncUserGeneratedPages`; +5. **`server.mjs` 禁止**在 remote 模式 early-return 跳过 sync。 + +触发点(保持不变): + +- `GET /mindspace/v1/pages` 列表前 +- 聊天 Finish 后(`onAfterFinish`) + +### 3. 103 拓扑约束 + +- Portal:`8081`,`MINDSPACE_SERVER_ADAPTER=remote` +- MindSpace Service:`8082`,跑 **local adapter**(含 pageSyncService + storage + h5Root) +- 用户 HTML:`/Users/john/Project/Memind/MindSpace//public/` +- Storage:`data/mindspace/users//...` + +**禁止**用「改回 Portal local adapter」作为生产长期方案;只可用于本地对比验证。 + +## 排查入口 + +```bash +# 1. 页面 DB 记录 vs storage vs workspace +mysql ... # h5_page_records + h5_page_versions.source_snapshot_json + h5_asset_versions.storage_key + +# 2. storage 是否存在 +ls -la data/mindspace/users//pages//versions/ + +# 3. workspace HTML 是否存在 +ls -la MindSpace//public/ + +# 4. 8082 健康与 adapter +curl -s http://127.0.0.1:8082/health + +# 5. 缩略图 RPC(需登录态;或直接在 8082 侧调 pageService.renderThumbnail) +curl -I 'https://m.tkmind.cn/api/mindspace/v1/pages//thumbnail' +``` + +## 改相关文件前必跑 + +```bash +npm run verify:mindspace-page-sync-guards +node --test mindspace-page-sync-service.test.mjs mindspace-pages.test.mjs +``` + +## Checklist(发 103 前) + +- [ ] `mindspace-pages.mjs` 含 workspace fallback +- [ ] `pageSyncService` 在 contract + local runtime + remote proxy 中齐全 +- [ ] `server.mjs` 无 remote sync skip +- [ ] **8082 与 Portal 均发布**(8082 承载 renderThumbnail + sync) +- [ ] 用真实 pageId 验证 thumbnail 200 + 列表含新页 + +## 相关单测 + +- `mindspace-page-sync-service.test.mjs` +- `mindspace-pages.test.mjs`(storage 缺失时 thumbnail / getPage fallback) +- `mindspace-page-sync.test.mjs`(public dir → DB) + +## 代码内标记 + +搜索 `REGRESSION GUARD: mindspace-page-sync-thumbnail` 定位内联说明。 diff --git a/mindspace-local-runtime-services.mjs b/mindspace-local-runtime-services.mjs index ef9fa03..6e97efc 100644 --- a/mindspace-local-runtime-services.mjs +++ b/mindspace-local-runtime-services.mjs @@ -6,6 +6,7 @@ import { createAgentJobService } from './mindspace-agent-jobs.mjs'; import { createAssetAgentService } from './mindspace-asset-agent.mjs'; import { createPageLiveEditService } from './mindspace-page-live-edit.mjs'; import { createPageService } from './mindspace-pages.mjs'; +import { createPageSyncService } from './mindspace-page-sync-service.mjs'; import { createPublicationService } from './mindspace-publications.mjs'; import { createMindSpaceLocalRuntime } from './mindspace-runtime-config.mjs'; @@ -19,6 +20,7 @@ export function createMindSpaceLocalRuntimeServices({ conversationPackageBackfill, conversationPackagePublicHtmlHydrator, logger = console, + syncWorkspaceAssets = null, } = {}) { let conversationPackageRegistry = null; const runtime = createMindSpaceLocalRuntime({ @@ -51,6 +53,14 @@ export function createMindSpaceLocalRuntimeServices({ storageRoot: runtime.storageRoot, conversationPackageRegistry, }); + const pageSyncService = createPageSyncService({ + pool, + pageService, + assetService, + h5Root, + syncWorkspaceAssets, + logger, + }); const publicationService = createPublicationService(pool, { h5Root, storageRoot: runtime.storageRoot, @@ -80,6 +90,7 @@ export function createMindSpaceLocalRuntimeServices({ conversationPackageRegistry, assetService, pageService, + pageSyncService, publicationService, cleanupService, agentJobService, diff --git a/mindspace-local-runtime-services.test.mjs b/mindspace-local-runtime-services.test.mjs index 7043309..b807dfb 100644 --- a/mindspace-local-runtime-services.test.mjs +++ b/mindspace-local-runtime-services.test.mjs @@ -30,6 +30,7 @@ test('createMindSpaceLocalRuntimeServices assembles local runtime-backed service assert.ok(services.conversationPackageRegistry); assert.ok(services.assetService); assert.ok(services.pageService); + assert.ok(services.pageSyncService); assert.ok(services.publicationService); assert.ok(services.cleanupService); assert.ok(services.agentJobService); diff --git a/mindspace-local-server-adapter.mjs b/mindspace-local-server-adapter.mjs index da8a237..34cd2e8 100644 --- a/mindspace-local-server-adapter.mjs +++ b/mindspace-local-server-adapter.mjs @@ -13,8 +13,10 @@ export function createMindSpaceLocalServerAdapter({ registerPublicHtmlArtifactsForConversation, logger = console, setIntervalFn = setInterval, + syncWorkspaceAssetsEnabled = true, } = {}) { - const services = createMindSpaceLocalRuntimeServices({ + let services; + services = createMindSpaceLocalRuntimeServices({ pool, h5Root, env, @@ -36,6 +38,10 @@ export function createMindSpaceLocalServerAdapter({ registerPublicHtmlArtifactsForConversation, }), logger, + syncWorkspaceAssets: syncWorkspaceAssetsEnabled + ? (targetUserId, options) => + services?.assetService?.syncWorkspaceAssets?.(targetUserId, options) + : null, }); return { diff --git a/mindspace-page-sync-service.mjs b/mindspace-page-sync-service.mjs new file mode 100644 index 0000000..3f3b6b3 --- /dev/null +++ b/mindspace-page-sync-service.mjs @@ -0,0 +1,44 @@ +import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs'; +import { resolvePublishDir } from './user-publish.mjs'; + +export function createPageSyncService({ + pool, + pageService, + assetService, + h5Root, + syncWorkspaceAssets = null, + logger = console, +} = {}) { + const syncInFlight = new Map(); + + async function syncUserGeneratedPages(userId) { + if (!pool || !pageService || !userId) { + return { created: 0, skipped: 0, updated: 0 }; + } + + let inFlight = syncInFlight.get(userId); + if (!inFlight) { + inFlight = syncGeneratedPagesFromPublicAssets({ + pool, + pageService, + assetService, + userId, + publishDir: h5Root ? resolvePublishDir(h5Root, { id: userId }) : null, + syncWorkspaceAssets, + }) + .catch((error) => { + logger.warn?.('[MindSpace] page sync failed:', error?.message ?? error); + return { created: 0, skipped: 0, updated: 0 }; + }) + .finally(() => { + if (syncInFlight.get(userId) === inFlight) { + syncInFlight.delete(userId); + } + }); + syncInFlight.set(userId, inFlight); + } + return inFlight; + } + + return { syncUserGeneratedPages }; +} diff --git a/mindspace-page-sync-service.test.mjs b/mindspace-page-sync-service.test.mjs new file mode 100644 index 0000000..96c7c75 --- /dev/null +++ b/mindspace-page-sync-service.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { createPageSyncService } from './mindspace-page-sync-service.mjs'; +import { PUBLISH_ROOT_DIR } from './user-publish.mjs'; + +test('syncUserGeneratedPages registers html files from publish public dir', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-page-sync-service-')); + const h5Root = tmp; + const userId = 'user-1'; + const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, userId); + const html = + 'Sync Demo

Hi

'; + await fs.mkdir(path.join(publishDir, 'public'), { recursive: true }); + await fs.writeFile(path.join(publishDir, 'public/index.html'), html); + + const calls = []; + const pageService = { + createFromChat: async (_userId, input, source) => { + calls.push({ input, source }); + return { id: 'page-1' }; + }, + updatePage: async () => assert.fail('should create, not update'), + }; + const pool = { + query: async (sql) => { + if (String(sql).includes('FROM h5_page_records')) return [[]]; + return [[]]; + }, + }; + + const pageSyncService = createPageSyncService({ + pool, + pageService, + assetService: null, + h5Root, + }); + const result = await pageSyncService.syncUserGeneratedPages(userId); + + assert.equal(result.created, 1); + assert.equal(calls.length, 1); + assert.equal(calls[0].input.title, 'Sync Demo'); + assert.equal(calls[0].source.snapshot.relative_path, 'public/index.html'); +}); + +test('syncUserGeneratedPages deduplicates concurrent sync for the same user', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-page-sync-dedupe-')); + const h5Root = tmp; + const userId = 'user-1'; + const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, userId); + await fs.mkdir(path.join(publishDir, 'public'), { recursive: true }); + await fs.writeFile( + path.join(publishDir, 'public/index.html'), + 'DedupeHi', + ); + + let active = 0; + let maxActive = 0; + const pageService = { + createFromChat: async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 20)); + active -= 1; + return { id: 'page-1' }; + }, + }; + const pool = { + query: async (sql) => { + if (String(sql).includes('FROM h5_page_records')) return [[]]; + return [[]]; + }, + }; + const pageSyncService = createPageSyncService({ + pool, + pageService, + h5Root, + }); + + await Promise.all([ + pageSyncService.syncUserGeneratedPages(userId), + pageSyncService.syncUserGeneratedPages(userId), + ]); + + assert.equal(maxActive, 1); +}); diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs index 4d26ebd..3c2fc53 100644 --- a/mindspace-pages.mjs +++ b/mindspace-pages.mjs @@ -309,6 +309,45 @@ export function createPageService(pool, options = {}) { throw lastError ?? Object.assign(new Error('存储文件不存在'), { code: 'storage_not_found' }); }; + // REGRESSION GUARD: mindspace-page-sync-thumbnail — storage ENOENT 时回退读 workspace HTML + const readPageContentWithWorkspaceFallback = async ({ + userId, + storageKey, + sourceSnapshotJson, + pageType, + }) => { + let snapshot = {}; + try { + snapshot = parseJsonColumn(sourceSnapshotJson); + } catch { + snapshot = {}; + } + const workspaceHtmlRelativePath = + pageType === 'html' ? snapshot.relative_path ?? null : null; + const workspacePublishDir = workspaceHtmlRelativePath + ? await resolveWorkspacePublishDir(userId) + : null; + + try { + const storagePath = await resolveReadableStoragePath(storageKey); + return await fs.readFile(storagePath, 'utf8'); + } catch (error) { + const storageMissing = error?.code === 'ENOENT' || error?.code === 'storage_not_found'; + if (!storageMissing || !workspacePublishDir || !workspaceHtmlRelativePath) { + throw error; + } + const workspaceHtmlPath = path.join(workspacePublishDir, workspaceHtmlRelativePath); + try { + return await fs.readFile(workspaceHtmlPath, 'utf8'); + } catch (workspaceError) { + if (workspaceError?.code === 'ENOENT') { + throw error; + } + throw workspaceError; + } + } + }; + const writeVersionContent = async (userId, assetId, versionId, content) => { const storageKey = path.posix.join( 'users', @@ -668,7 +707,7 @@ export function createPageService(pool, options = {}) { async function getPage(userId, pageId) { const [rows] = await pool.query( - `SELECT p.*, c.category_code, pv.version_no, av.storage_key + `SELECT p.*, c.category_code, pv.version_no, av.storage_key, pv.source_snapshot_json FROM h5_page_records p JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id JOIN h5_page_versions pv ON pv.id = p.current_version_id @@ -679,8 +718,12 @@ export function createPageService(pool, options = {}) { ); const row = rows[0]; if (!row) throw pageError('页面不存在', 'page_not_found'); - const storagePath = await resolveReadableStoragePath(row.storage_key); - const content = await fs.readFile(storagePath, 'utf8'); + const content = await readPageContentWithWorkspaceFallback({ + userId, + storageKey: row.storage_key, + sourceSnapshotJson: row.source_snapshot_json, + pageType: row.page_type, + }); return pageResponse({ ...row, content }); } @@ -1265,8 +1308,12 @@ export function createPageService(pool, options = {}) { if (row.page_type !== 'html') { throw pageError('该页面不支持缩略图', 'thumbnail_not_supported'); } - const storagePath = await resolveReadableStoragePath(row.storage_key); - const content = await fs.readFile(storagePath, 'utf8'); + const content = await readPageContentWithWorkspaceFallback({ + userId, + storageKey: row.storage_key, + sourceSnapshotJson: row.source_snapshot_json, + pageType: row.page_type, + }); let snapshot = {}; try { snapshot = parseJsonColumn(row.source_snapshot_json); diff --git a/mindspace-pages.test.mjs b/mindspace-pages.test.mjs index 1d64f20..603316d 100644 --- a/mindspace-pages.test.mjs +++ b/mindspace-pages.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; -import { pageInternals, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; +import { createPageService, pageInternals, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; +import { PUBLISH_ROOT_DIR } from './user-publish.mjs'; test('parseJsonColumn accepts mysql json objects and strings', () => { const objectValue = { relative_path: 'public/demo.html', auto_synced: true }; @@ -208,3 +212,86 @@ test('registerPageArtifactForConversation skips missing registry or session safe null, ); }); + +test('renderThumbnail falls back to workspace html when storage asset is missing', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-thumb-')); + const storageRoot = path.join(tmp, 'storage'); + const h5Root = tmp; + const userId = 'user-1'; + const pageId = 'page-1'; + const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, userId); + const html = + 'Demo

Hi

'; + await fs.mkdir(path.join(publishDir, 'public'), { recursive: true }); + await fs.writeFile(path.join(publishDir, 'public/index.html'), html); + + const pool = { + query: async (sql) => { + if (String(sql).includes('FROM h5_page_records')) { + return [[{ + title: 'Demo', + summary: '', + page_type: 'html', + version_no: 1, + storage_key: `users/${userId}/pages/missing/versions/v1.md`, + source_snapshot_json: { relative_path: 'public/index.html' }, + }]]; + } + if (String(sql).includes('FROM h5_users')) { + return [[{ username: 'demo' }]]; + } + return [[]]; + }, + }; + + const pageService = createPageService(pool, { storageRoot, h5Root }); + const svg = await pageService.renderThumbnail(userId, pageId); + assert.match(svg, / { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-get-page-')); + const storageRoot = path.join(tmp, 'storage'); + const h5Root = tmp; + const userId = 'user-1'; + const pageId = 'page-1'; + const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, userId); + const html = + 'Page Demo

Body

'; + await fs.mkdir(path.join(publishDir, 'public'), { recursive: true }); + await fs.writeFile(path.join(publishDir, 'public/index.html'), html); + + const pool = { + query: async (sql) => { + if (String(sql).includes('FROM h5_page_records')) { + return [[{ + id: pageId, + user_id: userId, + title: 'Page Demo', + summary: '', + page_type: 'html', + template_id: 'static-html', + current_version_id: 'version-1', + status: 'draft', + visibility: 'private', + category_code: 'draft', + version_no: 1, + storage_key: `users/${userId}/pages/missing/versions/v1.md`, + source_snapshot_json: { relative_path: 'public/index.html' }, + created_at: 1, + updated_at: 1, + }]]; + } + if (String(sql).includes('FROM h5_users')) { + return [[{ username: 'demo' }]]; + } + return [[]]; + }, + }; + + const pageService = createPageService(pool, { storageRoot, h5Root }); + const page = await pageService.getPage(userId, pageId); + assert.equal(page.title, 'Page Demo'); + assert.match(page.content, /

Body<\/h1>/); +}); diff --git a/mindspace-server-adapter-contract.mjs b/mindspace-server-adapter-contract.mjs index da8e2bd..d3783ff 100644 --- a/mindspace-server-adapter-contract.mjs +++ b/mindspace-server-adapter-contract.mjs @@ -44,6 +44,7 @@ export const MINDSPACE_SERVER_ADAPTER_BINDINGS = Object.freeze({ 'updatePage', 'uploadThumbnail', ]), + pageSyncService: Object.freeze(['syncUserGeneratedPages']), pageLiveEditService: Object.freeze(['applyAgentPatch', 'bindSession', 'getRevisionSnapshot']), assetAgentService: Object.freeze(['applyAgentDelete']), publicationService: Object.freeze([ diff --git a/package.json b/package.json index bcf1295..1008ee8 100644 --- a/package.json +++ b/package.json @@ -51,12 +51,13 @@ "smoke:memory-v2-qdrant": "node scripts/smoke-memory-v2-qdrant.mjs", "smoke:memory-v2-external": "node scripts/smoke-memory-v2-external.mjs", "mock:memory-v2-services": "node scripts/mock-memory-v2-services.mjs", - "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", + "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", "test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs", "verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs", "verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs", "verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs", "verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime", + "verify:mindspace-page-sync-guards": "node scripts/verify-mindspace-page-sync-guards.mjs", "verify:wechat-channel-isolation": "node scripts/check-wechat-channel-isolation.mjs", "test:mindspace-e2e": "node scripts/mindspace-e2e.mjs", "test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs", diff --git a/public-site-bases.test.mjs b/public-site-bases.test.mjs new file mode 100644 index 0000000..ccea7a9 --- /dev/null +++ b/public-site-bases.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + DEFAULT_PLAZA_PUBLIC_BASE, + DEFAULT_LOCAL_PLAZA_PUBLIC_BASE, + resolvePlazaHomePath, + resolvePlazaPostPath, + resolvePlazaPublicBase, +} from './src/utils/public-site-bases.mjs'; + +test('resolvePlazaPublicBase uses local default on localhost without configured base', () => { + assert.equal( + resolvePlazaPublicBase({ hostname: '127.0.0.1', dev: false }), + DEFAULT_LOCAL_PLAZA_PUBLIC_BASE, + ); +}); + +test('resolvePlazaPublicBase uses plaza.tkmind.cn on production host without configured base', () => { + assert.equal( + resolvePlazaPublicBase({ hostname: 'm.tkmind.cn', dev: false }), + DEFAULT_PLAZA_PUBLIC_BASE, + ); +}); + +test('resolvePlazaPublicBase ignores loopback configured base on production host', () => { + assert.equal( + resolvePlazaPublicBase({ + configuredBase: 'http://127.0.0.1:3001', + hostname: 'm.tkmind.cn', + dev: false, + }), + DEFAULT_PLAZA_PUBLIC_BASE, + ); +}); + +test('resolvePlazaPublicBase keeps loopback configured base on local host', () => { + assert.equal( + resolvePlazaPublicBase({ + configuredBase: 'http://127.0.0.1:3001', + hostname: '127.0.0.1', + dev: false, + }), + 'http://127.0.0.1:3001', + ); +}); + +test('resolvePlazaPublicBase respects explicit production configured base', () => { + assert.equal( + resolvePlazaPublicBase({ + configuredBase: 'https://plaza.tkmind.cn', + hostname: 'm.tkmind.cn', + dev: false, + }), + 'https://plaza.tkmind.cn', + ); +}); + +test('resolvePlazaHomePath and resolvePlazaPostPath append plaza routes', () => { + assert.equal(resolvePlazaHomePath('https://plaza.tkmind.cn'), 'https://plaza.tkmind.cn/plaza'); + assert.equal( + resolvePlazaPostPath('https://plaza.tkmind.cn', 'post-1'), + 'https://plaza.tkmind.cn/plaza/p/post-1', + ); +}); diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index 571cb1c..9ec2680 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -81,7 +81,18 @@ async function writeFile(targetPath, content) { async function buildFrontend() { if (skipBuild) return; console.log('==> 构建 Portal 前端 dist'); - await run('npm', ['run', 'build']); + const buildEnv = { + ...process.env, + VITE_PLAZA_BASE: + process.env.VITE_PLAZA_BASE ?? + process.env.PLAZA_PUBLIC_BASE ?? + 'https://plaza.tkmind.cn', + VITE_MINDSPACE_BASE: + process.env.VITE_MINDSPACE_BASE ?? + process.env.H5_PUBLIC_BASE_URL ?? + 'https://m.tkmind.cn', + }; + await run('npm', ['run', 'build'], { env: buildEnv }); } async function bundleServer() { @@ -397,6 +408,11 @@ async function writeMetadata() { ' TKMIND_API_TARGETS / TKMIND_API_TARGET / TKMIND_API_TARGET_1', ' H5_USERS_ROOT / MINDSPACE_STORAGE_ROOT / MEMIND_SHARED_PUBLISH_ROOT', '', + 'Frontend compile-time public URLs (baked into dist at build):', + ' VITE_PLAZA_BASE defaults to https://plaza.tkmind.cn in build-portal-runtime.mjs', + ' VITE_MINDSPACE_BASE defaults to H5_PUBLIC_BASE_URL or https://m.tkmind.cn', + ' Do not build production dist with VITE_PLAZA_BASE=http://127.0.0.1:* in .env', + '', 'Deployment and operations transport:', ' H5 public domain: m.tkmind.cn', ' Current public path: m.tkmind.cn -> 105 nginx -> 103 Portal :8081', diff --git a/scripts/verify-mindspace-page-sync-guards.mjs b/scripts/verify-mindspace-page-sync-guards.mjs new file mode 100644 index 0000000..36f65f1 --- /dev/null +++ b/scripts/verify-mindspace-page-sync-guards.mjs @@ -0,0 +1,68 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(new URL('..', import.meta.url).pathname); + +function read(relativePath) { + return fs.readFileSync(path.join(root, relativePath), 'utf8'); +} + +function assertIncludes(label, source, snippet) { + if (!source.includes(snippet)) { + throw new Error(`${label} is missing required guard snippet:\n${snippet}`); + } +} + +const serverSource = read('server.mjs'); +const pagesSource = read('mindspace-pages.mjs'); +const contractSource = read('mindspace-server-adapter-contract.mjs'); + +assertIncludes( + 'server.mjs syncUserGeneratedPages', + serverSource, + 'await mindSpacePageSync.syncUserGeneratedPages(userId);', +); +if (serverSource.includes("mindSpaceServerRuntime.adapterKind === 'remote'")) { + throw new Error( + 'server.mjs must not skip page sync in remote mode; use pageSyncService RPC instead', + ); +} + +assertIncludes( + 'mindspace-server-adapter-contract.mjs', + contractSource, + "pageSyncService: Object.freeze(['syncUserGeneratedPages'])", +); + +assertIncludes( + 'mindspace-pages.mjs', + pagesSource, + 'readPageContentWithWorkspaceFallback', +); +assertIncludes( + 'mindspace-pages.mjs workspace fallback', + pagesSource, + "snapshot.relative_path ?? null", +); + +const publicSiteBasesSource = read('src/utils/public-site-bases.mjs'); +const publicUrlSource = read('src/utils/publicUrl.ts'); +const buildRuntimeSource = read('scripts/build-portal-runtime.mjs'); + +assertIncludes( + 'public-site-bases.mjs loopback guard', + publicSiteBasesSource, + 'isLoopbackPublicBase', +); +assertIncludes( + 'publicUrl.ts uses public-site-bases', + publicUrlSource, + "from './public-site-bases.mjs'", +); +assertIncludes( + 'build-portal-runtime.mjs production plaza default', + buildRuntimeSource, + 'https://plaza.tkmind.cn', +); + +console.log('mindspace page sync + thumbnail regression guards ok'); diff --git a/server.mjs b/server.mjs index 8ab61d4..2f38dbd 100644 --- a/server.mjs +++ b/server.mjs @@ -122,7 +122,6 @@ import { normalizePublicHtmlRelativePath, syncPublicHtmlAfterFinish, } from './mindspace-public-finish-sync.mjs'; -import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs'; import { extractCoverSignals, generateHtmlThumbnail } from './mindspace-thumbnails.mjs'; import { extractSharePreviewMeta, @@ -299,6 +298,7 @@ let mindSpaceAudit = null; let mindSpaceServiceFacade = null; let mindSpaceConversationPackageRegistry = null; let mindSpacePages = null; +let mindSpacePageSync = null; let mindSpacePageLiveEdit = null; let mindSpaceAssetAgent = null; let mindSpacePageEditSession = null; @@ -363,6 +363,7 @@ async function bootstrapUserAuth() { resolveUserIdForAgentSession, resolveSessionSnapshot: (sessionId) => sessionSnapshotService?.get?.(sessionId), registerPublicHtmlArtifactsForConversation, + syncWorkspaceAssetsEnabled: WORKSPACE_MAINTENANCE_ENABLED, remote: mindSpaceServerRuntime.remote, logger: console, }), @@ -372,6 +373,7 @@ async function bootstrapUserAuth() { mindSpaceConversationPackageRegistry = mindSpaceRuntimeAdapter.conversationPackageRegistry; mindSpaceAssets = mindSpaceRuntimeAdapter.assetService; mindSpacePages = mindSpaceRuntimeAdapter.pageService; + mindSpacePageSync = mindSpaceRuntimeAdapter.pageSyncService; mindSpacePageLiveEdit = mindSpaceRuntimeAdapter.pageLiveEditService; mindSpaceAssetAgent = mindSpaceRuntimeAdapter.assetAgentService; mindSpacePublications = mindSpaceRuntimeAdapter.publicationService; @@ -2942,36 +2944,10 @@ async function resolveExistingSavedPage(userId, { sessionId, messageId, relative const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']); -const pageSyncInFlight = new Map(); - +// REGRESSION GUARD: mindspace-page-sync-thumbnail — remote 也经 pageSyncService RPC 同步 public HTML async function syncUserGeneratedPages(userId) { - if (!mindSpacePages || !authPool || !userId) return; - if (mindSpaceServerRuntime.adapterKind === 'remote') return; - - let inFlight = pageSyncInFlight.get(userId); - if (!inFlight) { - inFlight = syncGeneratedPagesFromPublicAssets({ - pool: authPool, - pageService: mindSpacePages, - assetService: mindSpaceAssets, - userId, - publishDir: resolveMindSpaceUserPublishDir(__dirname, { id: userId }), - syncWorkspaceAssets: - mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED - ? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options) - : null, - }) - .catch((error) => { - console.warn('[MindSpace] page sync failed:', error?.message ?? error); - }) - .finally(() => { - if (pageSyncInFlight.get(userId) === inFlight) { - pageSyncInFlight.delete(userId); - } - }); - pageSyncInFlight.set(userId, inFlight); - } - await inFlight; + if (!mindSpacePageSync || !userId) return; + await mindSpacePageSync.syncUserGeneratedPages(userId); } async function resolveChatSaveBundle(user, h5Root, input = {}) { diff --git a/src/utils/public-site-bases.mjs b/src/utils/public-site-bases.mjs new file mode 100644 index 0000000..08af4aa --- /dev/null +++ b/src/utils/public-site-bases.mjs @@ -0,0 +1,43 @@ +export const DEFAULT_PLAZA_PUBLIC_BASE = 'https://plaza.tkmind.cn'; +export const DEFAULT_LOCAL_PLAZA_PUBLIC_BASE = 'http://127.0.0.1:3001'; + +export function isLocalSiteHost(hostname) { + const host = String(hostname ?? '').trim().toLowerCase(); + return !host || host === 'localhost' || host === '127.0.0.1' || host.endsWith('.localhost'); +} + +export function isLoopbackPublicBase(base) { + const value = String(base ?? '').trim(); + if (!value) return false; + return ( + /^https?:\/\/127\.0\.0\.1(?::\d+)?(?:\/|$)/i.test(value) || + /^https?:\/\/localhost(?::\d+)?(?:\/|$)/i.test(value) + ); +} + +export function resolvePlazaPublicBase({ + configuredBase = '', + dev = false, + hostname = '', +} = {}) { + const configured = String(configuredBase ?? '').trim().replace(/\/$/, ''); + const localHost = isLocalSiteHost(hostname); + if (configured) { + if (!localHost && !dev && isLoopbackPublicBase(configured)) { + return DEFAULT_PLAZA_PUBLIC_BASE; + } + return configured; + } + if (dev || localHost) { + return DEFAULT_LOCAL_PLAZA_PUBLIC_BASE; + } + return DEFAULT_PLAZA_PUBLIC_BASE; +} + +export function resolvePlazaHomePath(base) { + return `${String(base ?? '').replace(/\/$/, '')}/plaza`; +} + +export function resolvePlazaPostPath(base, postId) { + return `${String(base ?? '').replace(/\/$/, '')}/plaza/p/${encodeURIComponent(postId)}`; +} diff --git a/src/utils/publicUrl.ts b/src/utils/publicUrl.ts index 2629501..f13ed22 100644 --- a/src/utils/publicUrl.ts +++ b/src/utils/publicUrl.ts @@ -1,3 +1,9 @@ +import { + resolvePlazaHomePath, + resolvePlazaPostPath, + resolvePlazaPublicBase, +} from './public-site-bases.mjs'; + export function resolvePublicPageUrl(publicUrl: string): string { const value = String(publicUrl ?? '').trim(); if (!value) return value; @@ -7,26 +13,20 @@ export function resolvePublicPageUrl(publicUrl: string): string { return `${window.location.origin}/${value.replace(/^\/+/, '')}`; } +function resolvedPlazaBase(): string { + return resolvePlazaPublicBase({ + configuredBase: import.meta.env.VITE_PLAZA_BASE, + dev: import.meta.env.DEV, + hostname: typeof window !== 'undefined' ? window.location.hostname : '', + }); +} + export function resolvePlazaPostUrl(postId: string): string { - const configured = String(import.meta.env.VITE_PLAZA_BASE ?? '').trim(); - if (configured) { - return `${configured.replace(/\/$/, '')}/plaza/p/${encodeURIComponent(postId)}`; - } - if (import.meta.env.DEV) { - return `http://127.0.0.1:3001/plaza/p/${encodeURIComponent(postId)}`; - } - return `${window.location.origin}/plaza/p/${encodeURIComponent(postId)}`; + return resolvePlazaPostPath(resolvedPlazaBase(), postId); } export function resolvePlazaHomeUrl(): string { - const configured = String(import.meta.env.VITE_PLAZA_BASE ?? '').trim(); - if (configured) { - return `${configured.replace(/\/$/, '')}/plaza`; - } - if (import.meta.env.DEV) { - return 'http://127.0.0.1:3001/plaza'; - } - return `${window.location.origin}/plaza`; + return resolvePlazaHomePath(resolvedPlazaBase()); } export function resolveMindSpaceHomeUrl(): string {