Files
jiang13-bbs/frontend/middleware.ts
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

467 lines
18 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";
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<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 分叉;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<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 } }, 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。
"/((?!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;
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<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;
}