/** * 从 MindSpace 公开 HTML 中解析 Page Data API 引用的 dataset 与读写意图。 */ const PAGE_DATA_CLIENT_SCRIPT_PATTERN = /\/assets\/page-data-client\.js/i; const FORBIDDEN_LEGACY_PAGE_DATA_API_PATTERN = /\/api\/page-data\b/i; export function htmlUsesForbiddenLegacyPageDataApi(html) { return FORBIDDEN_LEGACY_PAGE_DATA_API_PATTERN.test(String(html ?? '')); } export function htmlUsesPageDataApi(html) { const content = String(html ?? ''); const usage = detectPageDataDatasetUsageFromHtml(content); return ( usage.size > 0 || PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content) || /\bMindSpacePageData\b/.test(content) || htmlUsesForbiddenLegacyPageDataApi(content) ); } export function detectPageDataDatasetUsageFromHtml(html) { const text = String(html ?? ''); const datasets = new Map(); const constants = new Map(); for (const match of text.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*['"]([^'"]+)['"]/g)) { constants.set(match[1], match[2]); } function remember(name, patch) { const key = String(name ?? '').trim(); if (!key) return; datasets.set(key, { ...(datasets.get(key) ?? {}), ...patch }); } for (const match of text.matchAll(/\.insertRow\(\s*['"]([^'"]+)['"]/g)) { remember(match[1], { insert: true }); } for (const match of text.matchAll(/\.listRows\(\s*['"]([^'"]+)['"]/g)) { remember(match[1], { read: true }); } for (const match of text.matchAll(/\.deleteRow\(\s*['"]([^'"]+)['"]/g)) { // The browser client's deleteRow endpoint intentionally falls back to a // soft delete unless a policy explicitly grants hard_delete. A page using // this API must therefore only require the safe, default capability. remember(match[1], { softDelete: true }); } for (const match of text.matchAll(/\.insertRow\(\s*([A-Za-z_$][\w$]*)/g)) { remember(constants.get(match[1]) ?? match[1], { insert: true }); } for (const match of text.matchAll(/\.listRows\(\s*([A-Za-z_$][\w$]*)/g)) { remember(constants.get(match[1]) ?? match[1], { read: true }); } for (const match of text.matchAll(/\.deleteRow\(\s*([A-Za-z_$][\w$]*)/g)) { remember(constants.get(match[1]) ?? match[1], { softDelete: true }); } return datasets; } export function inferPageDataBindAccessMode(relativePath, html) { const usage = detectPageDataDatasetUsageFromHtml(html); const hasRead = [...usage.values()].some((item) => item.read); const hasInsert = [...usage.values()].some((item) => item.insert); // A page that exchanges a password for a Page Data token is intentionally // protected even when it both reads and writes its dataset (for example, a // personal tracker). Do not silently re-bind it as public merely because it // is not named "-admin.html". const usesServerAuthentication = /\.\s*authenticate\s*\(/.test(String(html ?? '')); if ( /-admin\.html$/i.test(String(relativePath ?? '')) || (hasRead && !hasInsert) || usesServerAuthentication ) { return 'password'; } return 'public'; } export function assertPolicyMatchesHtmlDatasets(html, policyDatasets) { const usage = detectPageDataDatasetUsageFromHtml(html); const htmlNames = [...usage.keys()].sort(); const policyNames = Object.keys(policyDatasets ?? {}).sort(); if (!htmlNames.length) return usage; if (htmlNames.join(',') !== policyNames.join(',')) { throw Object.assign( new Error( `Page Data 策略 dataset(${policyNames.join(', ') || '无'})与 HTML 中 insertRow/listRows 引用的 dataset(${htmlNames.join(', ')})不一致。请使用与页面脚本相同的 dataset 名称。`, ), { code: 'dataset_policy_html_mismatch', htmlDatasets: htmlNames, policyDatasets: policyNames, }, ); } return usage; } export function buildPageDataPolicyDatasetsFromRegistry({ html, registryDatasets, usage }) { const detected = usage ?? detectPageDataDatasetUsageFromHtml(html); if (!detected.size) return null; const registryMap = new Map(registryDatasets.map((dataset) => [dataset.name, dataset])); const datasets = {}; for (const [name, perms] of detected) { const registered = registryMap.get(name); if (!registered) { throw Object.assign( new Error(`HTML 引用的 dataset「${name}」尚未注册,请先执行 private_data_register_dataset`), { code: 'dataset_not_registered', datasetName: name }, ); } const entry = { insert: false, read: false, update: false, softDelete: false, hardDelete: false, columns: {}, }; if (perms.insert && !perms.read) { entry.insert = true; entry.columns.insert = registered.columns?.insert ?? []; } else if (perms.read && !perms.insert) { entry.read = true; entry.columns.read = registered.columns?.read ?? []; } else { if (perms.insert) { entry.insert = true; entry.columns.insert = registered.columns?.insert ?? []; } if (perms.read) { entry.read = true; entry.columns.read = registered.columns?.read ?? []; } } if (perms.softDelete) { entry.softDelete = true; entry.columns.soft_delete = registered.columns?.soft_delete ?? ['id']; } datasets[name] = entry; } return datasets; }