#!/usr/bin/env node import { readFileSync } from 'node:fs'; const APP_PAGE_ROUTES_PATH = 'src/routing/activeAppPageRoutes.ts'; const APP_ROUTES_PATH = 'src/routing/activeAppRoutes.tsx'; const COMPATIBILITY_ROUTES = []; const NGINX_PATHS = [ 'deploy/nginx/genarrative.conf', 'deploy/nginx/genarrative-dev-http.conf', 'deploy/container/nginx.conf', ]; const MAINTENANCE_NGINX_PATHS = [ 'deploy/nginx/genarrative.conf', 'deploy/nginx/genarrative-dev-http.conf', ]; const MAINTENANCE_SNIPPET_PATH = 'deploy/nginx/snippets/genarrative-maintenance.conf'; const SPA_BLOCK_START = '# BEGIN GENARRATIVE MAIN SPA ROUTES'; const SPA_BLOCK_END = '# END GENARRATIVE MAIN SPA ROUTES'; const UNKNOWN_ROUTE_SAMPLES = [ '/unknown-root', '/creation/not-exist', '/runtime/not-exist', '/puzzle/not-exist', ]; const failures = []; function fail(message) { failures.push(message); } function extractSourceBlock(source, pattern, label) { const match = source.match(pattern); if (!match) { fail(`${label} 未找到。`); return ''; } return match[1]; } function collectExpectedMainSpaRoutes() { const appPageRoutes = readFileSync(APP_PAGE_ROUTES_PATH, 'utf8'); const appRoutes = readFileSync(APP_ROUTES_PATH, 'utf8'); const stageEntries = extractSourceBlock( appPageRoutes, /const STAGE_ROUTE_ENTRIES = \[([\s\S]*?)\] as const/u, `${APP_PAGE_ROUTES_PATH} STAGE_ROUTE_ENTRIES`, ); const routes = [ ...Array.from( stageEntries.matchAll(/\[\s*'[^']+'\s*,\s*'([^']+)'\s*\]/gu), (match) => match[1], ), ...Array.from( appRoutes.matchAll(/normalizedPath === '([^']+)'/gu), (match) => match[1], ), ...COMPATIBILITY_ROUTES, ]; const uniqueRoutes = [...new Set(routes)].sort(); if (uniqueRoutes.length === 0) { fail('未从前端路由源提取到主站 SPA 路由。'); } for (const route of uniqueRoutes) { if (!/^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/u.test(route)) { fail(`前端路由源包含门禁暂不支持的路径格式: ${route}`); } } return uniqueRoutes; } function compareRouteSets(actualRoutes, expectedRoutes, label) { const actual = new Set(actualRoutes); const expected = new Set(expectedRoutes); const missing = expectedRoutes.filter((route) => !actual.has(route)); const extra = actualRoutes.filter((route) => !expected.has(route)); if (missing.length > 0) { fail(`${label} 缺少 SPA 路由: ${missing.join(', ')}`); } if (extra.length > 0) { fail(`${label} 包含非当前路由: ${extra.join(', ')}`); } } function validateNginxRoutes(nginxPath, expectedRoutes) { const source = readFileSync(nginxPath, 'utf8'); const blockStart = source.indexOf(SPA_BLOCK_START); const blockEnd = source.indexOf(SPA_BLOCK_END); if (blockStart < 0 || blockEnd <= blockStart) { fail(`${nginxPath} 缺少完整 SPA allowlist 标记。`); return; } const block = source.slice(blockStart, blockEnd + SPA_BLOCK_END.length); if (!/location\s+=\s+\/\s*\{/u.test(block)) { fail(`${nginxPath} SPA allowlist 缺少根路径精确 location。`); } if (!block.includes('try_files /index.html =404;')) { fail(`${nginxPath} 根路径没有精确回退 index.html。`); } if (!block.includes('try_files $uri /index.html =404;')) { fail(`${nginxPath} SPA allowlist 没有精确回退 index.html。`); } const regexMatch = block.match(/location\s+~\*\s+"([^"]+)"\s*\{/u); if (!regexMatch) { fail(`${nginxPath} 缺少大小写不敏感的 SPA allowlist regex location。`); return; } const nginxPattern = regexMatch[1]; const alternativesMatch = nginxPattern.match(/^\^\/\(\?:(.+)\)\/\?\$$/u); if (!alternativesMatch) { fail(`${nginxPath} SPA allowlist 必须锚定完整路径并允许一个尾部斜杠。`); return; } const configuredRoutes = [ '/', ...alternativesMatch[1].split('|').map((route) => `/${route}`), ].sort(); compareRouteSets(configuredRoutes, expectedRoutes, nginxPath); const matcher = new RegExp(nginxPattern, 'iu'); for (const route of expectedRoutes.filter((candidate) => candidate !== '/')) { if (!matcher.test(route)) { fail(`${nginxPath} SPA allowlist 未匹配完整路径: ${route}`); } if (!matcher.test(`${route.toUpperCase()}/`)) { fail(`${nginxPath} SPA allowlist 未允许大小写差异和尾部斜杠: ${route}`); } } for (const route of UNKNOWN_ROUTE_SAMPLES) { if (matcher.test(route) || matcher.test(`${route}/`)) { fail(`${nginxPath} SPA allowlist 错误接收未知路径: ${route}`); } } const defaultLocation = source.slice(blockEnd + SPA_BLOCK_END.length); if (!defaultLocation.includes('try_files $uri $uri/ =404;')) { fail( `${nginxPath} 未命中 SPA allowlist 的路径必须只读真实静态文件并返回 404。`, ); } if (defaultLocation.includes('try_files $uri $uri/ /index.html;')) { fail(`${nginxPath} 默认 location 仍存在全路径 SPA fallback。`); } } function validateMaintenanceInternalBypass() { const snippet = readFileSync(MAINTENANCE_SNIPPET_PATH, 'utf8'); const internalBypassPattern = /set \$genarrative_maintenance 0;\s*if \(-f \/var\/lib\/genarrative\/maintenance\/enabled\) \{\s*set \$genarrative_maintenance 1;\s*\}\s*if \(\$genarrative_internal_client\) \{\s*set \$genarrative_maintenance 0;\s*\}/u; if (!internalBypassPattern.test(snippet)) { fail( `${MAINTENANCE_SNIPPET_PATH} 必须在读取维护 marker 后为真实内网来源清除全站维护状态。`, ); } if (snippet.includes('$genarrative_admin_maintenance')) { fail(`${MAINTENANCE_SNIPPET_PATH} 不应保留仅后台使用的维护变量。`); } for (const fragment of [ 'location = /404.html {', 'if ($http_accept !~* "text/html") {', 'try_files /404.html =404;', 'add_header Cache-Control "no-store";', 'internal;', ]) { if (!snippet.includes(fragment)) { fail(`${MAINTENANCE_SNIPPET_PATH} 缺少品牌 404 页面约束: ${fragment}`); } } for (const nginxPath of MAINTENANCE_NGINX_PATHS) { const source = readFileSync(nginxPath, 'utf8'); for (const fragment of [ 'geo $remote_addr $genarrative_internal_client {', '127.0.0.0/8 1;', '10.0.0.0/8 1;', '172.16.0.0/12 1;', '192.168.0.0/16 1;', '169.254.0.0/16 1;', '::1 1;', 'fc00::/7 1;', 'fe80::/10 1;', ]) { if (!source.includes(fragment)) { fail(`${nginxPath} 缺少内网来源识别片段: ${fragment}`); } } if (source.includes('$genarrative_admin_maintenance')) { fail(`${nginxPath} 的维护入口必须统一使用全站维护变量。`); } if (!source.includes('error_page 404 /404.html;')) { fail(`${nginxPath} 的 Web 未知路由必须返回品牌 404 页面。`); } const maintenanceChecks = source.match(/if \(\$genarrative_[a-z_]*maintenance\)/gu) ?? []; if (maintenanceChecks.length === 0) { fail(`${nginxPath} 缺少维护状态判断。`); } for (const maintenanceCheck of maintenanceChecks) { if (maintenanceCheck !== 'if ($genarrative_maintenance)') { fail( `${nginxPath} 存在未统一到全站维护变量的判断: ${maintenanceCheck}`, ); } } } } export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes(); for (const nginxPath of NGINX_PATHS) { validateNginxRoutes(nginxPath, expectedMainSpaRoutes); } validateMaintenanceInternalBypass(); if (failures.length > 0) { console.error('[check:nginx-spa-routes] FAILED'); for (const failure of failures) { console.error(`- ${failure}`); } process.exit(1); } console.log( `[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${NGINX_PATHS.length} Nginx templates)`, );