import { type NextFetchEvent, type NextRequest, NextResponse } from "next/server"; import { getToken } from "next-auth/jwt"; import { isBlockedIP, getClientIP } from "@/lib/security/ip-blocklist"; import { checkRateLimit, checkRateLimitByIdentifier, rateLimiters, } from "@/lib/security/rate-limiter"; import { isSuspiciousRequest, logSuspiciousRequest } from "@/lib/security/request-monitor"; type AccessLogPayload = { eventType?: string; ip?: string | null; userId?: string | null; userEmail?: string | null; userRole?: string | null; method?: string | null; path?: string | null; routeGroup?: string | null; statusCode?: number | null; outcome?: string | null; severity?: "info" | "watch" | "high" | "critical"; reasons?: string[]; userAgent?: string | null; referer?: string | null; metadata?: Record; occurredAt?: string; }; type ManualIpBlockCheckResult = { blocked: boolean; entry?: { id: string; ip: string; reason?: string | null; expiresAt?: string | null; } | null; }; const manualIpBlockCache = new Map< string, { expiresAt: number; result: ManualIpBlockCheckResult } >(); function isHighFrequencyReadApi(pathname: string, method: string) { if (method !== "GET") return false; if (/^\/api\/canvas\/[^/]+\/detail-archives$/.test(pathname)) return true; if (/^\/api\/canvas\/[^/]+\/revision$/.test(pathname)) return true; if (/^\/api\/canvas\/layout-replace\/batches\/[^/]+$/.test(pathname)) { return true; } if (/^\/api\/canvas\/[^/]+$/.test(pathname)) return true; return [ "/api/ai-wash/config", "/api/models", "/api/canvas", "/api/canvas/model-aliases", "/api/canvas-skill-tags", "/api/canvas/clawd-chat/status", "/api/enhance/pro/config", "/api/featured", "/api/featured/ranking", "/api/home-animation", "/api/nav-links", "/api/scene-fusion/config", "/api/site-settings", "/api/style-pro/config", "/api/messages", "/api/history", "/api/vote", "/api/video-tasks", "/api/user/image-stats", "/api/tutorial-videos", "/api/user/membership-summary", "/api/user/account-center", "/api/video-canvas/config", ].includes(pathname); } function getAccessRouteGroup(pathname: string) { if (pathname.startsWith("/api/auth")) return "auth"; if (pathname.startsWith("/api/admin")) return "admin-api"; if (pathname.startsWith("/api/")) return "api"; if (pathname.startsWith("/settings")) return "admin-page"; if (pathname === "/login" || pathname === "/register") return "auth-page"; if (pathname.startsWith("/canvas")) return "canvas-page"; return "page"; } function shouldRecordAccessLog( pathname: string, method: string, payload: AccessLogPayload ) { if (process.env.ACCESS_LOG_ENABLED === "false") return false; if (pathname === "/api/internal/access-log") return false; if (payload.severity && payload.severity !== "info") return true; if (payload.outcome && payload.outcome !== "allowed") return true; if (isRoutineAuthRead(pathname, method)) return false; if (isHighFrequencyReadApi(pathname, method)) return false; if (pathname.startsWith("/api/auth")) return true; if (pathname === "/login" || pathname === "/register") return true; if (pathname.startsWith("/settings")) return true; if (pathname.startsWith("/api/admin")) return true; if (pathname.startsWith("/api/")) return method !== "GET"; return false; } function isInternalAccessPath(pathname: string) { return ( pathname === "/api/internal/access-log" || pathname === "/api/internal/ip-blocklist/check" ); } function isLoopbackIp(ip: string | null) { return ( ip === "::1" || ip === "127.0.0.1" || ip === "::ffff:127.0.0.1" || ip === "localhost" ); } function getInternalAccessUrl(request: NextRequest, pathname: string) { const configuredBaseUrl = process.env.ACCESS_LOG_INTERNAL_URL?.trim(); if (configuredBaseUrl) { try { return new URL(pathname, configuredBaseUrl); } catch { // Fall through to the self-hosted runtime address. } } const port = process.env.PORT?.trim(); if (port && /^\d{1,5}$/.test(port)) { return new URL(pathname, `http://127.0.0.1:${port}`); } const url = new URL(request.url); if (url.hostname === "127.0.0.1" || url.hostname === "localhost") { url.protocol = "http:"; } url.pathname = pathname; url.search = ""; url.hash = ""; return url; } function isRoutineAuthRead(pathname: string, method: string) { if (method !== "GET") return false; return ( pathname === "/api/auth/session" || pathname === "/api/auth/validate-session" || pathname === "/api/auth/providers" || pathname === "/api/auth/csrf" ); } function isPascalInternalProxyRequest(request: NextRequest) { const pathname = request.nextUrl.pathname; if ( pathname !== "/api/internal/pascal/access" && pathname !== "/api/internal/pascal-agent/v1/chat/completions" ) { return false; } const expected = String(process.env.PASCAL_PROXY_SECRET || "").trim(); if (!expected) return false; const authorization = request.headers.get("authorization") || ""; const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim() || ""; const supplied = request.headers.get("x-pascal-proxy-secret")?.trim() || bearer; return supplied === expected; } function shouldBypassLocalAuthSessionRateLimit(pathname: string, ip: string | null) { return ( process.env.CANVAS_E2E_LOCAL === "true" && isLoopbackIp(ip) && (pathname === "/api/auth/session" || pathname === "/api/auth/validate-session") ); } function shouldBypassLocalCanvasPageAuth(pathname: string, ip: string | null) { return ( process.env.CANVAS_E2E_LOCAL === "true" && isLoopbackIp(ip) && pathname.startsWith("/canvas") ); } function shouldBypassLocalCanvasE2E(pathname: string, ip: string | null) { if (process.env.CANVAS_E2E_LOCAL !== "true" || !isLoopbackIp(ip)) { return false; } return ( pathname.startsWith("/canvas") || /^\/api\/canvas(?:\/|$)/.test(pathname) || pathname === "/api/models" || pathname === "/api/site-settings" || pathname === "/api/user/membership-summary" || pathname === "/api/video-canvas/config" || pathname === "/api/favicon" || pathname === "/api/auth/session" || pathname === "/api/auth/validate-session" ); } async function checkManualIpBlock( request: NextRequest, ip: string | null ): Promise { if (!ip || ip === "unknown") return { blocked: false }; const now = Date.now(); const cached = manualIpBlockCache.get(ip); if (cached && cached.expiresAt > now) { return cached.result; } const secret = process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || ""; if (!secret) return { blocked: false }; try { const response = await fetch( getInternalAccessUrl(request, "/api/internal/ip-blocklist/check"), { method: "POST", headers: { "content-type": "application/json", "x-access-log-internal-secret": secret, }, body: JSON.stringify({ ip }), } ); if (!response.ok) { return { blocked: false }; } const result = (await response.json().catch(() => null)) as | ManualIpBlockCheckResult | null; const normalizedResult = result?.blocked ? result : { blocked: false }; manualIpBlockCache.set(ip, { expiresAt: now + (normalizedResult.blocked ? 10_000 : 30_000), result: normalizedResult, }); return normalizedResult; } catch (error) { console.warn("[AccessIpBlocklist] manual block check failed", error); manualIpBlockCache.set(ip, { expiresAt: now + 5_000, result: { blocked: false }, }); return { blocked: false }; } } function queueAccessLog( request: NextRequest, event: NextFetchEvent | undefined, payload: AccessLogPayload ) { const secret = process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || ""; if (!secret) return; const { pathname } = request.nextUrl; if (shouldBypassLocalCanvasE2E(pathname, getClientIP(request))) return; if (!shouldRecordAccessLog(pathname, request.method, payload)) return; const body = JSON.stringify({ eventType: "request", ip: getClientIP(request), method: request.method, path: pathname, routeGroup: getAccessRouteGroup(pathname), statusCode: payload.statusCode ?? null, outcome: payload.outcome || "allowed", severity: payload.severity || "info", reasons: payload.reasons || [], userAgent: request.headers.get("user-agent"), referer: request.headers.get("referer"), occurredAt: new Date().toISOString(), ...payload, }); const writePromise = fetch(getInternalAccessUrl(request, "/api/internal/access-log"), { method: "POST", headers: { "content-type": "application/json", "x-access-log-internal-secret": secret, }, body, }).catch((error) => { console.warn("[AccessIpEvent] async proxy write failed", error); }); if (event) { event.waitUntil(writePromise); return; } void writePromise; } export async function proxy(request: NextRequest, event: NextFetchEvent) { const { pathname } = request.nextUrl; const isSecure = process.env.AUTH_URL?.startsWith("https://") || request.nextUrl.protocol === "https:"; let cachedToken: Awaited> | null | undefined; const resolveToken = async () => { if (cachedToken !== undefined) { return cachedToken; } cachedToken = await getToken({ req: request, secret: process.env.AUTH_SECRET, secureCookie: isSecure, }); return cachedToken; }; if (isInternalAccessPath(pathname)) { return NextResponse.next(); } // 🛡️ 安全检查1:标记历史黑名单IP,但不封禁 const clientIP = getClientIP(request); const bypassLocalCanvasE2E = shouldBypassLocalCanvasE2E(pathname, clientIP); const staticBlocklistHit = isBlockedIP(clientIP); let suspiciousCheck: ReturnType = { suspicious: false, }; const accessLogIdentity = () => { const token = cachedToken && typeof cachedToken === "object" ? cachedToken : null; return { userId: typeof token?.sub === "string" ? token.sub : null, userEmail: typeof token?.email === "string" ? token.email : null, userRole: typeof token?.type === "string" ? token.type : null, }; }; const withAccessLog = ( response: NextResponse, details: AccessLogPayload = {} ) => { const reasons = [...(details.reasons || [])]; let severity = details.severity || "info"; if (staticBlocklistHit) { reasons.push("静态黑名单命中但未封禁"); severity = severity === "critical" ? severity : "high"; } if (suspiciousCheck.suspicious && suspiciousCheck.reason) { reasons.push(suspiciousCheck.reason); if (severity === "info") severity = "watch"; } queueAccessLog(request, event, { ...accessLogIdentity(), statusCode: response.status || details.statusCode || null, outcome: details.outcome || "allowed", ...details, severity, reasons, }); return response; }; if (staticBlocklistHit) { console.warn(`[Security] Static blocklist hit but request was not blocked: ${clientIP}`, { pathname, method: request.method, userAgent: request.headers.get("user-agent"), }); } const manualBlock = bypassLocalCanvasE2E ? { blocked: false } : await checkManualIpBlock(request, clientIP); if (manualBlock.blocked) { return withAccessLog( new NextResponse("Forbidden", { status: 403 }), { outcome: "manual_blocked", severity: "critical", reasons: [ "管理员手动拉黑", manualBlock.entry?.reason ? `拉黑原因:${manualBlock.entry.reason}` : "", ].filter(Boolean), metadata: { blocklistId: manualBlock.entry?.id || null, matchedRule: manualBlock.entry?.ip || null, }, } ); } // 🛡️ 安全检查2:检测可疑请求模式 suspiciousCheck = bypassLocalCanvasE2E ? { suspicious: false } : isSuspiciousRequest(request); if (suspiciousCheck.suspicious) { logSuspiciousRequest(request, suspiciousCheck.reason || "Unknown suspicious pattern"); // 对于可疑请求,记录但不立即阻止(避免误报) // 可以在这里添加更严格的检查 } if (bypassLocalCanvasE2E) { return NextResponse.next(); } // 浏览器会自动拉取 favicon,不应参与通用 API 限流。 if (pathname === "/api/favicon") { return NextResponse.next(); } // three.js KTX2/Basis transcoder assets are public runtime dependencies used // by the in-browser 3D viewer. Redirecting them to login breaks texture decode // and leaves generated 3D models as white clay renders. if (pathname.startsWith("/three/basis/")) { return NextResponse.next(); } // Site fonts are public static assets referenced by global CSS. Redirecting // them to login prevents the browser from loading the configured typeface. if (pathname.startsWith("/fonts/")) { return NextResponse.next(); } // 🛡️ 安全检查3:速率限制 // 对API路径应用速率限制 if (pathname === "/api/chat") { const chatRateLimit = checkRateLimit(request, rateLimiters.chat); if (!chatRateLimit.allowed) { console.warn(`[Security] Chat rate limit exceeded for IP: ${clientIP}`, { pathname, resetTime: new Date(chatRateLimit.resetTime).toISOString(), }); return withAccessLog( NextResponse.json( { error: "Too many chat requests", retryAfter: Math.ceil((chatRateLimit.resetTime - Date.now()) / 1000) }, { status: 429 } ), { outcome: "rate_limited", severity: "high", reasons: ["聊天接口限流"], } ); } } else if ( pathname === "/api/canvas/clawd-chat/artifact" && request.method === "GET" ) { const token = await resolveToken(); const userId = typeof token?.sub === "string" && token.sub ? token.sub : null; const artifactRateLimit = checkRateLimitByIdentifier( userId ? `user:${userId}:clawd-artifact` : `ip:${clientIP}:clawd-artifact`, rateLimiters.clawdArtifact ); if (!artifactRateLimit.allowed) { const retryAfter = Math.max( 1, Math.ceil((artifactRateLimit.resetTime - Date.now()) / 1000) ); return withAccessLog( NextResponse.json( { error: `附件读取过于频繁,请 ${retryAfter} 秒后重试`, retryAfter }, { status: 429, headers: { "Retry-After": String(retryAfter) } } ), { outcome: "rate_limited", severity: "watch", reasons: ["Claw附件读取限流"], userId, } ); } } else if (pathname === "/api/image-proxy" && request.method === "GET") { const imageProxyRateLimit = checkRateLimit(request, rateLimiters.imageProxy); if (!imageProxyRateLimit.allowed) { const retryAfter = Math.max(1, Math.ceil((imageProxyRateLimit.resetTime - Date.now()) / 1000)); console.warn(`[Security] Image proxy rate limit exceeded for IP: ${clientIP}`, { pathname, method: request.method, resetTime: new Date(imageProxyRateLimit.resetTime).toISOString(), }); return withAccessLog( NextResponse.json( { error: "Too many image proxy requests", retryAfter }, { status: 429, headers: { "Retry-After": String(retryAfter) } } ), { outcome: "rate_limited", severity: "high", reasons: ["图片代理限流"], } ); } } else if ( (pathname === "/api/files/upload" || pathname === "/api/files/direct-upload") && request.method === "POST" ) { const token = await resolveToken(); const userId = typeof token?.sub === "string" && token.sub.length > 0 ? token.sub : "anonymous"; const uploadRateLimit = checkRateLimitByIdentifier( `${userId}:${request.method}:${pathname}`, rateLimiters.upload ); if (!uploadRateLimit.allowed) { const retryAfter = Math.max(1, Math.ceil((uploadRateLimit.resetTime - Date.now()) / 1000)); console.warn(`[Security] Upload rate limit exceeded for IP: ${clientIP}`, { pathname, method: request.method, userId, resetTime: new Date(uploadRateLimit.resetTime).toISOString(), }); return withAccessLog( NextResponse.json( { error: "Too many upload requests", retryAfter }, { status: 429, headers: { "Retry-After": String(retryAfter) } } ), { outcome: "rate_limited", severity: "high", reasons: ["上传接口限流"], } ); } } else if ( pathname === "/api/canvas/download-image" && request.method === "POST" ) { const token = await resolveToken(); const userId = typeof token?.sub === "string" && token.sub.length > 0 ? token.sub : null; const downloadIdentifier = userId ? `user:${userId}:canvas-download` : `ip:${clientIP}:canvas-download`; const downloadRateLimit = checkRateLimitByIdentifier( downloadIdentifier, rateLimiters.download ); if (!downloadRateLimit.allowed) { const retryAfter = Math.max( 1, Math.ceil((downloadRateLimit.resetTime - Date.now()) / 1000) ); console.warn(`[Security] Download rate limit exceeded for IP: ${clientIP}`, { pathname, userId, resetTime: new Date(downloadRateLimit.resetTime).toISOString(), }); return withAccessLog( NextResponse.json( { error: `图片下载请求过于频繁,请 ${retryAfter} 秒后重试`, retryAfter, }, { status: 429, headers: { "Retry-After": String(retryAfter) }, } ), { outcome: "rate_limited", severity: "high", reasons: ["图片下载限流"], userId, } ); } } else if (pathname.startsWith("/api/canvas/") && request.method === "PUT") { const canvasRateLimit = checkRateLimit(request, rateLimiters.canvas); if (!canvasRateLimit.allowed) { console.warn(`[Security] Canvas rate limit exceeded for IP: ${clientIP}`, { pathname, resetTime: new Date(canvasRateLimit.resetTime).toISOString(), }); return withAccessLog( NextResponse.json( { error: "Too many canvas requests", retryAfter: Math.ceil((canvasRateLimit.resetTime - Date.now()) / 1000) }, { status: 429 } ), { outcome: "rate_limited", severity: "high", reasons: ["画布接口限流"], } ); } } else if (pathname === "/api/canvas/auto-title" && request.method === "POST") { const canvasRateLimit = checkRateLimit(request, rateLimiters.canvas); if (!canvasRateLimit.allowed) { console.warn(`[Security] Canvas auto-title rate limit exceeded for IP: ${clientIP}`, { pathname, resetTime: new Date(canvasRateLimit.resetTime).toISOString(), }); return withAccessLog( NextResponse.json( { error: "Too many canvas requests", retryAfter: Math.ceil((canvasRateLimit.resetTime - Date.now()) / 1000), }, { status: 429 } ), { outcome: "rate_limited", severity: "high", reasons: ["画布自动标题限流"], } ); } } else if ( pathname.startsWith("/api/realtime/") || pathname.startsWith("/api/fal/proxy") ) { const realtimeRateLimit = checkRateLimit(request, rateLimiters.realtime); if (!realtimeRateLimit.allowed) { console.warn(`[Security] Realtime rate limit exceeded for IP: ${clientIP}`, { pathname, resetTime: new Date(realtimeRateLimit.resetTime).toISOString(), }); return withAccessLog( NextResponse.json( { error: "Too many realtime requests", retryAfter: Math.ceil((realtimeRateLimit.resetTime - Date.now()) / 1000) }, { status: 429 } ), { outcome: "rate_limited", severity: "high", reasons: ["实时接口限流"], } ); } } else if (isHighFrequencyReadApi(pathname, request.method)) { const pollingRateLimit = checkRateLimit(request, rateLimiters.uiPolling); if (!pollingRateLimit.allowed) { console.warn(`[Security] UI polling rate limit exceeded for IP: ${clientIP}`, { pathname, resetTime: new Date(pollingRateLimit.resetTime).toISOString(), }); return withAccessLog( NextResponse.json( { error: "Too many polling requests", retryAfter: Math.ceil((pollingRateLimit.resetTime - Date.now()) / 1000), }, { status: 429 } ), { outcome: "rate_limited", severity: "high", reasons: ["高频轮询限流"], } ); } } else if (isRoutineAuthRead(pathname, request.method)) { // Session reads are used for UI hydration and should not count as login attempts. } else if (pathname.startsWith("/api/admin/")) { const token = await resolveToken(); const adminIdentifier = typeof token?.sub === "string" && token.sub.length > 0 ? `admin:${token.sub}` : `admin-ip:${clientIP}`; const adminRateLimit = checkRateLimitByIdentifier( adminIdentifier, rateLimiters.admin ); if (!adminRateLimit.allowed) { const retryAfter = Math.max( 1, Math.ceil((adminRateLimit.resetTime - Date.now()) / 1000) ); console.warn(`[Security] Admin rate limit exceeded for IP: ${clientIP}`, { pathname, resetTime: new Date(adminRateLimit.resetTime).toISOString(), }); return withAccessLog( NextResponse.json( { error: "Too many admin requests", retryAfter }, { status: 429, headers: { "Retry-After": String(retryAfter) } } ), { outcome: "rate_limited", severity: "high", reasons: ["管理后台限流"], } ); } } else if (pathname.startsWith("/api/")) { const rateLimit = checkRateLimit(request, rateLimiters.api); if (!rateLimit.allowed) { const retryAfter = Math.max( 1, Math.ceil((rateLimit.resetTime - Date.now()) / 1000) ); console.warn(`[Security] Rate limit exceeded for IP: ${clientIP}`, { pathname, resetTime: new Date(rateLimit.resetTime).toISOString(), }); return withAccessLog( NextResponse.json( { error: "Too many requests", retryAfter }, { status: 429, headers: { "Retry-After": String(retryAfter) }, } ), { outcome: "rate_limited", severity: "high", reasons: ["通用 API 限流"], } ); } } // 🛡️ 安全检查4:认证相关API的严格速率限制 if ( pathname.startsWith("/api/auth/") && !isRoutineAuthRead(pathname, request.method) && !shouldBypassLocalAuthSessionRateLimit(pathname, clientIP) ) { const rateLimit = checkRateLimit(request, rateLimiters.auth); if (!rateLimit.allowed) { console.warn(`[Security] Auth rate limit exceeded for IP: ${clientIP}`); return withAccessLog( NextResponse.json( { error: "Too many authentication attempts", retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) }, { status: 429 } ), { outcome: "rate_limited", severity: "high", reasons: ["认证接口限流"], } ); } } // 允许访问 ping 端点 if (pathname.startsWith("/ping")) { return NextResponse.next(); } if (pathname === "/classic" || pathname.startsWith("/chat/")) { return withAccessLog( NextResponse.redirect(new URL("/", request.url)), { outcome: "redirect", reasons: ["旧入口重定向"], } ); } // 允许访问认证相关 API if (pathname.startsWith("/api/auth")) { return withAccessLog(NextResponse.next(), { outcome: "allowed", reasons: ["认证接口访问"], }); } // 允许访问公开的网站设置 API(登录页面需要) if (pathname === "/api/site-settings") { return NextResponse.next(); } // Pascal 服务到服务调用由对应 Route 再次验证共享密钥。 if (isPascalInternalProxyRequest(request)) { return NextResponse.next(); } // 允许定时任务接口由服务端调用(具体鉴权由接口内部 CRON_SECRET 负责) if (pathname.startsWith("/api/jobs/")) { return NextResponse.next(); } // 允许公开访问动态 favicon(浏览器标签图标) if (pathname === "/api/favicon") { return NextResponse.next(); } // 允许访问公开的认证配置(登录弹窗需要) if (pathname === "/api/auth-settings") { return NextResponse.next(); } // 图片代理用于站内图片优化/转码,需支持未登录用户下的公开图片链接渲染。 if (pathname === "/api/image-proxy" && request.method === "GET") { return NextResponse.next(); } // 允许匿名访问新版首页灵感数据 if (pathname === "/api/featured" || pathname.startsWith("/api/featured/")) { return NextResponse.next(); } // 允许匿名访问新版首页的轻量渐进配置接口 if ( request.method === "GET" && ["/api/nav-links", "/api/tutorial-videos", "/api/home-animation"].includes(pathname) ) { return NextResponse.next(); } // 允许访问钉钉回调页面(public 文件夹中的文件) if (pathname === "/dingtalk-callback.html") { return NextResponse.next(); } // 允许 DuluPay 支付回调(服务端回调无 session cookie) if (pathname.startsWith("/api/payment/")) { return NextResponse.next(); } // 允许 World Labs key 池导入接口由外部脚本调用(具体鉴权由接口内 token 负责) if (pathname === "/api/worldlabs/key-pool/import") { return NextResponse.next(); } // 允许第三方生图 API 由外部系统直接调用(具体鉴权由接口内 API Key 负责) if (pathname.startsWith("/api/external/image/")) { return NextResponse.next(); } // OpenAPI routes authenticate scoped user Access Keys in the route itself. if (pathname.startsWith("/api/openapi/")) { return NextResponse.next(); } const token = await resolveToken(); const isPublicPage = pathname === "/" || pathname === "/login" || pathname === "/register" || pathname === "/particle-preview" || pathname === "/riji" || pathname.startsWith("/riji/") || pathname.startsWith("/worlds/share/") || shouldBypassLocalCanvasPageAuth(pathname, clientIP); // 未登录用户允许访问公开页面,其余需要登录 if (!token && !isPublicPage) { // 对于 API 路由,统一返回 401 JSON 响应,避免 GET 请求被重定向到登录页 if (pathname.startsWith("/api/")) { return withAccessLog( NextResponse.json( { error: "未授权,请先登录" }, { status: 401 } ), { outcome: "unauthorized", severity: "watch", reasons: ["未登录访问 API"], } ); } // 对于页面路由(GET 请求),重定向到登录页面 const externalBase = process.env.AUTH_URL ?? `${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`; const loginUrl = new URL("/login", externalBase); // 保存当前路径作为回调 URL loginUrl.searchParams.set("callbackUrl", pathname); return withAccessLog( NextResponse.redirect(loginUrl), { outcome: "redirect", severity: "watch", reasons: ["未登录访问受保护页面"], } ); } return withAccessLog(NextResponse.next()); } export const config = { matcher: [ /* * 匹配所有请求路径,除了: * - _next/static (静态文件) * - _next/image (图片优化文件) * - favicon.ico (favicon文件) * - public 文件夹中的文件 */ "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|js|wasm|woff2?|ttf|otf|eot)$).*)", ], };