2026-07-08 21:10:47 +08:00
|
|
|
|
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';
|
2026-07-13 09:35:40 +08:00
|
|
|
|
import { resolvePageDataDeliveryBaseUrl } from './user-publish.mjs';
|
2026-07-08 21:10:47 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-27 09:54:46 +08:00
|
|
|
|
/** password 模式:用户未传口令时使用 DEFAULT_PAGE_DATA_ADMIN_PASSWORD;<6 位抛错 */
|
2026-07-08 21:10:47 +08:00
|
|
|
|
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) {
|
2026-07-13 09:35:40 +08:00
|
|
|
|
const publicBaseUrl = resolvePageDataDeliveryBaseUrl();
|
2026-07-08 21:10:47 +08:00
|
|
|
|
return createMindSpacePublicUrl({
|
|
|
|
|
|
publicBaseUrl,
|
|
|
|
|
|
ownerKey: userId,
|
|
|
|
|
|
relativePath,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-12 18:39:13 +08:00
|
|
|
|
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',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-07-15 10:56:22 +08:00
|
|
|
|
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',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-07-12 18:39:13 +08:00
|
|
|
|
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 },
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 15:29:29 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-12 18:39:13 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 15:29:29 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 21:10:47 +08:00
|
|
|
|
export async function bindWorkspaceHtmlForPageData({
|
|
|
|
|
|
pool,
|
|
|
|
|
|
h5Root,
|
|
|
|
|
|
storageRoot,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
workspaceRoot,
|
|
|
|
|
|
relativePath,
|
|
|
|
|
|
title = null,
|
2026-07-10 20:44:04 +08:00
|
|
|
|
accessMode = 'public',
|
2026-07-08 21:10:47 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-07-13 15:29:29 +08:00
|
|
|
|
const userDataSpace = createUserDataSpaceService({ workspaceRoot, userId, query: pool });
|
|
|
|
|
|
const resolvedPageDataPolicy = await resolveRegisteredPageDataPolicyAsync({
|
2026-07-12 18:39:13 +08:00
|
|
|
|
html: content,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
accessMode: normalizedAccessMode,
|
|
|
|
|
|
pageDataPolicy,
|
|
|
|
|
|
userDataSpace,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-08 21:10:47 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 20:44:04 +08:00
|
|
|
|
const workspaceUrl = buildWorkspacePublicUrl(userId, normalized);
|
2026-07-08 21:10:47 +08:00
|
|
|
|
return {
|
|
|
|
|
|
pageId: page.id,
|
|
|
|
|
|
pageTitle: page.title,
|
|
|
|
|
|
relativePath: normalized,
|
|
|
|
|
|
publicationId: publication.id,
|
|
|
|
|
|
publicationAccessMode: publication.accessMode,
|
|
|
|
|
|
publicationPasswordApplied: normalizedAccessMode === 'password',
|
2026-07-11 00:23:01 +08:00
|
|
|
|
publicationUrl: workspaceUrl,
|
2026-07-10 20:44:04 +08:00
|
|
|
|
workspaceUrl,
|
|
|
|
|
|
deliveryUrl: workspaceUrl,
|
|
|
|
|
|
deliveryHint:
|
|
|
|
|
|
'向用户交付静态页时优先使用 deliveryUrl(MindSpace /MindSpace/<用户ID>/public/... 路径),publicationUrl 仅作补充',
|
2026-07-08 21:10:47 +08:00
|
|
|
|
policy,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|