6ef3ea89c6
# Conflicts: # mindspace-page-data-finish-guard.mjs # page-data-delivery-assess.mjs
333 lines
11 KiB
JavaScript
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;
|
|
}
|