mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
49bd850c8b
Resolve login-page 401 hangs and redirect races by relying on the shared user state. Keep protected-route redirects intact, clean pending requests without unhandled rejections, and allow the dynamic icon route through the page proxy.
128 lines
3.7 KiB
TypeScript
128 lines
3.7 KiB
TypeScript
import type {NextRequest} from 'next/server'
|
|
import {NextResponse} from 'next/server'
|
|
|
|
/**
|
|
* Next.js 16 代理层
|
|
*
|
|
* 1. API 请求速率限制
|
|
* 2. 页面身份验证
|
|
*/
|
|
|
|
/* ==================== 速率限制 ==================== */
|
|
|
|
interface RateLimitEntry {
|
|
count: number
|
|
windowStart: number
|
|
}
|
|
|
|
const rateLimitStore = new Map<string, RateLimitEntry>()
|
|
|
|
/** 不需要速率限制的路径 */
|
|
const EXCLUDED_PREFIXES = [
|
|
'/api/v1/config',
|
|
'/epay/',
|
|
'/lpay/',
|
|
]
|
|
|
|
/** 速率限制规则: [最大请求数, 窗口时长(ms)] */
|
|
const RATE_LIMITS: Record<string, [number, number]> = {
|
|
'/api/v1/oauth/login': [1, 5000],
|
|
'/api/v1/oauth/callback': [1, 5000],
|
|
}
|
|
|
|
/** 默认限制: 60次/60秒 */
|
|
const DEFAULT_RATE_LIMIT: [number, number] = [60, 60000]
|
|
|
|
function getRateLimit(pathname: string): [number, number] {
|
|
if (RATE_LIMITS[pathname]) return RATE_LIMITS[pathname]
|
|
|
|
const match = Object.keys(RATE_LIMITS)
|
|
.filter(prefix => pathname.startsWith(prefix))
|
|
.sort((a, b) => b.length - a.length)[0]
|
|
|
|
return match ? RATE_LIMITS[match] : DEFAULT_RATE_LIMIT
|
|
}
|
|
|
|
function shouldRateLimit(pathname: string): boolean {
|
|
return !EXCLUDED_PREFIXES.some(prefix => pathname.startsWith(prefix))
|
|
}
|
|
|
|
function checkRateLimit(identifier: string, pathname: string): [boolean, number] {
|
|
const [maxRequests, windowMs] = getRateLimit(pathname)
|
|
const key = `${ identifier }:${ pathname }`
|
|
const now = Date.now()
|
|
const entry = rateLimitStore.get(key)
|
|
|
|
if (!entry || now - entry.windowStart >= windowMs) {
|
|
rateLimitStore.set(key, { count: 1, windowStart: now })
|
|
return [true, 0]
|
|
}
|
|
|
|
entry.count++
|
|
if (entry.count > maxRequests) {
|
|
return [false, Math.ceil((windowMs - (now - entry.windowStart)) / 1000)]
|
|
}
|
|
return [true, 0]
|
|
}
|
|
|
|
if (typeof setInterval !== 'undefined') {
|
|
setInterval(() => {
|
|
const now = Date.now()
|
|
for (const [key, entry] of rateLimitStore.entries()) {
|
|
if (now - entry.windowStart > 120000) rateLimitStore.delete(key)
|
|
}
|
|
}, 60000)
|
|
}
|
|
|
|
/* ==================== 代理主函数 ==================== */
|
|
|
|
export function proxy(request: NextRequest) {
|
|
const { pathname, search } = request.nextUrl
|
|
const sessionCookieName = process.env.WAVELET_SESSION_COOKIE_NAME || 'wavelet_session_id'
|
|
const sessionCookie = request.cookies.get(sessionCookieName)
|
|
|
|
/* API 请求:速率限制后放行 */
|
|
if (pathname.startsWith('/api/')) {
|
|
const rateLimitEnabled = process.env.WAVELET_RATE_LIMIT_ENABLED === 'true'
|
|
|
|
if (rateLimitEnabled && shouldRateLimit(pathname)) {
|
|
const identifier = sessionCookie?.value ||
|
|
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
|
|
request.headers.get('x-real-ip') ||
|
|
'anonymous'
|
|
|
|
const [allowed, waitTime] = checkRateLimit(identifier, pathname)
|
|
if (!allowed) {
|
|
return NextResponse.json(
|
|
{ error_code: 'RATE_LIMITED', error_msg: `请求过于频繁,请 ${ waitTime } 秒后重试` },
|
|
{ status: 429, headers: { 'Retry-After': String(waitTime) } }
|
|
)
|
|
}
|
|
}
|
|
return NextResponse.next()
|
|
}
|
|
|
|
/* 页面请求:公共路由放行 */
|
|
const publicRoutes = ['/', '/login', '/register', '/callback', '/privacy', '/terms', '/icon']
|
|
const publicPrefixes = ['/docs/', '/epay/']
|
|
|
|
if (publicRoutes.includes(pathname) || publicPrefixes.some(p => pathname.startsWith(p))) {
|
|
return NextResponse.next()
|
|
}
|
|
|
|
if (!sessionCookie) {
|
|
const loginUrl = new URL('/login', request.url)
|
|
loginUrl.searchParams.set('callbackUrl', pathname + search)
|
|
return NextResponse.redirect(loginUrl)
|
|
}
|
|
|
|
return NextResponse.next()
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
'/api/:path*',
|
|
'/((?!_next|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:jpg|jpeg|gif|png|svg|ico|webp)).*)',
|
|
],
|
|
}
|