#!/usr/bin/env node import { readFileSync } from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; const APP_PAGE_ROUTES_PATH = 'src/routing/activeAppPageRoutes.ts'; const APP_ROUTES_PATH = 'src/routing/activeAppRoutes.tsx'; const APP_PREFIX_ROUTE_ENTRIES_PATTERN = /const APP_PREFIX_ROUTE_ENTRIES = \[([\s\S]*?)\] as const/u; 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(', ')}`); } } /** * 前缀路由在 Nginx 里的锚定形状:前缀 + 恰好一个路径段 + 一个可省略的尾部斜杠。 * 裸前缀自身由 SPA allowlist 的精确 location 负责,这里不重复放行,也不放宽到多段路径。 */ export function buildPrefixRouteNginxPattern(prefix) { const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); return `^${escapedPrefix}/[^/]+/?$`; } /** 校验前缀路由的放行形状;返回失败原因列表(空数组代表通过)。 */ export function collectPrefixRoutePatternFailures(pattern, prefix) { const failures = []; const expected = buildPrefixRouteNginxPattern(prefix); if (pattern !== expected) { failures.push( `前缀路由 ${prefix} 的 Nginx 放行形状必须锚定为 ${expected}(前缀 + 恰好一个路径段 + 可省略的尾部斜杠),实际为 ${pattern}。`, ); return failures; } const matcher = new RegExp(pattern, 'iu'); for (const sample of [ `${prefix}/checkout-token`, `${prefix.toUpperCase()}/CheckOutToken/`, ]) { if (!matcher.test(sample)) { failures.push(`前缀路由形状 ${pattern} 未匹配深链: ${sample}`); } } for (const sample of [ prefix, `${prefix}/`, `${prefix}/two/segments`, `${prefix}suffix/segment`, ]) { if (matcher.test(sample)) { failures.push(`前缀路由形状 ${pattern} 错误接收非深链路径: ${sample}`); } } return failures; } export function collectExpectedPrefixRoutes() { const appPageRoutes = readFileSync(APP_PAGE_ROUTES_PATH, 'utf8'); const entries = extractSourceBlock( appPageRoutes, APP_PREFIX_ROUTE_ENTRIES_PATTERN, `${APP_PAGE_ROUTES_PATH} APP_PREFIX_ROUTE_ENTRIES`, ); const routes = Array.from( entries.matchAll(/\[\s*'([^']+)'\s*,\s*'([^']+)'\s*\]/gu), (match) => ({ path: match[1], stage: match[2] }), ); const uniqueRoutes = new Map(routes.map((route) => [route.path, route])); for (const route of uniqueRoutes.values()) { if (!/^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/u.test(route.path)) { fail(`前端前缀路由源包含门禁暂不支持的路径格式: ${route.path}`); } } return [...uniqueRoutes.values()].sort((left, right) => left.path.localeCompare(right.path), ); } function findRegexLocationBody(block, pattern) { for (const match of block.matchAll(/location\s+~\*\s+"([^"]+)"\s*\{/gu)) { if (match[1] !== pattern) { continue; } const openBrace = match.index + match[0].length - 1; let depth = 0; for (let index = openBrace; index < block.length; index += 1) { if (block[index] === '{') { depth += 1; } else if (block[index] === '}') { depth -= 1; if (depth === 0) { return block.slice(openBrace + 1, index); } } } return null; } return null; } function validateNginxRoutes(nginxPath, expectedRoutes, prefixRoutes) { 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}`); } } // 前缀路由(带动态段)必须有独立的锚定 location:只放行裸前缀会让真实深链落到默认 // location 变成 404(例如收银台 `/pay/`)。 const exactLocationBody = findRegexLocationBody(block, nginxPattern); const maintenanceGuard = 'if ($genarrative_maintenance) { return 503; }'; for (const { path: prefix } of prefixRoutes) { if (!expectedRoutes.includes(prefix)) { fail( `${nginxPath} 前缀路由 ${prefix} 必须同时是精确路由:裸前缀自身也要能直达。`, ); continue; } const expectedPattern = buildPrefixRouteNginxPattern(prefix); const prefixLocationBody = findRegexLocationBody(block, expectedPattern); if (prefixLocationBody === null) { fail( `${nginxPath} 缺少前缀路由 ${prefix} 的锚定 location(期望 location ~* "${expectedPattern}")。`, ); continue; } for (const failure of collectPrefixRoutePatternFailures( expectedPattern, prefix, )) { fail(`${nginxPath} ${failure}`); } if (!prefixLocationBody.includes('try_files $uri /index.html =404;')) { fail( `${nginxPath} 前缀路由 ${prefix} 的 location 没有精确回退 index.html。`, ); } if ( exactLocationBody?.includes(maintenanceGuard) && !prefixLocationBody.includes(maintenanceGuard) ) { fail( `${nginxPath} 前缀路由 ${prefix} 的 location 必须与精确 SPA location 一样先判维护状态。`, ); } } 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 = /branding/taonier-maintenance-page.png {', 'try_files /branding/taonier-maintenance-page.png =404;', 'location = /branding/taonier-product-ip.png {', 'try_files /branding/taonier-product-ip.png =404;', '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(); export const expectedPrefixRoutes = collectExpectedPrefixRoutes(); const isMainModule = process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; if (isMainModule) { for (const nginxPath of NGINX_PATHS) { validateNginxRoutes(nginxPath, expectedMainSpaRoutes, expectedPrefixRoutes); } 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, ${expectedPrefixRoutes.length} prefix routes, ${NGINX_PATHS.length} Nginx templates)`, ); }