Files
jiang13-bbs/frontend/middleware.ts

328 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse, type NextRequest } from "next/server";
import { TOKEN_COOKIE, REFRESH_COOKIE, CSRF_COOKIE } from "@/lib/cookies";
// SSR 登录态保障:
// access token(j13_token,15 分钟)过期后,页面/RSC 请求到达时先在此静默轮转,
// 新 cookie 同时①注入本次请求头供 layout 的 /api/me 使用 ②透传给浏览器。
// refresh token 是一次性轮转(旧的立即吊销),而 RSC 预取与真实导航、甚至多
// 运行时实例可能几乎同时发起轮转:
// - 同一实例内用 in-flight Promise 去重(只合并进行中的请求,失败不缓存);
// - 跨实例的重复请求由后端宽限期兜底(返回同一个新 token 对)。
// 服务端专用地址;边缘部署(如 Cloudflare Workers)必须通过 BACKEND_URL /
// NEXT_PUBLIC_API_URL 显式配置(生产构建不保留 localhost 兜底,避免在边缘
// 环境发起必然失败的请求;未配置时放行,由客户端 fetchWithRefresh 兜底)。
const API_BASE =
process.env.BACKEND_URL ||
process.env.NEXT_PUBLIC_API_URL ||
(process.env.NODE_ENV === "production" ? "" : "http://localhost:3001");
const EXP_SKEW_SECONDS = 30; // 提前 30s 视为过期,规避服务端时钟差
const REFRESH_TIMEOUT_MS = 8000;
type RefreshResult = { ok: boolean; setCookies: string[] };
// 进行中的轮转表:key 为 refresh token 的 SHA-256(不持有明文),
// 请求结束即删除,失败结果绝不缓存
const inflight = new Map<string, Promise<RefreshResult>>();
function isAccessTokenExpired(token: string | undefined): boolean {
if (!token) return true;
try {
const seg = token.split(".")[1]?.replace(/-/g, "+").replace(/_/g, "/");
if (!seg) return true;
const payload = JSON.parse(atob(seg)) as { exp?: number };
// 只读取过期时间,验签由后端负责
return typeof payload.exp !== "number"
? true
: Date.now() >= (payload.exp - EXP_SKEW_SECONDS) * 1000;
} catch {
return true;
}
}
async function hashToken(token: string): Promise<string> {
const data = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
return Array.from(new Uint8Array(data), (b) => b.toString(16).padStart(2, "0")).join("");
}
// 从合并形式的 set-cookie 头中拆出多条(部分运行时没有 headers.getSetCookie())。
// Expires 属性格式含 ", ",不能直接按逗号切分:只有顶层段是 "非属性名=..."
// 才是一条新 cookie 的开始,其余片段拼回上一条。
const COOKIE_ATTR_NAMES = new Set([
"expires",
"max-age",
"domain",
"path",
"samesite",
"secure",
"httponly",
"priority",
]);
function splitCombinedSetCookie(raw: string): string[] {
const segments = raw.split(", ");
const cookies: string[] = [];
for (const seg of segments) {
const pair = seg.split(";", 1)[0] ?? "";
const eq = pair.indexOf("=");
const name = eq > 0 ? pair.slice(0, eq).trim().toLowerCase() : "";
if (eq > 0 && name && !COOKIE_ATTR_NAMES.has(name)) {
cookies.push(seg);
} else if (cookies.length > 0) {
cookies[cookies.length - 1] += ", " + seg;
}
}
return cookies;
}
function readSetCookies(res: Response): string[] {
const headers = res.headers as unknown as {
getSetCookie?: () => string[];
};
if (typeof headers.getSetCookie === "function") {
try {
const list = headers.getSetCookie();
if (list.length > 0) return list;
} catch {
// 落到手动解析
}
}
const raw = res.headers.get("set-cookie");
return raw ? splitCombinedSetCookie(raw) : [];
}
async function doRotate(refreshToken: string, csrf: string): Promise<RefreshResult> {
const res = await fetch(`${API_BASE}/api/auth/refresh`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
Cookie: `${REFRESH_COOKIE}=${refreshToken}; ${CSRF_COOKIE}=${csrf}`,
},
cache: "no-store",
signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
}).catch(() => null);
if (!res) return { ok: false, setCookies: [] };
// 成功=新三枚 cookie;失败(refresh 过期/被吊销)=后端下发的清除指令
const setCookies = readSetCookies(res);
return { ok: res.ok && setCookies.length > 0, setCookies };
}
// 同一 refresh token 的并发请求共享同一个进行中的 Promise(完成即删除,不缓存结果)
async function rotateRefreshToken(refreshToken: string, csrf: string): Promise<RefreshResult> {
const key = await hashToken(refreshToken);
const existing = inflight.get(key);
if (existing) return existing;
const p = doRotate(refreshToken, csrf).finally(() => {
inflight.delete(key);
});
inflight.set(key, p);
return p;
}
// 从 Set-Cookie 头提取 name=value
function parseCookiePair(setCookie: string): [string, string] | null {
const pair = setCookie.split(";", 1)[0] ?? "";
const eq = pair.indexOf("=");
if (eq <= 0) return null;
return [pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()];
}
/** 透传 pathname 给根布局,用于 /admin 与公开站 chrome 分叉 */
function withPathname(req: NextRequest, init?: { request?: { headers: Headers } }) {
const headers = new Headers(init?.request?.headers ?? req.headers);
headers.set("x-pathname", req.nextUrl.pathname);
return NextResponse.next({ request: { headers } });
}
// 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;
// 游客、access 仍有效、或服务端地址未配置:直接放行
if (!refreshToken || !isAccessTokenExpired(accessToken) || !API_BASE) {
return withPathname(req);
}
const csrf = req.cookies.get(CSRF_COOKIE)?.value ?? "";
try {
const { ok, setCookies } = await rotateRefreshToken(refreshToken, csrf);
if (ok) {
// 以旧 jar 为基础覆盖轮转结果,保证本次 SSR 的 cookies() 读到新 access
const jar = new Map<string, string>();
req.cookies.getAll().forEach((c) => jar.set(c.name, c.value));
for (const sc of setCookies) {
const parsed = parseCookiePair(sc);
if (parsed) jar.set(parsed[0], parsed[1]);
}
const headers = new Headers(req.headers);
headers.set("Cookie", [...jar].map(([k, v]) => `${k}=${v}`).join("; "));
const res = withPathname(req, { request: { headers } });
// 原样透传,HttpOnly/Path/SameSite/Secure 等属性全部以后端为准
for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
return res;
}
if (setCookies.length > 0) {
// refresh 已失效:透传后端的清 cookie 指令,避免之后每次请求都白轮转
const res = withPathname(req);
for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
return res;
}
} catch {
// 后端不可达:降级匿名渲染,客户端 fetchWithRefresh 仍可兜底
}
return withPathname(req);
}
export const config = {
// 仅拦截页面与 RSC 请求;/api 由客户端 fetchWithRefresh 处理,静态资源放行
matcher: [
// 小写 /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;
}