238 lines
7.9 KiB
JavaScript
238 lines
7.9 KiB
JavaScript
#!/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 = /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();
|
|
|
|
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)`,
|
|
);
|