feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接 - 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配 - 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成 - middleware 与相关组件适配
This commit is contained in:
@@ -1,5 +1,16 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { TOKEN_COOKIE, REFRESH_COOKIE, CSRF_COOKIE } from "@/lib/cookies";
|
||||
import {
|
||||
entityPath,
|
||||
leaderboardPath,
|
||||
matchPrettyPath,
|
||||
matchStandardPath,
|
||||
normalizeUrlStyle,
|
||||
pagePathFromSlug,
|
||||
standardEntityPath,
|
||||
type PageKind,
|
||||
type UrlStyle,
|
||||
} from "@/lib/urlStyle";
|
||||
|
||||
// SSR 登录态保障:
|
||||
// access token(j13_token,15 分钟)过期后,页面/RSC 请求到达时先在此静默轮转,
|
||||
@@ -129,11 +140,14 @@ function parseCookiePair(setCookie: string): [string, string] | null {
|
||||
return [pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()];
|
||||
}
|
||||
|
||||
/** 透传 pathname 给根布局,用于 /admin 与公开站 chrome 分叉 */
|
||||
function withPathname(req: NextRequest, init?: { request?: { headers: Headers } }) {
|
||||
/** 透传 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", req.nextUrl.pathname);
|
||||
return NextResponse.next({ request: { 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 检测工具会请求
|
||||
@@ -147,16 +161,91 @@ function rewriteRobotsCase(req: NextRequest): NextResponse | null {
|
||||
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("/leaderboard") ||
|
||||
pathname.endsWith(".html")
|
||||
);
|
||||
}
|
||||
|
||||
type UrlStyleAction = { type: "pass" } | { type: "redirect"; target: string } | { type: "rewrite"; target: string };
|
||||
|
||||
/** 反解结果 → 标准内部路由路径(rewrite 落点);page 的 slug 保持 encoded 原样 */
|
||||
function standardTarget(m: { kind: PageKind; id?: number; slug?: string }): string {
|
||||
if (m.kind === "page") return `/p/${m.slug ?? ""}`;
|
||||
if (m.kind === "leaderboard") return "/leaderboard";
|
||||
return standardEntityPath(m.kind, m.id ?? 0);
|
||||
}
|
||||
|
||||
/** 反解结果 → 对应风格路径(301 目标);page 用 encoded slug 直接拼,避免二次编码 */
|
||||
function styledPath(style: UrlStyle, m: { kind: PageKind; id?: number; slug?: string }): string {
|
||||
if (m.kind === "page") return pagePathFromSlug(style, m.slug ?? "");
|
||||
if (m.kind === "leaderboard") return leaderboardPath(style);
|
||||
return entityPath(style, m.kind, m.id ?? 0);
|
||||
}
|
||||
|
||||
function resolveUrlStyleAction(style: UrlStyle, pathname: string): UrlStyleAction {
|
||||
const pretty = matchPrettyPath(pathname);
|
||||
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) };
|
||||
}
|
||||
// 标准路径 → 301 到当前风格
|
||||
return { type: "redirect", target: styledPath(style, standard!) };
|
||||
}
|
||||
|
||||
/** 301 目标只换 pathname,origin 与 query(?tab=/?page=)原样保留 */
|
||||
function urlStyleRedirect(req: NextRequest, targetPath: string): NextResponse {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = targetPath;
|
||||
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);
|
||||
if (action.type === "redirect") return urlStyleRedirect(req, action.target);
|
||||
if (action.type === "rewrite") {
|
||||
styleRewrite = req.nextUrl.clone();
|
||||
styleRewrite.pathname = action.target;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
return withPathname(req, undefined, styleRewrite);
|
||||
}
|
||||
|
||||
const csrf = req.cookies.get(CSRF_COOKIE)?.value ?? "";
|
||||
@@ -175,7 +264,7 @@ async function sessionMiddleware(req: NextRequest) {
|
||||
const headers = new Headers(req.headers);
|
||||
headers.set("Cookie", [...jar].map(([k, v]) => `${k}=${v}`).join("; "));
|
||||
|
||||
const res = withPathname(req, { request: { headers } });
|
||||
const res = withPathname(req, { request: { headers } }, styleRewrite);
|
||||
// 原样透传,HttpOnly/Path/SameSite/Secure 等属性全部以后端为准
|
||||
for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
|
||||
return res;
|
||||
@@ -183,7 +272,7 @@ async function sessionMiddleware(req: NextRequest) {
|
||||
|
||||
if (setCookies.length > 0) {
|
||||
// refresh 已失效:透传后端的清 cookie 指令,避免之后每次请求都白轮转
|
||||
const res = withPathname(req);
|
||||
const res = withPathname(req, undefined, styleRewrite);
|
||||
for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
|
||||
return res;
|
||||
}
|
||||
@@ -191,7 +280,7 @@ async function sessionMiddleware(req: NextRequest) {
|
||||
// 后端不可达:降级匿名渲染,客户端 fetchWithRefresh 仍可兜底
|
||||
}
|
||||
|
||||
return withPathname(req);
|
||||
return withPathname(req, undefined, styleRewrite);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
@@ -219,6 +308,7 @@ type SiteMaintState = {
|
||||
retry_after: number;
|
||||
};
|
||||
bypass?: boolean;
|
||||
url_style?: string;
|
||||
};
|
||||
|
||||
// 维护态短缓存:Next 每次页面/RSC/预取都会进 middleware,开发态尤其密。
|
||||
|
||||
Reference in New Issue
Block a user