新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
467 lines
18 KiB
TypeScript
467 lines
18 KiB
TypeScript
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) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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;
|
||
}
|