完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 00:17:00 +08:00
parent acde7ee288
commit 6f054a903c
98 changed files with 8239 additions and 2073 deletions

View File

@@ -136,7 +136,21 @@ function withPathname(req: NextRequest, init?: { request?: { headers: Headers }
return NextResponse.next({ request: { headers } });
}
export async function middleware(req: NextRequest) {
// RFC 9309 规定爬虫文件必须是小写 /robots.txt。部分 SEO 检测工具会请求
// /Robots.txt、/ROBOTS.TXT 等大小写变体;生产环境(Linux)路径区分大小写,
// 这些请求会 404。内部改写到规范路径,对外仍只维护 robots.ts 一份内容。
function rewriteRobotsCase(req: NextRequest): NextResponse | null {
const path = req.nextUrl.pathname;
if (path === "/robots.txt" || !/^\/robots\.txt$/i.test(path)) return null;
const url = req.nextUrl.clone();
url.pathname = "/robots.txt";
return NextResponse.rewrite(url);
}
async function sessionMiddleware(req: NextRequest) {
const robotsRewrite = rewriteRobotsCase(req);
if (robotsRewrite) return robotsRewrite;
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
const accessToken = req.cookies.get(TOKEN_COOKIE)?.value;
@@ -183,6 +197,131 @@ export async function middleware(req: NextRequest) {
export const config = {
// 仅拦截页面与 RSC 请求;/api 由客户端 fetchWithRefresh 处理,静态资源放行
matcher: [
"/((?!api/|healthz|_next/static/|_next/image/|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|txt|woff2?)$).*)",
// 小写 /robots.txt 由 Metadata Route 直接响应,不进 middleware。
// 故意不排除 .txt:否则 /Robots.txt 到不了 rewriteRobotsCase。
"/((?!api/|healthz|_next/static/|_next/image/|favicon.ico|robots.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff2?)$).*)",
],
};
function escapeMaintenance(value: unknown): string {
return String(value ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c] || c),
);
}
type SiteMaintState = {
maintenance?: {
mode: string;
title: string;
message: string;
contact: string;
until: string;
retry_after: number;
};
bypass?: boolean;
};
// 维护态短缓存:Next 每次页面/RSC/预取都会进 middleware,开发态尤其密。
// 不带 Cookie 拉公开态(bypass 恒为 false),避免把管理员 bypass 错缓存给游客。
const SITE_STATE_TTL_MS = 3_000;
let siteStateCache: { at: number; state: SiteMaintState | null } | null = null;
let siteStateInflight: Promise<SiteMaintState | null> | null = null;
async function fetchPublicSiteState(): Promise<SiteMaintState | null> {
if (!API_BASE) return null;
try {
const upstream = await fetch(`${API_BASE}/api/site-state`, {
cache: "no-store",
signal: AbortSignal.timeout(5000),
});
if (!upstream.ok) return null;
return (await upstream.json()) as SiteMaintState;
} catch {
return null;
}
}
async function getCachedPublicSiteState(): Promise<SiteMaintState | null> {
const now = Date.now();
if (siteStateCache && now - siteStateCache.at < SITE_STATE_TTL_MS) {
return siteStateCache.state;
}
if (!siteStateInflight) {
siteStateInflight = fetchPublicSiteState().finally(() => {
siteStateInflight = null;
});
}
const state = await siteStateInflight;
siteStateCache = { at: Date.now(), state };
return state;
}
async function fetchSiteStateBypass(cookie: string): Promise<boolean> {
if (!API_BASE || !cookie.trim()) return false;
try {
const upstream = await fetch(`${API_BASE}/api/site-state`, {
cache: "no-store",
headers: { Cookie: cookie },
signal: AbortSignal.timeout(5000),
});
if (!upstream.ok) return false;
const state = (await upstream.json()) as SiteMaintState;
return !!state.bypass;
} catch {
return false;
}
}
export async function middleware(req: NextRequest) {
const response = await sessionMiddleware(req);
const path = req.nextUrl.pathname;
if (
path === "/login" ||
path === "/reset-password" ||
path === "/admin" ||
path.startsWith("/admin/") ||
/^\/robots\.txt$/i.test(path)
) {
return response;
}
// 公开维护态(短缓存);paused 时再带 Cookie 确认管理员 bypass
const state = await getCachedPublicSiteState();
if (state && state.maintenance?.mode !== "paused") {
response.headers.set("Cache-Control", "private, no-store");
return response;
}
const cookie =
response.headers.get("x-middleware-request-cookie") || req.headers.get("cookie") || "";
if (await fetchSiteStateBypass(cookie)) {
response.headers.set("Cache-Control", "private, no-store");
return response;
}
const m = state?.maintenance;
const title = escapeMaintenance(m?.title || "站点暂时不可用");
const body =
'<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' +
title +
"</title><style>body{font-family:system-ui,sans-serif;background:#f4f7f5;color:#24352b;margin:0;padding:8vh 24px}main{max-width:620px;margin:auto;background:white;border:1px solid #dce5df;border-radius:20px;padding:36px}p{line-height:1.8;white-space:pre-wrap}a{color:#236e49}@media(prefers-color-scheme:dark){body{background:#151c18;color:#e1eae4}main{background:#202b24;border-color:#3a4a40}a{color:#81cda3}}</style><main><h1>" +
title +
"</h1><p>" +
escapeMaintenance(m?.message || "请稍后重试。") +
"</p><p>" +
escapeMaintenance(m?.until ? "预计恢复:" + m.until : "") +
"</p><p>" +
escapeMaintenance(m?.contact) +
'</p><a href="/login?redirect=%2Fadmin%2Fsettings%2Fbasic">管理员登录</a></main></html>';
const paused = new NextResponse(body, {
status: 503,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "private, no-store",
"Retry-After": String(m?.retry_after || 300),
"X-Content-Type-Options": "nosniff",
},
});
for (const sc of readSetCookies(response)) paused.headers.append("Set-Cookie", sc);
return paused;
}