Files
memind/page-data-html-detect.mjs
T

167 lines
5.9 KiB
JavaScript
Raw 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.
/**
* 从 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(/\.updateRow\(\s*['"]([^'"]+)['"]/g)) {
remember(match[1], { update: 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 });
}
// `${...}` inside an HTML template literal is a row-id interpolation, not a
// JavaScript identifier passed as the dataset argument. Without this guard,
// snippets such as `onclick="deleteRow(${row.id})"` are detected as a `$`
// dataset and can make the whole workspace binding pass fail.
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(/\.updateRow\(\s*(?!\$\{)([A-Za-z_$][\w$]*)/g)) {
remember(constants.get(match[1]) ?? match[1], { update: 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/updateRow/deleteRow 引用的 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'];
}
if (perms.update) {
entry.update = true;
entry.columns.update = registered.columns?.update ?? [];
}
datasets[name] = entry;
}
return datasets;
}