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 = /]+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; }