完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user