/** * # 模块 全局请求中间件:旧链接 301/302 重定向 + 关站维护页 * 在 router 请求阶段查 redirects 表:先精确匹配(findRedirect), * 再对 is_regex 的条目做正则匹配;命中即返回 301/302 Response。 * 正则条目内存缓存 60 秒,避免每请求全表扫描。 * v0.16.0:settings.maintenance.enabled 开启时前台全部页面返回 503 维护页 * (内容为管理员在 设置→关闭网站 配置的 HTML;/admin 后台不受影响)。 */ import { createMiddleware } from "@tanstack/react-start"; import { db } from "~/db"; import { redirects, settings } from "~/db/schema"; import { and, eq, sql } from "drizzle-orm"; import { hasPublishedSlug, resolveRedirect } from "./data"; /** # 类型 已启用的正则重定向条目(缓存行) **/ type RegexEntry = { source: string; target: string; code: number }; /** # 成员变量 正则重定向缓存 **/ let regexCache: { entries: RegexEntry[]; fetchedAt: number } | null = null; const REGEX_CACHE_TTL = 60_000; /** # 函数 读取启用的正则重定向条目(带缓存) */ async function getRegexEntries(): Promise { if (regexCache && Date.now() - regexCache.fetchedAt < REGEX_CACHE_TTL) return regexCache.entries; const rows = await db .select({ source: redirects.source, target: redirects.target, code: redirects.code }) .from(redirects) .where(and(eq(redirects.isRegex, true), eq(redirects.enabled, true))); regexCache = { entries: rows, fetchedAt: Date.now() }; return rows; } /** # 函数 命中计数(fire-and-forget,不阻塞响应) */ function bumpHits(id: number) { db.update(redirects) .set({ hits: sql`${redirects.hits} + 1` }) .where(eq(redirects.id, id)) .catch(() => {}); } /** # 类型 关站维护配置(settings.maintenance 值) **/ type MaintenanceCfg = { enabled: boolean; html: string }; /** # 成员变量 维护配置缓存(每请求查询太重,60s TTL;关站开关生效最多延迟 60s) **/ let maintenanceCache: { value: MaintenanceCfg; at: number } | null = null; const MAINTENANCE_TTL = 60_000; /** # 函数 读取关站维护配置(带缓存) */ async function getMaintenance(): Promise { if (maintenanceCache && Date.now() - maintenanceCache.at < MAINTENANCE_TTL) return maintenanceCache.value; const [row] = await db.select().from(settings).where(eq(settings.key, "maintenance")); const v = (row?.value ?? {}) as { enabled?: boolean; html?: string }; const value = { enabled: v.enabled === true, html: typeof v.html === "string" ? v.html : "" }; maintenanceCache = { value, at: Date.now() }; return value; } /** # 函数 构造 503 维护页响应(管理员配置的 HTML 居中渲染) */ function toMaintenanceResponse(request: Request, cfg: MaintenanceCfg): Response { const html = `Site temporarily unavailable
${cfg.html || "

Site temporarily unavailable

We will be back shortly.

"}
`; return new Response(html, { status: 503, headers: { "content-type": "text/html; charset=utf-8", "retry-after": "3600", "cache-control": "no-store" }, }); } /** # 函数 请求重定向中间件 * 仅处理 router 类型请求;路径先带斜杠再无斜杠各查一次精确匹配。 * 关站开启时(/admin 除外)直接返回 503 维护页,不再走重定向/内容渲染。 */ export const redirectMiddleware = createMiddleware({ type: "request" }).server(async ({ request, pathname, next, handlerType }) => { // server function RPC 与静态资源不查重定向 if (handlerType !== "router") return next(); const path = decodeURI(pathname).replace(/\/+$/, "") || "/"; // 0) 关站维护:前台全部页面 503(后台 /admin、admin 登录会话接口不受影响) if (!path.startsWith("/admin")) { try { const cfg = await getMaintenance(); if (cfg.enabled) return toMaintenanceResponse(request, cfg); } catch { // 配置读取失败不阻塞正常渲染 } } // 1) 链式解析(原样/规范化/带斜杠变体依次匹配,自指规则跳过防死循环)。 // 内容优先:路径末段能命中已发布内容时直接出内容不重定向(与 sitemap 的 // canonical 一致;导入的重定向表存在 A→B 且 B→A 的矛盾规则对,内容优先可破环) const resolved = await resolveRedirect(path); if (resolved) { const lastSeg = path.split("/").filter(Boolean).pop() ?? ""; if (!(await hasPublishedSlug(decodeURIComponent(lastSeg)).catch(() => false))) { return toRedirect(request, resolved.target, resolved.code, resolved.id); } } // 2) 正则匹配(目标里的 $1..$9 替换为捕获组;部分条目匹配带查询串的完整路径) const search = new URL(request.url).search; const full = path + search; const entries = await getRegexEntries(); for (const entry of entries) { try { const re = new RegExp(entry.source); const m = re.exec(path) ?? re.exec(pathname) ?? re.exec(full); if (m) return toRedirect(request, entry.target, entry.code, undefined, m); } catch { // 非法正则跳过 } } return next(); }); /** # 函数 构造重定向响应(相对目标解析为绝对 URL;$n 替换捕获组) */ function toRedirect(request: Request, target: string, code: number, id?: number, match?: RegExpExecArray): Response { let dest = target; if (match) { dest = target.replace(/\$(\d)/g, (_, d: string) => match[Number(d)] ?? ""); } if (id) bumpHits(id); const abs = new URL(dest, request.url).toString(); const headers = new Headers({ location: abs }); return new Response(null, { status: code, headers }); }