forked from wangziqi/gongxue-base
222 lines
11 KiB
JavaScript
222 lines
11 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
validateProductionApiBaseUrl,
|
|
validateProductionTenantCode,
|
|
validateProductionWechatAppId,
|
|
validateTenantCodeFormat,
|
|
} from './build-weapp-student.js';
|
|
|
|
const scriptPath = fileURLToPath(import.meta.url);
|
|
const repoRoot = path.resolve(path.dirname(scriptPath), '..');
|
|
const defaultDistRoot = path.join(repoRoot, 'apps', 'taro', 'dist', 'weapp-student');
|
|
|
|
function walkFiles(dir) {
|
|
if (!fs.existsSync(dir)) return [];
|
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
|
const filePath = path.join(dir, entry.name);
|
|
return entry.isDirectory() ? walkFiles(filePath) : [filePath];
|
|
});
|
|
}
|
|
|
|
function directoryBytes(dir) {
|
|
return walkFiles(dir).reduce((total, filePath) => total + fs.statSync(filePath).size, 0);
|
|
}
|
|
|
|
function parsedJson(filePath) {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
}
|
|
|
|
export function extractCompiledPublicBuildConfig(source) {
|
|
const normalizedSource = source.replace(/"([A-Za-z][A-Za-z0-9]*)":/g, '$1:');
|
|
const marker = 'weappTenantMode:';
|
|
const configs = [];
|
|
let markerIndex = normalizedSource.indexOf(marker);
|
|
while (markerIndex >= 0) {
|
|
const start = normalizedSource.lastIndexOf('{portal:', markerIndex);
|
|
if (start >= 0) {
|
|
let inString = false;
|
|
let escaped = false;
|
|
let depth = 0;
|
|
for (let index = start; index < normalizedSource.length; index += 1) {
|
|
const char = normalizedSource[index];
|
|
if (inString) {
|
|
if (escaped) escaped = false;
|
|
else if (char === '\\') escaped = true;
|
|
else if (char === '"') inString = false;
|
|
continue;
|
|
}
|
|
if (char === '"') inString = true;
|
|
else if (char === '{') depth += 1;
|
|
else if (char === '}') {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
const candidate = normalizedSource.slice(start, index + 1);
|
|
const keys = ['portal', 'target', 'releaseMode', 'weappTenantMode', 'apiBaseUrl', 'supabaseUrl', 'supabasePublishableKey', 'tenantCode'];
|
|
const config = Object.fromEntries(keys.map(key => {
|
|
const match = candidate.match(new RegExp(`(?:^|[,\\{])${key}:"((?:\\\\.|[^"\\\\])*)"`));
|
|
if (!match) return [key, ''];
|
|
return [key, JSON.parse(`"${match[1]}"`)];
|
|
}));
|
|
if (config.portal && config.target && config.weappTenantMode) configs.push(config);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
markerIndex = normalizedSource.indexOf(marker, markerIndex + marker.length);
|
|
}
|
|
|
|
const unique = [...new Map(configs.map(config => [JSON.stringify(config), config])).values()];
|
|
if (!unique.length) throw new Error('Compiled __TARO_PUBLIC_BUILD_CONFIG__ was not found in common.js');
|
|
if (unique.length > 1) throw new Error('Multiple conflicting public build configs were found in common.js');
|
|
return unique[0];
|
|
}
|
|
|
|
function validationError(validate, value) {
|
|
try {
|
|
validate(value);
|
|
return '';
|
|
} catch (error) {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
}
|
|
|
|
export function inspectWeappRelease({ distRoot = defaultDistRoot, requireProduction = false } = {}) {
|
|
const checks = [];
|
|
const push = (status, id, message, details = {}) => checks.push({ status, id, message, details });
|
|
|
|
if (!fs.existsSync(distRoot)) {
|
|
push('fail', 'weapp.dist.exists', 'Student WeApp output is missing', { dist: path.relative(repoRoot, distRoot) });
|
|
return checks;
|
|
}
|
|
|
|
push('pass', 'weapp.dist.exists', 'Student WeApp output exists');
|
|
const projectConfigPath = path.join(distRoot, 'project.config.json');
|
|
const appConfigPath = path.join(distRoot, 'app.json');
|
|
const commonPath = path.join(distRoot, 'common.js');
|
|
if (![projectConfigPath, appConfigPath, commonPath].every(fs.existsSync)) {
|
|
push('fail', 'weapp.artifacts.required', 'WeApp output is missing project.config.json, app.json, or common.js');
|
|
return checks;
|
|
}
|
|
|
|
const projectConfig = parsedJson(projectConfigPath);
|
|
const appConfig = parsedJson(appConfigPath);
|
|
const appId = String(projectConfig.appid || '');
|
|
const appIdError = validationError(validateProductionWechatAppId, appId);
|
|
if (requireProduction && appIdError) push('fail', 'weapp.appid', appIdError);
|
|
else push(appIdError ? 'warn' : 'pass', 'weapp.appid', appIdError || 'WeApp AppID is production-ready');
|
|
|
|
if (requireProduction && projectConfig.setting?.urlCheck !== true) push('fail', 'weapp.url_check', 'Production WeApp must enable legal-domain URL checks');
|
|
else push(projectConfig.setting?.urlCheck === true ? 'pass' : 'warn', 'weapp.url_check', projectConfig.setting?.urlCheck === true ? 'WeApp legal-domain URL checks are enabled' : 'WeApp URL checks are disabled for preview');
|
|
|
|
let compiledConfig = null;
|
|
try {
|
|
compiledConfig = extractCompiledPublicBuildConfig(fs.readFileSync(commonPath, 'utf8'));
|
|
push('pass', 'weapp.public_config.found', 'Compiled public build config was found in common.js');
|
|
} catch (error) {
|
|
push('fail', 'weapp.public_config.found', error instanceof Error ? error.message : String(error));
|
|
}
|
|
if (compiledConfig) {
|
|
if (compiledConfig.portal === 'student' && compiledConfig.target === 'weapp') {
|
|
push('pass', 'weapp.public_config.identity', 'Compiled public build config targets the student WeApp');
|
|
} else {
|
|
push('fail', 'weapp.public_config.identity', 'Compiled public build config must target portal=student and target=weapp', { compiledConfig });
|
|
}
|
|
|
|
if (requireProduction && compiledConfig.releaseMode !== 'production') {
|
|
push('fail', 'weapp.public_config.release_mode', 'Production guard requires releaseMode=production', { releaseMode: compiledConfig.releaseMode });
|
|
} else {
|
|
push(compiledConfig.releaseMode === 'production' ? 'pass' : 'warn', 'weapp.public_config.release_mode', `Compiled release mode is ${compiledConfig.releaseMode || 'missing'}`);
|
|
}
|
|
|
|
const tenantMode = String(compiledConfig.weappTenantMode || '');
|
|
if (tenantMode !== 'fixed' && tenantMode !== 'launch') {
|
|
push('fail', 'weapp.public_config.tenant_mode', 'Compiled weappTenantMode must be fixed or launch');
|
|
} else {
|
|
push('pass', 'weapp.public_config.tenant_mode', `Compiled WeApp tenant mode is ${tenantMode}`);
|
|
const tenantCode = String(compiledConfig.tenantCode || '');
|
|
if (tenantMode === 'launch') {
|
|
if (tenantCode) push('fail', 'weapp.public_config.tenant_code', 'Launch mode must not retain a compiled tenant fallback', { tenantCode });
|
|
else push('pass', 'weapp.public_config.tenant_code', 'Launch mode contains no compiled tenant fallback');
|
|
} else {
|
|
const tenantError = validationError(requireProduction ? validateProductionTenantCode : validateTenantCodeFormat, tenantCode);
|
|
if (tenantError) push('fail', 'weapp.public_config.tenant_code', tenantError);
|
|
else push('pass', 'weapp.public_config.tenant_code', 'Fixed mode contains a valid compiled tenant code');
|
|
}
|
|
}
|
|
|
|
const apiBaseUrl = String(compiledConfig.apiBaseUrl || '');
|
|
const apiError = validationError(validateProductionApiBaseUrl, apiBaseUrl);
|
|
if (requireProduction && apiError) push('fail', 'weapp.public_config.api', apiError);
|
|
else push(apiError ? 'warn' : 'pass', 'weapp.public_config.api', apiError || 'Compiled API endpoint is production-ready');
|
|
}
|
|
|
|
const mainPages = Array.isArray(appConfig.pages) ? appConfig.pages : [];
|
|
const subpackages = appConfig.subPackages || appConfig.subpackages || [];
|
|
if (mainPages.length === 1 && mainPages[0] === 'pages/bootstrap/index') push('pass', 'weapp.main_pages', 'WeApp main package contains only bootstrap');
|
|
else push('fail', 'weapp.main_pages', 'WeApp main package must contain only bootstrap', { mainPages });
|
|
if (subpackages.length === 1 && subpackages[0]?.root === 'pages/student') push('pass', 'weapp.subpackage', 'Student pages use one subpackage');
|
|
else push('fail', 'weapp.subpackage', 'Student WeApp subpackage contract is invalid');
|
|
|
|
const totalBytes = directoryBytes(distRoot);
|
|
const subpackageBytes = directoryBytes(path.join(distRoot, 'pages', 'student'));
|
|
const mainBytes = totalBytes - subpackageBytes;
|
|
if (totalBytes > 4 * 1024 * 1024) push('fail', 'weapp.size.total', 'WeApp output exceeds the 4 MiB release budget', { totalBytes });
|
|
else push('pass', 'weapp.size.total', 'WeApp output is within the 4 MiB release budget', { totalBytes });
|
|
if (mainBytes > 2 * 1024 * 1024) push('fail', 'weapp.size.main', 'WeApp main package exceeds the 2 MiB release budget', { mainBytes });
|
|
else push('pass', 'weapp.size.main', 'WeApp main package is within the 2 MiB release budget', { mainBytes });
|
|
|
|
const forbidden = [
|
|
['database-url', /postgres(?:ql)?:\/\//i],
|
|
['service-role', /\b(?:service_role|sb_secret_)\b/i],
|
|
['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/i],
|
|
];
|
|
const violations = [];
|
|
const localEndpointFiles = [];
|
|
const allowedDependencyLocalEndpoints = [];
|
|
for (const filePath of walkFiles(distRoot).filter(item => /\.(?:js|json|wxml|wxss|txt)$/.test(item))) {
|
|
const source = fs.readFileSync(filePath, 'utf8');
|
|
const relativeFile = path.relative(repoRoot, filePath);
|
|
const localEndpoints = source.match(/https?:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?/gi) || [];
|
|
for (const endpoint of localEndpoints) {
|
|
if (relativeFile === 'apps/taro/dist/weapp-student/vendors.js' && endpoint.toLowerCase() === 'http://localhost:9999') {
|
|
allowedDependencyLocalEndpoints.push({ file: relativeFile, endpoint, dependency: '@supabase/auth-js' });
|
|
} else {
|
|
localEndpointFiles.push(relativeFile);
|
|
}
|
|
}
|
|
for (const [id, pattern] of forbidden) {
|
|
if (pattern.test(source)) violations.push({ id, file: relativeFile });
|
|
}
|
|
}
|
|
if (allowedDependencyLocalEndpoints.length) {
|
|
push('pass', 'weapp.dependency_local_placeholder', 'Known inert Supabase SDK local placeholder is explicitly allowlisted', { matches: allowedDependencyLocalEndpoints });
|
|
}
|
|
if (localEndpointFiles.length) push(requireProduction ? 'fail' : 'warn', 'weapp.local_endpoints', 'WeApp output contains local API endpoints', { files: [...new Set(localEndpointFiles)].slice(0, 20) });
|
|
else push('pass', 'weapp.local_endpoints', 'WeApp output contains no local API endpoints');
|
|
if (violations.length) push('fail', 'weapp.forbidden_patterns', 'WeApp output contains secret-looking values', { violations: violations.slice(0, 20) });
|
|
else push('pass', 'weapp.forbidden_patterns', 'WeApp output contains no secret-looking values');
|
|
return checks;
|
|
}
|
|
|
|
export function runGuardrails({ argv = process.argv.slice(2), distRoot = defaultDistRoot } = {}) {
|
|
const requireProduction = argv.includes('--production');
|
|
const json = argv.includes('--json');
|
|
const checks = inspectWeappRelease({ distRoot, requireProduction });
|
|
const summary = checks.reduce((result, item) => ({ ...result, [item.status]: result[item.status] + 1 }), { fail: 0, warn: 0, pass: 0 });
|
|
const payload = { summary, checks };
|
|
if (json) console.log(JSON.stringify(payload, null, 2));
|
|
else {
|
|
console.log(`Taro WeApp release guardrails: ${summary.fail} fail(s), ${summary.warn} warning(s), ${summary.pass} pass(es)`);
|
|
checks.filter(item => item.status !== 'pass').forEach(item => console.log(`[${item.status.toUpperCase()}] ${item.id}: ${item.message}`));
|
|
}
|
|
return summary.fail > 0 ? 1 : 0;
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
|
|
process.exitCode = runGuardrails();
|
|
}
|