Files
Genarrative/scripts/check-nginx-spa-routes.mjs
T
kdletters d756e8e04b 补齐Jenkins凭据与维护后台通道
固化三个Stdb流水线的Secret File凭据默认值
将04:00开发服定时发布默认切换为normal
显式关闭三个下游Build Job的自动发布
允许内网来源在维护模式下访问后台完整链路
保持公网后台与普通业务路由的维护阻断
补齐Nginx、Pingora回归门禁与运维文档
2026-07-11 17:40:37 +08:00

217 lines
6.9 KiB
JavaScript

#!/usr/bin/env node
import { readFileSync } from 'node:fs';
const APP_PAGE_ROUTES_PATH = 'src/routing/appPageRoutes.ts';
const APP_ROUTES_PATH = 'src/routing/appRoutes.tsx';
const COMPATIBILITY_ROUTES = ['/creation/rpg/agent'];
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 runtimeEntries = extractSourceBlock(
appPageRoutes,
/export const APP_RUNTIME_ROUTES[^=]*= \{([\s\S]*?)\n\};/u,
`${APP_PAGE_ROUTES_PATH} APP_RUNTIME_ROUTES`,
);
const routes = [
...Array.from(
stageEntries.matchAll(/\[\s*'[^']+'\s*,\s*'([^']+)'\s*\]/gu),
(match) => match[1],
),
...Array.from(
runtimeEntries.matchAll(/'[^']+'\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 validateMaintenanceAdminBypass() {
const snippet = readFileSync(MAINTENANCE_SNIPPET_PATH, 'utf8');
for (const fragment of [
'set $genarrative_admin_maintenance $genarrative_maintenance;',
'if ($genarrative_internal_client)',
'set $genarrative_admin_maintenance 0;',
]) {
if (!snippet.includes(fragment)) {
fail(`${MAINTENANCE_SNIPPET_PATH} 缺少内网后台维护放行片段: ${fragment}`);
}
}
for (const nginxPath of MAINTENANCE_NGINX_PATHS) {
const source = readFileSync(nginxPath, 'utf8');
for (const fragment of [
'geo $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;',
'::1 1;',
'fc00::/7 1;',
]) {
if (!source.includes(fragment)) {
fail(`${nginxPath} 缺少内网来源识别片段: ${fragment}`);
}
}
const adminMaintenanceChecks = source.match(
/if \(\$genarrative_admin_maintenance\)/gu,
);
if ((adminMaintenanceChecks?.length ?? 0) !== 4) {
fail(`${nginxPath} 的后台入口、页面、静态资源与 API 必须只按内网感知的维护变量拦截。`);
}
}
}
export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes();
for (const nginxPath of NGINX_PATHS) {
validateNginxRoutes(nginxPath, expectedMainSpaRoutes);
}
validateMaintenanceAdminBypass();
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)`,
);