import { NextResponse, type NextRequest } from "next/server"; import { TOKEN_COOKIE, REFRESH_COOKIE, CSRF_COOKIE } from "@/lib/cookies"; import { entityPath, leaderboardPath, libraryDocPathFromSlug, libraryDocReadPath, libraryPath, matchPrettyPath, matchStandardPath, normalizeUrlStyle, pagePathFromSlug, standardEntityPath, type PageKind, type UrlStyle, } from "@/lib/urlStyle"; /** 解析正整数 query:读页 ?c=(书内章序号)、伪静态详情 ?sec=(新章节地址);非法/缺省返回 null */ function readPositiveInt(sp: URLSearchParams, key: string): number | null { const n = Number.parseInt(sp.get(key) ?? "", 10); return Number.isFinite(n) && n > 0 ? n : null; } // SSR 登录态保障: // access token(j13_token,7 天)过期后,页面/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>(); 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 { 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 { 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 { 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 分叉;styleRewrite 时透传改写后的内部路径 */ function withPathname(req: NextRequest, init?: { request?: { headers: Headers } }, styleRewrite?: URL) { const headers = new Headers(init?.request?.headers ?? req.headers); headers.set("x-pathname", (styleRewrite ?? req.nextUrl).pathname); const res = styleRewrite ? NextResponse.rewrite(styleRewrite, { request: { headers } }) : NextResponse.next({ request: { headers } }); return res; } // 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); } // ---- 伪静态 URL 风格改写(模板定义见 lib/urlStyle.ts)---- // 风格来自 /api/site-state(与维护态共用 3s 短缓存): // - 非 default:当前风格 pretty 路径 → rewrite 回标准内部路由(继续走 token 轮转, // 不得提前 return);标准路径或其它风格 pretty 路径 → 301 到当前风格(SEO 收敛, // query 原样保留;301 优先于维护态 503,对爬虫语义正确)。 // - default:任何 pretty 路径 → 301 回标准路径。 /** 廉价预判:只对可能命中的路径拉 site-state,/admin、/login 等不产生额外请求 */ function couldBeEntityPath(pathname: string): boolean { return ( pathname.startsWith("/post/") || pathname.startsWith("/u/") || pathname.startsWith("/announcement/") || pathname.startsWith("/p/") || pathname.startsWith("/library") || pathname.startsWith("/leaderboard") || pathname.endsWith(".html") ); } type UrlStyleAction = { type: "pass" } | { type: "redirect"; target: string } | { type: "rewrite"; target: string }; /** 反解结果 → 标准内部路由路径(rewrite 落点);page/libraryDoc/libraryRead 的 slug 保持 encoded 原样。 * 章节号来自 pretty 路径自身,随 target 的 ?c= 透传给读页 */ function standardTarget(m: { kind: PageKind; id?: number; slug?: string }): string { if (m.kind === "page") return `/p/${m.slug ?? ""}`; if (m.kind === "libraryDoc") return `/library/${m.slug ?? ""}`; if (m.kind === "libraryRead") return `/library/${m.slug ?? ""}/read?c=${m.id ?? 0}`; if (m.kind === "library") return "/library"; if (m.kind === "leaderboard") return "/leaderboard"; return standardEntityPath(m.kind, m.id ?? 0); } /** 反解结果 → 对应风格路径(301 目标);page/libraryDoc 用 encoded slug 直接拼,避免二次编码。 * chapterNo:标准读页的 ?c= 值(pretty 路径自身已含章号时不走此参数) */ function styledPath( style: UrlStyle, m: { kind: PageKind; id?: number; slug?: string }, chapterNo?: number | null ): string { if (m.kind === "page") return pagePathFromSlug(style, m.slug ?? ""); if (m.kind === "libraryDoc") return libraryDocPathFromSlug(style, m.slug ?? ""); if (m.kind === "libraryRead") { return libraryDocReadPath(style, m.slug ?? "", chapterNo ?? m.id ?? 1); } if (m.kind === "library") return libraryPath(style); if (m.kind === "leaderboard") return leaderboardPath(style); return entityPath(style, m.kind, m.id ?? 0); } function resolveUrlStyleAction( style: UrlStyle, pathname: string, chapterNo: number | null, secNo: number | null ): UrlStyleAction { // 伪静态详情路径 + ?sec={n}:新章节地址(/library-{slug}.html?sec=n)→ 标准读页; // default 风格无伪静态,301 收敛到标准读页(query ?sec= 转为落点 ?c=) if (secNo != null) { const docHit = matchPrettyPath(pathname, style === "default" ? undefined : style); if (docHit?.kind === "libraryDoc") { const target = `/library/${docHit.slug}/read?c=${secNo}`; return style === "default" ? { type: "redirect", target } : { type: "rewrite", target }; } } // 书库两风格同模板:按当前风格优先匹配,否则 301 目标会与自身相同而死循环 const pretty = matchPrettyPath(pathname, style === "default" ? undefined : style); const standard = pretty ? null : matchStandardPath(pathname); if (!pretty && !standard) return { type: "pass" }; if (style === "default") { if (!pretty) return { type: "pass" }; return { type: "redirect", target: standardTarget(pretty) }; } if (pretty) { if (pretty.style === style) { return { type: "rewrite", target: standardTarget(pretty) }; } return { type: "redirect", target: styledPath(style, pretty, chapterNo) }; } // 标准路径 → 301 到当前风格(读页的 ?c= 已并入伪静态路径段,redirect 时覆盖 query) return { type: "redirect", target: styledPath(style, standard!, chapterNo) }; } /** 301 目标只换 pathname;target 自带 query(读页 ?c= 转路径段)时覆盖原 query,否则原样保留(?tab=/?page=) */ function urlStyleRedirect(req: NextRequest, targetPath: string): NextResponse { const url = req.nextUrl.clone(); const q = targetPath.indexOf("?"); url.pathname = q >= 0 ? targetPath.slice(0, q) : targetPath; if (q >= 0) url.search = targetPath.slice(q); return NextResponse.redirect(url, 301); } async function sessionMiddleware(req: NextRequest) { const robotsRewrite = rewriteRobotsCase(req); if (robotsRewrite) return robotsRewrite; // 伪静态风格改写:rewrite 产物继续走下方 token 轮转(styleRewrite 传给 withPathname), // 301 直接返回(优先于维护态,语义正确) let styleRewrite: URL | undefined; if (couldBeEntityPath(req.nextUrl.pathname)) { const state = await getCachedPublicSiteState(); const action = resolveUrlStyleAction( normalizeUrlStyle(state?.url_style), req.nextUrl.pathname, readPositiveInt(req.nextUrl.searchParams, "c"), readPositiveInt(req.nextUrl.searchParams, "sec") ); if (action.type === "redirect") return urlStyleRedirect(req, action.target); if (action.type === "rewrite") { styleRewrite = req.nextUrl.clone(); // 读页章节 rewrite 落点自带 ?c=,覆盖原 query;其余落点无 query,原样保留 const q = action.target.indexOf("?"); styleRewrite.pathname = q >= 0 ? action.target.slice(0, q) : action.target; if (q >= 0) styleRewrite.search = action.target.slice(q); } } 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, undefined, styleRewrite); } 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(); 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 } }, styleRewrite); // 原样透传,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, undefined, styleRewrite); for (const sc of setCookies) res.headers.append("Set-Cookie", sc); return res; } } catch { // 后端不可达:降级匿名渲染,客户端 fetchWithRefresh 仍可兜底 } return withPathname(req, undefined, styleRewrite); } export const config = { // 仅拦截页面与 RSC 请求;/api 由客户端 fetchWithRefresh 处理,静态资源放行 matcher: [ // 小写 /robots.txt 由 Metadata Route 直接响应,不进 middleware。 // 故意不排除 .txt:否则 /Robots.txt 到不了 rewriteRobotsCase。 // fonts/ 为构建抽取的静态字体声明,排除以免页面级 no-store 覆盖其缓存头。 "/((?!api/|healthz|fonts/|_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; url_style?: string; }; // 维护态短缓存: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 | null = null; async function fetchPublicSiteState(): Promise { 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 { 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 { 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 = '' + title + "

" + title + "

" + escapeMaintenance(m?.message || "请稍后重试。") + "

" + escapeMaintenance(m?.until ? "预计恢复:" + m.until : "") + "

" + escapeMaintenance(m?.contact) + '

管理员登录
'; 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; }