Files
dusuniot/src/server/middleware.ts
T

126 lines
6.1 KiB
TypeScript
Raw 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.
/**
* # 模块 全局请求中间件:旧链接 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<RegexEntry[]> {
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<MaintenanceCfg> {
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 = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>Site temporarily unavailable</title><style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f5f6f8;font-family:system-ui,-apple-system,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;color:#333}.wrap{max-width:680px;padding:48px 32px;text-align:center;line-height:1.7}.wrap img{max-width:100%;height:auto}</style></head><body><div class="wrap">${cfg.html || "<h1>Site temporarily unavailable</h1><p>We will be back shortly.</p>"}</div></body></html>`;
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 });
}