mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
1f1f4efec7
Use raw i18n for push template hints so ICU does not parse
{{placeholders}}. Pass total into the user list record count.
Allow log websocket origins behind the Next rewrite, skip the
proxy on Upgrade, and do not open a socket after unmount.
148 lines
4.0 KiB
TypeScript
148 lines
4.0 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);
|
|
|
|
// WebSocket upgrades must pass through to rewrites untouched.
|
|
if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
/* 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/((?!v1/admin/logs/ws).*)',
|
|
'/((?!_next|favicon.ico|robots.txt|sitemap.xml|api/v1/admin/logs/ws|.*\\.(?:jpg|jpeg|gif|png|svg|ico|webp)).*)',
|
|
],
|
|
};
|