统一维护与错误页面并修复移动端顶部文案
新增品牌维护页和404页面并补齐网关路由 将临时维护公告迁到release外运行态覆盖并增加构建门禁 同步Nginx、Pingora、部署脚本与生产运维文档 隐藏移动端顶部SEO介绍并保留桌面端与H1语义 增加维护页、404页和移动端回归测试
This commit is contained in:
@@ -446,6 +446,17 @@ if [[ "${BUILD_WEB}" -eq 1 ]]; then
|
||||
MAINTENANCE_HTML
|
||||
fi
|
||||
|
||||
if [[ ! -f "${WEB_DIR}/404.html" ]]; then
|
||||
echo "[production-release] Web 发布包缺少品牌 404 页面: ${WEB_DIR}/404.html" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[production-release] 校验默认维护页不包含临时公告"
|
||||
(
|
||||
cd "${REPO_ROOT}"
|
||||
node scripts/check-maintenance-page.mjs --file "${WEB_DIR}/maintenance.html"
|
||||
)
|
||||
|
||||
echo "[production-release] 规范 Web 静态资源权限"
|
||||
find "${WEB_DIR}" -type d -exec chmod 755 {} +
|
||||
find "${WEB_DIR}" -type f -exec chmod 644 {} +
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const failures = [];
|
||||
const requestedFiles = [];
|
||||
|
||||
for (let index = 2; index < process.argv.length; index += 1) {
|
||||
if (process.argv[index] !== '--file' || !process.argv[index + 1]) {
|
||||
failures.push(`未知或不完整参数: ${process.argv[index]}`);
|
||||
continue;
|
||||
}
|
||||
requestedFiles.push(path.resolve(process.argv[index + 1]));
|
||||
index += 1;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
failures.push(message);
|
||||
}
|
||||
|
||||
function validateDefaultPage(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
fail(`默认维护页不存在: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const source = readFileSync(filePath, 'utf8');
|
||||
if (!source.includes('服务维护中')) {
|
||||
fail(`${filePath} 必须保留无日期的“服务维护中”默认文案。`);
|
||||
}
|
||||
for (const [pattern, label] of [
|
||||
[/(?:今天|今晚|明天|昨天|昨日)/u, '相对日期'],
|
||||
[/(?:20\d{2}[-/.年]\d{1,2}(?:[-/.月]\d{1,2}日?)?|\d{1,2}月\d{1,2}日)/u, '具体日期'],
|
||||
[/(?:[01]?\d|2[0-3]):[0-5]\d/u, '具体维护时间'],
|
||||
]) {
|
||||
if (pattern.test(source)) {
|
||||
fail(`${filePath} 不能包含${label};临时公告必须使用运行态覆盖页。`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runScript(scriptPath, args, env) {
|
||||
return spawnSync('bash', [scriptPath, ...args], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, ...env },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function validateRuntimePageLifecycle() {
|
||||
const tempRoot = mkdtempSync(path.join(os.tmpdir(), 'genarrative-maintenance-'));
|
||||
const markerFile = path.join(tempRoot, 'state', 'enabled');
|
||||
const runtimePageFile = path.join(tempRoot, 'state', 'page.html');
|
||||
const sourcePageFile = path.join(tempRoot, 'announcement.html');
|
||||
const onScript = path.join(repoRoot, 'scripts/deploy/maintenance-on.sh');
|
||||
const offScript = path.join(repoRoot, 'scripts/deploy/maintenance-off.sh');
|
||||
const env = {
|
||||
GENARRATIVE_MAINTENANCE_FILE: markerFile,
|
||||
GENARRATIVE_MAINTENANCE_PAGE_FILE: runtimePageFile,
|
||||
};
|
||||
|
||||
try {
|
||||
const announcement = '<!doctype html><title>planned maintenance</title>\n';
|
||||
writeFileSync(sourcePageFile, announcement);
|
||||
|
||||
const enable = runScript(
|
||||
onScript,
|
||||
['--page-file', sourcePageFile, 'planned maintenance'],
|
||||
env,
|
||||
);
|
||||
if (enable.status !== 0) {
|
||||
fail(`maintenance-on --page-file 执行失败: ${enable.stderr || enable.stdout}`);
|
||||
return;
|
||||
}
|
||||
if (!existsSync(markerFile)) {
|
||||
fail('maintenance-on --page-file 必须创建维护 marker。');
|
||||
}
|
||||
if (!existsSync(runtimePageFile)) {
|
||||
fail('maintenance-on --page-file 必须原子安装运行态公告页。');
|
||||
} else {
|
||||
if (readFileSync(runtimePageFile, 'utf8') !== announcement) {
|
||||
fail('运行态公告页内容与输入文件不一致。');
|
||||
}
|
||||
if ((statSync(runtimePageFile).mode & 0o777) !== 0o644) {
|
||||
fail('运行态公告页权限必须为 0644。');
|
||||
}
|
||||
}
|
||||
|
||||
const nestedEnable = runScript(onScript, ['api deploy'], env);
|
||||
if (nestedEnable.status !== 0 || !existsSync(runtimePageFile)) {
|
||||
fail('同一维护窗口内的后续 maintenance-on 必须保留已安装公告页。');
|
||||
}
|
||||
|
||||
const disable = runScript(offScript, [], env);
|
||||
if (disable.status !== 0) {
|
||||
fail(`maintenance-off 执行失败: ${disable.stderr || disable.stdout}`);
|
||||
}
|
||||
if (existsSync(markerFile) || existsSync(runtimePageFile)) {
|
||||
fail('maintenance-off 必须同时清理 marker 和运行态公告页。');
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(runtimePageFile), { recursive: true });
|
||||
writeFileSync(runtimePageFile, announcement);
|
||||
chmodSync(runtimePageFile, 0o644);
|
||||
const genericEnable = runScript(onScript, ['generic maintenance'], env);
|
||||
if (genericEnable.status !== 0) {
|
||||
fail(`通用 maintenance-on 执行失败: ${genericEnable.stderr || genericEnable.stdout}`);
|
||||
}
|
||||
if (existsSync(runtimePageFile)) {
|
||||
fail('新维护窗口未提供 --page-file 时必须清理残留公告页。');
|
||||
}
|
||||
runScript(offScript, [], env);
|
||||
|
||||
const missingPage = runScript(
|
||||
onScript,
|
||||
['--page-file', path.join(tempRoot, 'missing.html')],
|
||||
env,
|
||||
);
|
||||
if (missingPage.status === 0 || existsSync(markerFile)) {
|
||||
fail('不存在的 --page-file 必须在创建 marker 前失败。');
|
||||
}
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function validateGatewayConfiguration() {
|
||||
const nginxSnippet = readFileSync(
|
||||
path.join(repoRoot, 'deploy/nginx/snippets/genarrative-maintenance.conf'),
|
||||
'utf8',
|
||||
);
|
||||
for (const expected of [
|
||||
'root /var/lib/genarrative/maintenance;',
|
||||
'try_files /page.html @genarrative_default_maintenance;',
|
||||
'location @genarrative_default_maintenance',
|
||||
'root /srv/genarrative/web;',
|
||||
]) {
|
||||
if (!nginxSnippet.includes(expected)) {
|
||||
fail(`Nginx 维护页配置缺少运行态覆盖约束: ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
const pingoraSource = readFileSync(
|
||||
path.join(repoRoot, 'server-rs/crates/pingora-gateway/src/main.rs'),
|
||||
'utf8',
|
||||
);
|
||||
for (const expected of [
|
||||
'GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE',
|
||||
'maintenance_page_file',
|
||||
]) {
|
||||
if (!pingoraSource.includes(expected)) {
|
||||
fail(`Pingora 维护页配置缺少运行态覆盖约束: ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
const releaseBuildScript = readFileSync(
|
||||
path.join(repoRoot, 'scripts/build-production-release.sh'),
|
||||
'utf8',
|
||||
);
|
||||
if (
|
||||
!releaseBuildScript.includes(
|
||||
'node scripts/check-maintenance-page.mjs --file "${WEB_DIR}/maintenance.html"',
|
||||
)
|
||||
) {
|
||||
fail('生产 Web 发布包构建必须校验最终 maintenance.html 不含临时公告。');
|
||||
}
|
||||
}
|
||||
|
||||
for (const filePath of
|
||||
requestedFiles.length > 0
|
||||
? requestedFiles
|
||||
: [path.join(repoRoot, 'public/maintenance.html')]) {
|
||||
validateDefaultPage(filePath);
|
||||
}
|
||||
|
||||
if (requestedFiles.length === 0) {
|
||||
validateRuntimePageLifecycle();
|
||||
validateGatewayConfiguration();
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[check:maintenance-page] FAILED');
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('[check:maintenance-page] 通过');
|
||||
@@ -172,6 +172,17 @@ function validateMaintenanceInternalBypass() {
|
||||
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');
|
||||
@@ -193,6 +204,9 @@ function validateMaintenanceInternalBypass() {
|
||||
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) {
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import net from 'node:net';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const failures = [];
|
||||
@@ -51,6 +51,7 @@ async function main() {
|
||||
const webRoot = path.join(tempRoot, 'web');
|
||||
const acmeRoot = path.join(tempRoot, 'acme');
|
||||
const maintenanceFile = path.join(tempRoot, 'maintenance', 'enabled');
|
||||
const maintenancePageFile = path.join(tempRoot, 'maintenance', 'page.html');
|
||||
const accessLogFile = path.join(tempRoot, 'logs', 'pingora.access.log');
|
||||
|
||||
await prepareStaticRoots(webRoot, acmeRoot);
|
||||
@@ -158,6 +159,7 @@ async function main() {
|
||||
GENARRATIVE_PINGORA_GATEWAY_WEB_ROOT: webRoot,
|
||||
GENARRATIVE_PINGORA_GATEWAY_ACME_ROOT: acmeRoot,
|
||||
GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE: maintenanceFile,
|
||||
GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE: maintenancePageFile,
|
||||
GENARRATIVE_PINGORA_GATEWAY_ACCESS_LOG_FILE: accessLogFile,
|
||||
GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN: probeToken,
|
||||
GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO: 'https',
|
||||
@@ -208,6 +210,7 @@ async function main() {
|
||||
redirectBaseUrl,
|
||||
probeToken,
|
||||
maintenanceFile,
|
||||
maintenancePageFile,
|
||||
accessLogFile,
|
||||
api,
|
||||
spacetime,
|
||||
@@ -348,8 +351,9 @@ async function prepareStaticRoots(webRoot, acmeRoot) {
|
||||
);
|
||||
await writeFile(
|
||||
path.join(webRoot, 'maintenance.html'),
|
||||
'<main>maintenance</main>',
|
||||
'<main>default-maintenance</main>',
|
||||
);
|
||||
await writeFile(path.join(webRoot, '404.html'), '<main>not-found-page</main>');
|
||||
await writeFile(
|
||||
path.join(acmeRoot, '.well-known', 'acme-challenge', 'token'),
|
||||
'acme-token',
|
||||
@@ -550,6 +554,7 @@ async function runSmokeCases(
|
||||
redirectBaseUrl,
|
||||
probeToken,
|
||||
maintenanceFile,
|
||||
maintenancePageFile,
|
||||
accessLogFile,
|
||||
api,
|
||||
spacetime,
|
||||
@@ -598,6 +603,21 @@ async function runSmokeCases(
|
||||
`主站未知路径返回真实 404: ${unknownPath}`,
|
||||
);
|
||||
}
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/some/browser/navigation',
|
||||
404,
|
||||
'not-found-page',
|
||||
'浏览器导航未知路径返回品牌 404 页面',
|
||||
{
|
||||
headers: {
|
||||
Accept: 'text/html,application/xhtml+xml',
|
||||
},
|
||||
validate: (response) =>
|
||||
response.headers['cache-control'] === 'no-store' &&
|
||||
response.headers['content-type']?.startsWith('text/html'),
|
||||
},
|
||||
);
|
||||
await expectHttp(baseUrl, '/admin', 301, '', '/admin 301 到 /admin/', {
|
||||
validate: (response) => response.headers.location === '/admin/',
|
||||
});
|
||||
@@ -1124,6 +1144,7 @@ async function runSmokeCases(
|
||||
]);
|
||||
|
||||
await mkdir(path.dirname(maintenanceFile), { recursive: true });
|
||||
await writeFile(maintenancePageFile, '<main>runtime-maintenance</main>');
|
||||
await writeFile(maintenanceFile, 'enabled');
|
||||
const publicClientHeaders = {
|
||||
'X-Real-IP': '203.0.113.50',
|
||||
@@ -1149,11 +1170,11 @@ async function runSmokeCases(
|
||||
'维护模式 Gitea Host 请求没有打到 Gitea mock',
|
||||
);
|
||||
for (const [path, bodyNeedle, label] of [
|
||||
['/', 'maintenance', '公网主站页面'],
|
||||
['/', 'runtime-maintenance', '公网主站页面'],
|
||||
['/api/creation-entry/config', 'MAINTENANCE', '公网普通 API'],
|
||||
['/v1/identity', 'maintenance', '公网 SpacetimeDB 路由'],
|
||||
['/admin/settings', 'maintenance', '公网后台页面'],
|
||||
['/admin/assets/admin.js', 'maintenance', '公网后台静态资源'],
|
||||
['/v1/identity', 'runtime-maintenance', '公网 SpacetimeDB 路由'],
|
||||
['/admin/settings', 'runtime-maintenance', '公网后台页面'],
|
||||
['/admin/assets/admin.js', 'runtime-maintenance', '公网后台静态资源'],
|
||||
['/admin/api/users', 'MAINTENANCE', '公网后台 API'],
|
||||
]) {
|
||||
await expectHttp(
|
||||
@@ -1167,6 +1188,15 @@ async function runSmokeCases(
|
||||
},
|
||||
);
|
||||
}
|
||||
await rm(maintenancePageFile, { force: true });
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/',
|
||||
503,
|
||||
'default-maintenance',
|
||||
'运行态公告缺失时回退 Web 制品默认维护页',
|
||||
{ headers: publicClientHeaders },
|
||||
);
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/',
|
||||
|
||||
@@ -3,10 +3,16 @@
|
||||
set -euo pipefail
|
||||
|
||||
MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenance/enabled}"
|
||||
MAINTENANCE_PAGE_FILE="${GENARRATIVE_MAINTENANCE_PAGE_FILE:-/var/lib/genarrative/maintenance/page.html}"
|
||||
|
||||
if [[ -f "${MAINTENANCE_FILE}" ]]; then
|
||||
rm -f "${MAINTENANCE_FILE}"
|
||||
rm -f -- "${MAINTENANCE_FILE}"
|
||||
echo "[maintenance] 已退出维护模式: ${MAINTENANCE_FILE}"
|
||||
else
|
||||
echo "[maintenance] 当前未处于维护模式: ${MAINTENANCE_FILE}"
|
||||
fi
|
||||
|
||||
if [[ -e "${MAINTENANCE_PAGE_FILE}" || -L "${MAINTENANCE_PAGE_FILE}" ]]; then
|
||||
rm -f -- "${MAINTENANCE_PAGE_FILE}"
|
||||
echo "[maintenance] 已清理本次运行态公告页: ${MAINTENANCE_PAGE_FILE}"
|
||||
fi
|
||||
|
||||
@@ -3,13 +3,59 @@
|
||||
set -euo pipefail
|
||||
|
||||
MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenance/enabled}"
|
||||
REASON="${*:-manual}"
|
||||
MAINTENANCE_PAGE_FILE="${GENARRATIVE_MAINTENANCE_PAGE_FILE:-/var/lib/genarrative/maintenance/page.html}"
|
||||
PAGE_SOURCE=""
|
||||
REASON_PARTS=()
|
||||
|
||||
mkdir -p "$(dirname "${MAINTENANCE_FILE}")"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--page-file)
|
||||
PAGE_SOURCE="${2:?缺少 --page-file 的值}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "用法: $0 [--page-file <临时公告 HTML>] [维护原因]"
|
||||
exit 0
|
||||
;;
|
||||
--*)
|
||||
echo "[maintenance] 未知参数: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
REASON_PARTS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
REASON="${REASON_PARTS[*]:-manual}"
|
||||
|
||||
if [[ -n "${PAGE_SOURCE}" && ( ! -f "${PAGE_SOURCE}" || ! -s "${PAGE_SOURCE}" ) ]]; then
|
||||
echo "[maintenance] 临时公告页不存在或为空: ${PAGE_SOURCE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "${MAINTENANCE_FILE}")" "$(dirname "${MAINTENANCE_PAGE_FILE}")"
|
||||
|
||||
if [[ -n "${PAGE_SOURCE}" ]]; then
|
||||
page_temp="$(mktemp "${MAINTENANCE_PAGE_FILE}.tmp.XXXXXX")"
|
||||
trap 'rm -f "${page_temp:-}" "${marker_temp:-}"' EXIT
|
||||
install -m 0644 -- "${PAGE_SOURCE}" "${page_temp}"
|
||||
mv -fT -- "${page_temp}" "${MAINTENANCE_PAGE_FILE}"
|
||||
page_temp=""
|
||||
echo "[maintenance] 已安装本次运行态公告页: ${MAINTENANCE_PAGE_FILE}"
|
||||
elif [[ ! -f "${MAINTENANCE_FILE}" && ( -e "${MAINTENANCE_PAGE_FILE}" || -L "${MAINTENANCE_PAGE_FILE}" ) ]]; then
|
||||
rm -f -- "${MAINTENANCE_PAGE_FILE}"
|
||||
fi
|
||||
|
||||
marker_temp="$(mktemp "${MAINTENANCE_FILE}.tmp.XXXXXX")"
|
||||
{
|
||||
printf "enabled_at=%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf "reason=%s\n" "${REASON}"
|
||||
} >"${MAINTENANCE_FILE}"
|
||||
} >"${marker_temp}"
|
||||
|
||||
chmod 0644 "${MAINTENANCE_FILE}"
|
||||
chmod 0644 "${marker_temp}"
|
||||
mv -fT -- "${marker_temp}" "${MAINTENANCE_FILE}"
|
||||
marker_temp=""
|
||||
trap - EXIT
|
||||
echo "[maintenance] 已进入维护模式: ${MAINTENANCE_FILE}"
|
||||
|
||||
Reference in New Issue
Block a user