import fs from 'node:fs/promises'; import path from 'node:path'; import { createPageService, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; import { createPublicationService, publicationInternals } from './mindspace-publications.mjs'; import { slugFromPageTitle } from './mindspace-chat-plaza.mjs'; import { writePageAccessPolicy } from './page-data-policy-store.mjs'; import { normalizePageAccessPolicy } from './page-access-policy.mjs'; import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs'; import { syncPageDataPolicyAccessMode } from './page-data-publish-sync.mjs'; import { createMindSpacePublicUrl } from './mindspace-canonical-url.mjs'; import { resolvePageDataDeliveryBaseUrl } from './user-publish.mjs'; import { createUserDataSpaceService } from './user-data-space-service.mjs'; import { assertPolicyMatchesHtmlDatasets, buildPageDataPolicyDatasetsFromRegistry, detectPageDataDatasetUsageFromHtml, } from './page-data-html-detect.mjs'; export const DEFAULT_PAGE_DATA_ADMIN_PASSWORD = '88888888'; export function resolvePageDataBindAccess(accessMode = 'public') { return publicationInternals.normalizeAccessMode(accessMode); } /** password 模式:用户未传口令时使用 DEFAULT_PAGE_DATA_ADMIN_PASSWORD;<8 位抛错 */ export function resolvePageDataBindPassword(accessMode, password = null) { const mode = resolvePageDataBindAccess(accessMode); if (mode !== 'password') return null; const explicit = String(password ?? '').trim(); if (explicit) { return publicationInternals.normalizePassword(explicit, true); } return DEFAULT_PAGE_DATA_ADMIN_PASSWORD; } function titleFromPublicHtml(content, relativePath) { const match = String(content ?? '').match(/]*>([^<]+)<\/title>/i); const fromTitle = match?.[1]?.replace(/\s+/g, ' ').trim(); if (fromTitle) return fromTitle; const basename = path.basename(String(relativePath ?? ''), '.html'); return basename || 'MindSpace 页面'; } function normalizePublicHtmlRelativePath(relativePath) { const normalized = normalizeWorkspaceRelativePath(relativePath); if (!normalized || !normalized.startsWith('public/') || !normalized.toLowerCase().endsWith('.html')) { throw Object.assign(new Error('仅支持 workspace 公开 HTML 页面(public/*.html)'), { code: 'invalid_public_html_path', }); } return normalized; } async function ensureWorkspaceHtmlPage({ userId, relativePath, content, title, mindSpacePages }) { const normalized = normalizePublicHtmlRelativePath(relativePath); let page = await mindSpacePages.findPageByRelativePath(userId, normalized); const pageInput = { title: title ?? page?.title ?? titleFromPublicHtml(content, normalized), content, contentFormat: 'html', pageType: 'html', categoryCode: 'draft', }; if (page) { return mindSpacePages.updatePage( userId, page.id, { ...pageInput, expectedVersion: page.versionNo, }, { snapshot: { content_mode: 'static_html', relative_path: normalized, }, }, ); } return mindSpacePages.createFromChat(userId, pageInput, { snapshot: { content_mode: 'static_html', relative_path: normalized, }, }); } async function ensureWorkspaceHtmlPublished({ userId, page, mindSpacePublications, accessMode, password, urlSlug, }) { const normalizedAccessMode = resolvePageDataBindAccess(accessMode); const resolvedPassword = resolvePageDataBindPassword(normalizedAccessMode, password); const existing = await mindSpacePublications.getCurrent(userId, page.id); if (existing?.id) { const accessChanged = existing.accessMode !== normalizedAccessMode; const needsPassword = normalizedAccessMode === 'password' && Boolean(resolvedPassword); if (accessChanged || needsPassword) { const updated = await mindSpacePublications.updatePublicationAccess(userId, existing.id, { accessMode: normalizedAccessMode, password: resolvedPassword, }); existing.accessMode = updated.accessMode; existing.id = updated.id; existing.publicUrl = updated.publicUrl; } if (page.pageType === 'html' && mindSpacePublications.refreshOnlinePublicationHtml) { return ( (await mindSpacePublications.refreshOnlinePublicationHtml(userId, page.id)) ?? existing ); } return existing; } const preferredSlug = urlSlug || slugFromPageTitle(page.title, page.id); return mindSpacePublications.publish(userId, page.id, { pageVersionId: page.currentVersionId, accessMode: normalizedAccessMode, password: resolvedPassword, urlSlug: preferredSlug, autoAcknowledgeFindings: true, }); } function buildWorkspacePublicUrl(userId, relativePath) { const publicBaseUrl = resolvePageDataDeliveryBaseUrl(); return createMindSpacePublicUrl({ publicBaseUrl, ownerKey: userId, relativePath, }); } function assertRegisteredDatasetTables(userDataSpace, registryDatasets, usage) { for (const dataset of registryDatasets) { const requiredUsage = usage.get(dataset.name) ?? {}; if (requiredUsage.insert && !dataset.actions.includes('insert')) { throw Object.assign(new Error(`dataset「${dataset.name}」未开放写入`), { code: 'dataset_action_not_registered', datasetName: dataset.name, action: 'insert', }); } if (requiredUsage.read && !dataset.actions.includes('read')) { throw Object.assign(new Error(`dataset「${dataset.name}」未开放读取`), { code: 'dataset_action_not_registered', datasetName: dataset.name, action: 'read', }); } if (requiredUsage.softDelete && !dataset.actions.includes('soft_delete')) { throw Object.assign(new Error(`dataset「${dataset.name}」未开放软删除`), { code: 'dataset_action_not_registered', datasetName: dataset.name, action: 'soft_delete', }); } const actualColumns = userDataSpace.listTableColumns(dataset.table); if (!actualColumns.length) { throw Object.assign(new Error(`dataset 对应表不存在:${dataset.table}`), { code: 'table_not_found', datasetName: dataset.name, }); } const actualNames = new Set(actualColumns.map((column) => column.name)); const configuredColumns = Object.values(dataset.columns ?? {}).flat(); const missingColumns = [...new Set(configuredColumns)].filter((column) => !actualNames.has(column)); if (missingColumns.length) { throw Object.assign( new Error(`dataset「${dataset.name}」注册字段不存在:${missingColumns.join(', ')}`), { code: 'dataset_schema_mismatch', datasetName: dataset.name, missingColumns }, ); } } } async function assertRegisteredDatasetTablesAsync(userDataSpace, registryDatasets, usage) { for (const dataset of registryDatasets) { const adapter = { listTableColumns: () => userDataSpace.listTableColumns(dataset.table), }; const columns = await adapter.listTableColumns(); assertRegisteredDatasetTables({ listTableColumns: () => columns }, [dataset], usage); } } /** * Page Data policy must be derived from a registered SQLite dataset, never * accepted solely from an Agent-supplied policy. This runs before page or * publication creation so an incomplete data setup cannot leave a live page. */ export function resolveRegisteredPageDataPolicy({ html, userId, accessMode, pageDataPolicy = null, userDataSpace, } = {}) { const usage = detectPageDataDatasetUsageFromHtml(html); if (!usage.size) return null; if (!userDataSpace) throw new Error('缺少 Page Data 数据空间'); if (pageDataPolicy?.datasets) { assertPolicyMatchesHtmlDatasets(html, pageDataPolicy.datasets); } const registryDatasets = userDataSpace.listDatasets(); const datasets = buildPageDataPolicyDatasetsFromRegistry({ html, registryDatasets, usage, }); assertRegisteredDatasetTables( userDataSpace, registryDatasets.filter((dataset) => datasets[dataset.name]), usage, ); return { ...pageDataPolicy, ownerUserId: String(pageDataPolicy?.ownerUserId ?? userId).trim(), accessMode: pageDataPolicy?.accessMode ?? accessMode, // The registry is authoritative for actions and allowed columns. Agent // input can describe the page, but cannot manufacture a dataset policy. datasets, }; } export async function resolveRegisteredPageDataPolicyAsync(options = {}) { const { html, userId, accessMode, pageDataPolicy = null, userDataSpace } = options; const usage = detectPageDataDatasetUsageFromHtml(html); if (!usage.size) return null; if (!userDataSpace) throw new Error('缺少 Page Data 数据空间'); if (pageDataPolicy?.datasets) assertPolicyMatchesHtmlDatasets(html, pageDataPolicy.datasets); const registryDatasets = await userDataSpace.listDatasets(); const datasets = buildPageDataPolicyDatasetsFromRegistry({ html, registryDatasets, usage }); await assertRegisteredDatasetTablesAsync( userDataSpace, registryDatasets.filter((dataset) => datasets[dataset.name]), usage, ); return { ...pageDataPolicy, ownerUserId: String(pageDataPolicy?.ownerUserId ?? userId).trim(), accessMode: pageDataPolicy?.accessMode ?? accessMode, datasets, }; } export async function bindWorkspaceHtmlForPageData({ pool, h5Root, storageRoot, userId, workspaceRoot, relativePath, title = null, accessMode = 'public', password = null, pageDataPolicy = null, urlSlug = null, publicPageLimit, }) { if (!pool) throw new Error('数据库未配置,无法绑定页面'); if (!userId) throw new Error('缺少 userId'); if (!workspaceRoot) throw new Error('缺少 workspaceRoot'); const normalized = normalizePublicHtmlRelativePath(relativePath); const absoluteHtmlPath = path.join(workspaceRoot, ...normalized.split('/')); const content = await fs.readFile(absoluteHtmlPath, 'utf8'); const normalizedAccessMode = resolvePageDataBindAccess(accessMode); const resolvedPassword = resolvePageDataBindPassword(normalizedAccessMode, password); const userDataSpace = createUserDataSpaceService({ workspaceRoot, userId, query: pool }); const resolvedPageDataPolicy = await resolveRegisteredPageDataPolicyAsync({ html: content, userId, accessMode: normalizedAccessMode, pageDataPolicy, userDataSpace, }); const mindSpacePages = createPageService(pool, { h5Root, storageRoot }); const mindSpacePublications = createPublicationService(pool, { h5Root, storageRoot, publicPageLimit, }); const page = await ensureWorkspaceHtmlPage({ userId, relativePath: normalized, content, title, mindSpacePages, }); const publication = await ensureWorkspaceHtmlPublished({ userId, page, mindSpacePublications, accessMode: normalizedAccessMode, password: resolvedPassword, urlSlug, }); let policy = null; if (resolvedPageDataPolicy) { const ownerUserId = String(resolvedPageDataPolicy.ownerUserId ?? userId).trim(); policy = normalizePageAccessPolicy( { pageId: page.id, ownerUserId, accessMode: resolvedPageDataPolicy.accessMode ?? normalizedAccessMode, datasets: resolvedPageDataPolicy.datasets, defaultVisitorRole: resolvedPageDataPolicy.defaultVisitorRole, visitors: resolvedPageDataPolicy.visitors, roles: resolvedPageDataPolicy.roles, }, { fallbackPageId: page.id, fallbackOwnerUserId: ownerUserId }, ); policy = writePageAccessPolicy(workspaceRoot, policy); await upsertPageDataPolicyIndex(pool, policy).catch(() => null); policy = syncPageDataPolicyAccessMode(workspaceRoot, page.id, publication.accessMode, ownerUserId) ?? policy; } const workspaceUrl = buildWorkspacePublicUrl(userId, normalized); return { pageId: page.id, pageTitle: page.title, relativePath: normalized, publicationId: publication.id, publicationAccessMode: publication.accessMode, publicationPasswordApplied: normalizedAccessMode === 'password', publicationUrl: workspaceUrl, workspaceUrl, deliveryUrl: workspaceUrl, deliveryHint: '向用户交付静态页时优先使用 deliveryUrl(MindSpace /MindSpace/<用户ID>/public/... 路径),publicationUrl 仅作补充', policy, }; }