import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { relative, resolve } from 'node:path'; import { loadEnv } from 'vite'; 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 requireLegalConsent = process.argv.includes('--require-legal-consent'); const checkLegalUrls = process.argv.includes('--check-legal-urls'); const failures = []; let releaseLegalUrls; const publicBuildEnv = loadEnv(process.env.NODE_ENV || 'production', projectRoot, 'VITE_'); const fail = (message) => failures.push(message); const compiledAppRoot = resolve(projectRoot, 'dist/build/app'); const compiledManifestPath = resolve(compiledAppRoot, 'manifest.json'); const compiledAppServicePath = resolve(compiledAppRoot, 'app-service.js'); const loginPagePath = resolve(projectRoot, 'src/pages/auth/login/index.vue'); 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', /(? { 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 !== false) { fail('app-plus.distribute.splashscreen.useOriginalMsgbox must disable 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 (!//.test(networkSecurity)) { fail(`${label} network security config must trust system certificates only`); } if (//], ['terms link', /openLegalDocument\('terms'\)/], ['privacy link', /openLegalDocument\('privacy'\)/], ['SMS legal gate', /const sendCode = async \(\) => \{[\s\S]*?if \(!ensureLegalConsent\(\)\) return;/], ['login legal gate', /const login = async \(\) => \{[\s\S]*?if \(!ensureLegalConsent\(\)\) return;/] ]) { if (!pattern.test(loginSource)) { fail(`login page is missing ${label}`); } } } if (!existsSync(compiledAppServicePath)) { fail('compiled App resource is missing app-service.js; run build:app'); } else if (releaseLegalUrls) { const appService = readFileSync(compiledAppServicePath, 'utf8'); if (!appService.includes(releaseLegalUrls.termsUrl)) { fail('compiled App login flow is missing VITE_APP_TERMS_URL'); } if (!appService.includes(releaseLegalUrls.privacyUrl)) { fail('compiled App login flow is missing VITE_APP_PRIVACY_URL'); } if (!appService.includes('请先阅读并勾选同意')) { fail('compiled App login flow is missing the unchecked-consent failure message'); } } 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 (requireLegalConsent && 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).`);