Files
memind/page-data-workspace-bind.mjs

311 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { resolvePublicBaseUrl } 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[^>]*>([^<]+)<\/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 = resolvePublicBaseUrl();
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',
});
}
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 },
);
}
}
}
/**
* 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 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 });
const resolvedPageDataPolicy = resolveRegisteredPageDataPolicy({
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:
'向用户交付静态页时优先使用 deliveryUrlMindSpace /MindSpace/<用户ID>/public/... 路径),publicationUrl 仅作补充',
policy,
};
}