Files
memind/page-data-delivery-assess.mjs
john 6ef3ea89c6 merge: integrate feature/0713-dev into main
# Conflicts:
#	mindspace-page-data-finish-guard.mjs
#	page-data-delivery-assess.mjs
2026-07-13 15:33:45 +08:00

333 lines
11 KiB
JavaScript

import fs from 'node:fs/promises';
import {
detectPageDataDatasetUsageFromHtml,
htmlUsesForbiddenLegacyPageDataApi,
inferPageDataBindAccessMode,
} from './page-data-html-detect.mjs';
import { listPageAccessPolicies, readPageAccessPolicy } from './page-data-policy-store.mjs';
import { createUserDataSpaceService } from './user-data-space-service.mjs';
const INJECTION_PAGE_ID_PATTERN =
/window\.__MINDSPACE_PAGE_DATA__\s*=\s*\{[^}]*(?:"pageId"|pageId)\s*:\s*"([^"]+)"/i;
const INJECTION_META_PAGE_ID_PATTERN =
/<meta[^>]+name=["']mindspace-page-data-page-id["'][^>]+content=["']([^"']+)["']/i;
export function normalizePageDataApiBase(apiBase) {
const raw = String(apiBase ?? '').trim().replace(/\/$/, '');
if (!raw) return '/api';
if (/\/api$/i.test(raw)) return raw;
return `${raw}/api`;
}
export function extractInjectedPageIdFromHtml(html) {
const content = String(html ?? '');
const fromScript = content.match(INJECTION_PAGE_ID_PATTERN)?.[1]?.trim();
if (fromScript) return fromScript;
return content.match(INJECTION_META_PAGE_ID_PATTERN)?.[1]?.trim() ?? null;
}
export function buildSmokeInsertRow(policy, datasetName) {
const columns = policy?.datasets?.[datasetName]?.columns?.insert ?? [];
const row = {};
for (const column of columns) {
if (/rating|score|星级|评分/i.test(column)) {
row[column] = 3;
} else if (/phone|手机|电话/i.test(column)) {
row[column] = '13800000000';
} else {
row[column] = 'smoke-test';
}
}
return row;
}
export function assessPageDataAuthenticationContract({ policy, html } = {}) {
const accessMode = String(policy?.accessMode ?? '').trim();
const content = String(html ?? '');
const usesServerAuthentication = /\.\s*authenticate\s*\(/.test(content);
const passwordValue = String.raw`\b(?:pwd|pass(?:word)?|passwordInput|adminPassword)\w*(?:\.value)?`;
const passwordComparison = String.raw`(?:===?|!==?)`;
const passwordOperand = String.raw`(?:[A-Za-z_$][\w$]*|['"\x60][^'"\x60]*['"\x60])`;
const usesClientSidePasswordCheck = new RegExp(
`(?:${passwordValue}\\s*${passwordComparison}\\s*${passwordOperand}|${passwordOperand}\\s*${passwordComparison}\\s*${passwordValue})`,
'i',
).test(content);
const reasons = [];
if (accessMode === 'password' && !usesServerAuthentication) {
reasons.push('password_page_missing_server_authentication');
}
if (accessMode === 'public' && usesServerAuthentication) {
reasons.push('public_page_cannot_use_password_authentication');
}
if (accessMode === 'public' && usesClientSidePasswordCheck) {
reasons.push('public_page_uses_client_side_password_gate');
}
if (accessMode === 'password' && usesClientSidePasswordCheck) {
reasons.push('password_page_uses_client_side_password_gate');
}
return reasons;
}
export async function queryOnlinePublication(pool, pageId) {
if (!pool || !pageId) return null;
const [rows] = await pool.query(
`SELECT id, page_id, status, access_mode
FROM h5_publish_records
WHERE page_id = ? AND status = 'online'
ORDER BY published_at DESC
LIMIT 1`,
[pageId],
);
return rows[0] ?? null;
}
function findWorkspacePolicyForHtml({ publishDir, relativePath, html }) {
const usage = detectPageDataDatasetUsageFromHtml(html);
const htmlDatasetNames = [...usage.keys()].sort().join(',');
const expectedAccessMode = inferPageDataBindAccessMode(relativePath, html);
for (const policy of listPageAccessPolicies(publishDir)) {
const policyNames = Object.keys(policy?.datasets ?? {}).sort().join(',');
if (policyNames !== htmlDatasetNames) continue;
if (String(policy.accessMode ?? '').trim() !== expectedAccessMode) continue;
let matches = true;
for (const [datasetName, perms] of usage) {
const policyDataset = policy.datasets?.[datasetName];
if (!policyDataset) {
matches = false;
break;
}
if (perms.insert && !policyDataset.insert) matches = false;
if (perms.read && !policyDataset.read) matches = false;
}
if (matches) return policy;
}
return null;
}
function assessWorkspacePageDataPolicyReadiness({ publishDir, relativePath, html }) {
const usage = detectPageDataDatasetUsageFromHtml(html);
if (!usage.size) {
return { ready: true, reasons: [] };
}
const reasons = [];
const policy = findWorkspacePolicyForHtml({ publishDir, relativePath, html });
if (!policy) {
reasons.push('missing_workspace_policy');
}
return { ready: reasons.length === 0, reasons, policy };
}
export async function assessWorkspacePageDataReadiness({ userId, publishDir, relativePath, html }) {
const usage = detectPageDataDatasetUsageFromHtml(html);
const policyAssessment = assessWorkspacePageDataPolicyReadiness({ publishDir, relativePath, html });
if (!usage.size) return policyAssessment;
const reasons = [...policyAssessment.reasons];
const dataSpace = createUserDataSpaceService({ workspaceRoot: publishDir, userId });
for (const datasetName of usage.keys()) {
if (!(await dataSpace.getDataset(datasetName))) {
reasons.push(`dataset_not_registered:${datasetName}`);
}
}
return { ...policyAssessment, ready: reasons.length === 0, reasons };
}
export async function assessPageDataHtmlBinding({
pool,
userId,
publishDir,
relativePath,
html,
findPageByRelativePath,
}) {
const usage = detectPageDataDatasetUsageFromHtml(html);
const reasons = [];
if (htmlUsesForbiddenLegacyPageDataApi(html)) {
reasons.push('forbidden_legacy_page_data_api');
}
if (!usage.size) {
return {
bound: reasons.length === 0,
reasons: [...new Set(reasons)],
pageId: null,
policy: null,
};
}
const workspace = await assessWorkspacePageDataReadiness({ userId, publishDir, relativePath, html });
reasons.push(...workspace.reasons);
let pageId = null;
if (typeof findPageByRelativePath === 'function') {
const page = await findPageByRelativePath(userId, relativePath).catch(() => null);
pageId = page?.id ? String(page.id).trim() : null;
if (!pageId) {
reasons.push('missing_page_record');
}
} else {
reasons.push('missing_page_lookup');
}
if (pageId) {
const policy = readPageAccessPolicy(publishDir, pageId);
if (!policy) {
reasons.push('missing_policy_for_page');
} else {
const htmlDatasetNames = [...usage.keys()].sort().join(',');
const policyNames = Object.keys(policy.datasets ?? {}).sort().join(',');
if (htmlDatasetNames !== policyNames) {
reasons.push('policy_dataset_mismatch');
}
const expectedAccessMode = inferPageDataBindAccessMode(relativePath, html);
if (String(policy.accessMode ?? '').trim() !== expectedAccessMode) {
reasons.push('policy_access_mode_mismatch');
}
reasons.push(...assessPageDataAuthenticationContract({ policy, html }));
for (const [datasetName, perms] of usage) {
const policyDataset = policy.datasets?.[datasetName];
if (!policyDataset) continue;
if (perms.insert && !policyDataset.insert) reasons.push('missing_insert_permission');
if (perms.read && !policyDataset.read) reasons.push('missing_read_permission');
}
}
if (pool && pageId) {
const publication = await queryOnlinePublication(pool, pageId);
const expectedAccessMode = inferPageDataBindAccessMode(relativePath, html);
if (publication && String(publication.access_mode ?? '').trim() !== expectedAccessMode) {
reasons.push('publication_access_mode_mismatch');
}
}
}
return {
bound: reasons.length === 0,
reasons: [...new Set(reasons)],
pageId,
policy: pageId ? readPageAccessPolicy(publishDir, pageId) : workspace.policy ?? null,
};
}
export async function fetchWorkspaceHtmlInjection({ url, fetchImpl = fetch }) {
const response = await fetchImpl(String(url));
if (!response.ok) {
return {
ok: false,
reason: `http_${response.status}`,
pageId: null,
html: '',
};
}
const html = await response.text();
const pageId = extractInjectedPageIdFromHtml(html);
if (!pageId) {
return { ok: false, reason: 'missing_page_data_injection', pageId: null, html };
}
return { ok: true, reason: null, pageId, html };
}
export async function smokeTestPageDataInsert({
apiBase,
pageId,
dataset,
policy,
fetchImpl = fetch,
}) {
const base = normalizePageDataApiBase(apiBase);
const row = buildSmokeInsertRow(policy, dataset);
if (!pageId || !dataset || !Object.keys(row).length) {
return { ok: false, reason: 'smoke_payload_unavailable', status: 0, body: null };
}
const response = await fetchImpl(
`${base}/public/pages/${encodeURIComponent(pageId)}/data/${encodeURIComponent(dataset)}/rows`,
{
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify(row),
},
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
return {
ok: false,
reason: body?.error?.code ?? 'insert_failed',
status: response.status,
body,
};
}
return { ok: true, reason: null, status: response.status, body };
}
export async function verifyPageDataDeliveryArtifacts({
artifacts = [],
publishDir,
apiBase,
pool,
userId,
findPageByRelativePath,
fetchImpl = fetch,
}) {
const failures = [];
for (const artifact of artifacts) {
const relativePath = artifact.relativePath;
const html = await fs.readFile(artifact.localPath, 'utf8').catch(() => '');
const usage = detectPageDataDatasetUsageFromHtml(html);
const insertDataset = [...usage.entries()].find(([, perms]) => perms.insert)?.[0] ?? null;
const assessment = await assessPageDataHtmlBinding({
pool,
userId,
publishDir,
relativePath,
html,
findPageByRelativePath,
});
if (!assessment.bound) {
failures.push({
relativePath,
stage: 'binding',
reason: assessment.reasons.join(',') || 'not_bound',
bindingReasons: assessment.reasons,
});
continue;
}
if (artifact.isAdmin || !insertDataset) continue;
const injection = await fetchWorkspaceHtmlInjection({ url: artifact.url, fetchImpl });
if (!injection.ok) {
failures.push({ relativePath, stage: 'injection', reason: injection.reason });
continue;
}
const pageId = injection.pageId;
const policy =
readPageAccessPolicy(publishDir, pageId) ?? assessment.policy;
const smoke = await smokeTestPageDataInsert({
apiBase,
pageId,
dataset: insertDataset,
policy,
fetchImpl,
});
if (smoke.ok) {
continue;
}
failures.push({
relativePath,
stage: 'insert_smoke',
reason: smoke.reason,
status: smoke.status,
bindingReasons: assessment.reasons,
});
}
return failures;
}