358 lines
14 KiB
JavaScript
358 lines
14 KiB
JavaScript
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
import { relative, resolve } from 'node:path';
|
|
import {
|
|
validateAndroidPrivacyDocument,
|
|
validateLegalUrlPair
|
|
} from './app-legal-config.mjs';
|
|
|
|
const projectRoot = resolve(import.meta.dirname, '..');
|
|
const manifestPath = resolve(projectRoot, 'src/manifest.json');
|
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
const requirePrivacy = process.argv.includes('--require-privacy');
|
|
const checkLegalUrls = process.argv.includes('--check-legal-urls');
|
|
const failures = [];
|
|
let releaseLegalUrls;
|
|
|
|
const fail = (message) => failures.push(message);
|
|
const compiledAppRoot = resolve(projectRoot, 'dist/build/app');
|
|
const sourceAndroidManifestPath = resolve(projectRoot, 'AndroidManifest.xml');
|
|
const compiledAndroidManifestPath = resolve(compiledAppRoot, 'AndroidManifest.xml');
|
|
const sourceNetworkSecurityPath = resolve(
|
|
projectRoot,
|
|
'nativeResources/android/res/xml/network_security_config.xml'
|
|
);
|
|
const compiledNetworkSecurityPath = resolve(
|
|
compiledAppRoot,
|
|
'nativeResources/android/res/xml/network_security_config.xml'
|
|
);
|
|
const releaseTextExtensions = new Set(['.css', '.html', '.js', '.json', '.txt', '.xml']);
|
|
const releaseSensitiveFilePattern = /(^|[\\/])(?:\.env(?:\.|$)|id_rsa$)|\.(?:jks|keystore|p12|pfx|pem)$/i;
|
|
const releaseSensitiveContentPatterns = [
|
|
['private key', /BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY/],
|
|
['cloud access key', /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/],
|
|
['GitHub token', /\b(?:ghp_|github_pat_)[A-Za-z0-9_]{20,}\b/],
|
|
['model API key', /\bsk-[A-Za-z0-9_-]{20,}\b/],
|
|
['mobile number', /(?<!\d)1[3-9]\d{9}(?!\d)/],
|
|
['fixed test SMS code', /测试验证码.{0,24}\b\d{4,8}\b/]
|
|
];
|
|
|
|
const compiledTextFiles = (root) => {
|
|
if (!existsSync(root)) return [];
|
|
const result = [];
|
|
const visit = (directory) => {
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
const absolutePath = resolve(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
visit(absolutePath);
|
|
} else if (releaseTextExtensions.has(entry.name.slice(entry.name.lastIndexOf('.')).toLowerCase())) {
|
|
result.push(absolutePath);
|
|
}
|
|
}
|
|
};
|
|
visit(root);
|
|
return result;
|
|
};
|
|
|
|
const scanCompiledAppForSensitiveValues = () => {
|
|
if (!existsSync(compiledAppRoot)) {
|
|
fail('compiled App resource directory is missing; run build:app before release verification');
|
|
return;
|
|
}
|
|
const allFiles = [];
|
|
const visit = (directory) => {
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
const absolutePath = resolve(directory, entry.name);
|
|
if (entry.isDirectory()) visit(absolutePath);
|
|
else allFiles.push(absolutePath);
|
|
}
|
|
};
|
|
visit(compiledAppRoot);
|
|
for (const file of allFiles) {
|
|
const relativePath = relative(compiledAppRoot, file);
|
|
if (releaseSensitiveFilePattern.test(relativePath)) {
|
|
fail(`compiled App contains a sensitive file: ${relativePath}`);
|
|
}
|
|
}
|
|
for (const file of compiledTextFiles(compiledAppRoot)) {
|
|
const content = readFileSync(file, 'utf8');
|
|
for (const [label, pattern] of releaseSensitiveContentPatterns) {
|
|
if (pattern.test(content)) {
|
|
fail(`compiled App contains a possible ${label}: ${relative(compiledAppRoot, file)}`);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
const readPng = (relativePath) => {
|
|
if (typeof relativePath !== 'string' || !relativePath) {
|
|
fail('manifest is missing an App asset path');
|
|
return undefined;
|
|
}
|
|
const path = [
|
|
resolve(projectRoot, 'src', relativePath),
|
|
resolve(projectRoot, relativePath)
|
|
].find(existsSync);
|
|
if (!path) {
|
|
fail(`missing asset: ${relativePath}`);
|
|
return undefined;
|
|
}
|
|
const header = readFileSync(path).subarray(0, 29);
|
|
const isPng = header.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
|
if (!isPng || header.toString('ascii', 12, 16) !== 'IHDR') {
|
|
fail(`invalid PNG: ${relativePath}`);
|
|
return undefined;
|
|
}
|
|
return {
|
|
width: header.readUInt32BE(16),
|
|
height: header.readUInt32BE(20),
|
|
colorType: header[25]
|
|
};
|
|
};
|
|
|
|
const expectPng = (relativePath, width, height, opaque = false) => {
|
|
const png = readPng(relativePath);
|
|
if (!png) return;
|
|
if (png.width !== width || png.height !== height) {
|
|
fail(`unexpected dimensions for ${relativePath}: got ${png.width}x${png.height}, expected ${width}x${height}`);
|
|
}
|
|
if (opaque && (png.colorType === 4 || png.colorType === 6)) {
|
|
fail(`iOS App icon must not contain alpha: ${relativePath}`);
|
|
}
|
|
};
|
|
|
|
const app = manifest['app-plus'];
|
|
const distribute = app?.distribute;
|
|
const icons = distribute?.icons;
|
|
const splashscreen = distribute?.splashscreen;
|
|
const android = distribute?.android;
|
|
const modules = app?.modules;
|
|
|
|
if (!Array.isArray(app?.screenOrientation) || !app.screenOrientation.includes('portrait-primary')) {
|
|
fail('app-plus.screenOrientation must include portrait-primary');
|
|
}
|
|
if (splashscreen?.useOriginalMsgbox !== true) {
|
|
fail('app-plus.distribute.splashscreen.useOriginalMsgbox must enable the native privacy prompt');
|
|
}
|
|
if (app?.ssl?.untrustedca !== 'refuse') {
|
|
fail('app-plus.ssl.untrustedca must remain refuse');
|
|
}
|
|
if (!/^\d+\.\d+\.\d+$/.test(String(manifest.versionName || ''))) {
|
|
fail('versionName must use semantic numeric form such as 0.1.8');
|
|
}
|
|
if (!/^[1-9]\d*$/.test(String(manifest.versionCode || ''))) {
|
|
fail('versionCode must be a positive integer');
|
|
}
|
|
if (android?.packagename !== 'com.yincheng.wygj') {
|
|
fail('Android package name must remain com.yincheng.wygj');
|
|
}
|
|
if (android?.targetSdkVersion !== 35) {
|
|
fail('Android targetSdkVersion must be 35 for the August 1 test package');
|
|
}
|
|
if (android?.usesCleartextTraffic !== false) {
|
|
fail('Android manifest configuration must explicitly disable cleartext traffic');
|
|
}
|
|
const expectedAndroidAbis = ['arm64-v8a', 'armeabi-v7a'];
|
|
const actualAndroidAbis = Array.isArray(android?.abiFilters)
|
|
? [...android.abiFilters].sort()
|
|
: [];
|
|
if (JSON.stringify(actualAndroidAbis) !== JSON.stringify(expectedAndroidAbis)) {
|
|
fail('Android abiFilters must include exactly armeabi-v7a and arm64-v8a');
|
|
}
|
|
if (android?.permissionExternalStorage?.request !== 'none') {
|
|
fail('Android external storage permission must not be requested at startup');
|
|
}
|
|
if (android?.permissionPhoneState?.request !== 'none') {
|
|
fail('Android phone state permission must not be requested at startup');
|
|
}
|
|
if (!Object.hasOwn(modules || {}, 'Camera') || !Object.hasOwn(modules || {}, 'Record')) {
|
|
fail('App modules must include Camera and Record for user-triggered media features');
|
|
}
|
|
const declaredAndroidPermissions = Array.isArray(android?.permissions)
|
|
? android.permissions.join('\n')
|
|
: '';
|
|
for (const permission of ['android.permission.RECORD_AUDIO', 'android.permission.MODIFY_AUDIO_SETTINGS']) {
|
|
if (!declaredAndroidPermissions.includes(permission)) {
|
|
fail(`Android permissions must include ${permission}`);
|
|
}
|
|
}
|
|
|
|
const verifyAndroidNetworkSecurity = (manifestPath, networkSecurityPath, label) => {
|
|
if (!existsSync(manifestPath)) {
|
|
fail(`${label} is missing AndroidManifest.xml`);
|
|
return;
|
|
}
|
|
if (!existsSync(networkSecurityPath)) {
|
|
fail(`${label} is missing network_security_config.xml`);
|
|
return;
|
|
}
|
|
const androidManifest = readFileSync(manifestPath, 'utf8');
|
|
const networkSecurity = readFileSync(networkSecurityPath, 'utf8');
|
|
if (!/android:usesCleartextTraffic\s*=\s*["']false["']/.test(androidManifest)) {
|
|
fail(`${label} AndroidManifest.xml must disable cleartext traffic`);
|
|
}
|
|
if (!/android:networkSecurityConfig\s*=\s*["']@xml\/network_security_config["']/.test(androidManifest)) {
|
|
fail(`${label} AndroidManifest.xml must reference network_security_config`);
|
|
}
|
|
if (!/cleartextTrafficPermitted\s*=\s*["']false["']/.test(networkSecurity)) {
|
|
fail(`${label} network security config must reject cleartext traffic`);
|
|
}
|
|
if (/cleartextTrafficPermitted\s*=\s*["']true["']/.test(networkSecurity)) {
|
|
fail(`${label} network security config must not contain a cleartext exception`);
|
|
}
|
|
if (!/<certificates\s+src\s*=\s*["']system["']\s*\/>/.test(networkSecurity)) {
|
|
fail(`${label} network security config must trust system certificates only`);
|
|
}
|
|
if (/<certificates\s+src\s*=\s*["'](?!system["'])[^"']+["']/.test(networkSecurity)) {
|
|
fail(`${label} network security config must not trust user or bundled certificates`);
|
|
}
|
|
if (/<debug-overrides\b/.test(networkSecurity)) {
|
|
fail(`${label} network security config must not contain debug trust overrides`);
|
|
}
|
|
};
|
|
|
|
verifyAndroidNetworkSecurity(
|
|
sourceAndroidManifestPath,
|
|
sourceNetworkSecurityPath,
|
|
'source App'
|
|
);
|
|
|
|
const androidIcons = [
|
|
['hdpi', 72], ['xhdpi', 96], ['xxhdpi', 144], ['xxxhdpi', 192]
|
|
];
|
|
for (const [density, size] of androidIcons) {
|
|
expectPng(icons?.android?.[density], size, size);
|
|
}
|
|
|
|
const iosIconPaths = [
|
|
[icons?.ios?.appstore, 1024],
|
|
[icons?.ios?.iphone?.['app@2x'], 120], [icons?.ios?.iphone?.['app@3x'], 180],
|
|
[icons?.ios?.iphone?.['spotlight@2x'], 80], [icons?.ios?.iphone?.['spotlight@3x'], 120],
|
|
[icons?.ios?.iphone?.['settings@2x'], 58], [icons?.ios?.iphone?.['settings@3x'], 87],
|
|
[icons?.ios?.iphone?.['notification@2x'], 40], [icons?.ios?.iphone?.['notification@3x'], 60],
|
|
[icons?.ios?.ipad?.app, 76], [icons?.ios?.ipad?.['app@2x'], 152], [icons?.ios?.ipad?.['proapp@2x'], 167],
|
|
[icons?.ios?.ipad?.spotlight, 40], [icons?.ios?.ipad?.['spotlight@2x'], 80],
|
|
[icons?.ios?.ipad?.settings, 29], [icons?.ios?.ipad?.['settings@2x'], 58],
|
|
[icons?.ios?.ipad?.notification, 20], [icons?.ios?.ipad?.['notification@2x'], 40]
|
|
];
|
|
for (const [relativePath, size] of iosIconPaths) expectPng(relativePath, size, size, true);
|
|
|
|
const splashPaths = [
|
|
[splashscreen?.android?.hdpi, 480, 762], [splashscreen?.android?.xhdpi, 720, 1242], [splashscreen?.android?.xxhdpi, 1080, 1882],
|
|
[splashscreen?.ios?.iphone?.retina40, 640, 1136], [splashscreen?.ios?.iphone?.retina47, 750, 1334],
|
|
[splashscreen?.ios?.iphone?.retina55, 1242, 2208], [splashscreen?.ios?.iphone?.iphonex, 1125, 2436],
|
|
[splashscreen?.ios?.iphone?.['portrait-896h@2x'], 828, 1792], [splashscreen?.ios?.iphone?.['portrait-896h@3x'], 1242, 2688],
|
|
[splashscreen?.ios?.ipad?.portrait7, 768, 1024], [splashscreen?.ios?.ipad?.['portrait-retina7'], 1536, 2048],
|
|
[splashscreen?.ios?.ipad?.['portrait-1112h@2x'], 1668, 2224], [splashscreen?.ios?.ipad?.['portrait-1194h@2x'], 1668, 2388],
|
|
[splashscreen?.ios?.ipad?.['portrait-1366h@2x'], 2048, 2732]
|
|
];
|
|
for (const [relativePath, width, height] of splashPaths) expectPng(relativePath, width, height);
|
|
|
|
const privacyPath = resolve(projectRoot, 'src/androidPrivacy.json');
|
|
const compiledPrivacyPath = resolve(projectRoot, 'dist/build/app/androidPrivacy.json');
|
|
if (requirePrivacy) {
|
|
if (String(process.env.VITE_DEV_SMS_HINT_ENABLED || '').toLowerCase() === 'true') {
|
|
fail('VITE_DEV_SMS_HINT_ENABLED must be disabled for release builds');
|
|
}
|
|
if (String(process.env.VITE_DEV_SMS_HINT_VALUE || '').trim()) {
|
|
fail('VITE_DEV_SMS_HINT_VALUE must not be present for release builds');
|
|
}
|
|
try {
|
|
releaseLegalUrls = validateLegalUrlPair(
|
|
process.env.VITE_APP_TERMS_URL,
|
|
process.env.VITE_APP_PRIVACY_URL
|
|
);
|
|
} catch (error) {
|
|
fail(`release build legal URL environment is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
|
|
if (!existsSync(privacyPath)) {
|
|
fail('src/androidPrivacy.json is required for release; run configure:app-privacy with final legal URLs');
|
|
} else {
|
|
try {
|
|
const privacy = JSON.parse(readFileSync(privacyPath, 'utf8'));
|
|
const privacyLegalUrls = validateAndroidPrivacyDocument(privacy);
|
|
if (
|
|
releaseLegalUrls
|
|
&& (
|
|
privacyLegalUrls.termsUrl !== releaseLegalUrls.termsUrl
|
|
|| privacyLegalUrls.privacyUrl !== releaseLegalUrls.privacyUrl
|
|
)
|
|
) {
|
|
fail('androidPrivacy.json legal links must match VITE_APP_TERMS_URL and VITE_APP_PRIVACY_URL');
|
|
}
|
|
releaseLegalUrls = privacyLegalUrls;
|
|
} catch (error) {
|
|
fail(`androidPrivacy.json is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
if (!existsSync(compiledPrivacyPath)) {
|
|
fail('compiled App resource is missing androidPrivacy.json; run build:app after configure:app-privacy');
|
|
} else {
|
|
try {
|
|
const compiledPrivacy = JSON.parse(readFileSync(compiledPrivacyPath, 'utf8'));
|
|
const compiledLegalUrls = validateAndroidPrivacyDocument(compiledPrivacy);
|
|
if (
|
|
releaseLegalUrls
|
|
&& (
|
|
compiledLegalUrls.termsUrl !== releaseLegalUrls.termsUrl
|
|
|| compiledLegalUrls.privacyUrl !== releaseLegalUrls.privacyUrl
|
|
)
|
|
) {
|
|
fail('compiled App androidPrivacy.json must match the source privacy configuration');
|
|
}
|
|
} catch (error) {
|
|
fail(`compiled App androidPrivacy.json is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
verifyAndroidNetworkSecurity(
|
|
compiledAndroidManifestPath,
|
|
compiledNetworkSecurityPath,
|
|
'compiled App'
|
|
);
|
|
scanCompiledAppForSensitiveValues();
|
|
}
|
|
|
|
const checkRemoteLegalPage = async (url, label, expectedText) => {
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
redirect: 'follow',
|
|
signal: AbortSignal.timeout(10000),
|
|
headers: { 'user-agent': 'Bangdao-App-Release-Preflight/1.0' }
|
|
});
|
|
if (!response.ok) {
|
|
fail(`${label} returned HTTP ${response.status}`);
|
|
return;
|
|
}
|
|
if (!String(response.url).startsWith('https://')) {
|
|
fail(`${label} redirected away from HTTPS`);
|
|
return;
|
|
}
|
|
const contentType = String(response.headers.get('content-type') || '').toLowerCase();
|
|
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) {
|
|
fail(`${label} must return an HTML document`);
|
|
return;
|
|
}
|
|
const body = await response.text();
|
|
if (body.trim().length < 100 || !expectedText.test(body)) {
|
|
fail(`${label} does not contain recognizable final legal content`);
|
|
}
|
|
} catch (error) {
|
|
fail(`${label} is not publicly reachable: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
};
|
|
|
|
if (requirePrivacy && checkLegalUrls && releaseLegalUrls) {
|
|
await Promise.all([
|
|
checkRemoteLegalPage(releaseLegalUrls.termsUrl, 'service agreement URL', /服务协议|用户协议/),
|
|
checkRemoteLegalPage(releaseLegalUrls.privacyUrl, 'privacy policy URL', /隐私政策|个人信息保护/)
|
|
]);
|
|
}
|
|
|
|
if (failures.length) {
|
|
console.error(`App asset verification failed:\n- ${failures.join('\n- ')}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`App asset verification passed (${androidIcons.length + iosIconPaths.length} icons, ${splashPaths.length} splash images).`);
|