Honest baseline after removing 45 gaming suppressions. Structural: lib/game plus root hooks extraction, CORS origin echo, DEV_ORIGINS layering. Fixes: error-cause binding, discord 5xx split, null-cache guard, co-located tests, rejecting-fetch coverage, naming and typing cleanup, socket controller, cache bound, cascade helpers, latch resets. Hardening: per-IP rate limit on API routes, deterministic-miss UX, retry cooldown plus cap. Suite 140 green, build plus lint clean.
101 lines
3.1 KiB
TypeScript
101 lines
3.1 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import type { NextRequest } from "next/server";
|
|
import { corsHeaders, forbidden, isAllowedOrigin } from "@/lib/api-route";
|
|
import { LOCALE_COOKIE, matchLocale } from "@/lib/i18n/locales";
|
|
|
|
function withCorsHeaders(response: NextResponse, origin: string | null): NextResponse {
|
|
const headers = corsHeaders(origin);
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
response.headers.set(key, value);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
const RATE_LIMIT_MAX_REQUESTS = 30;
|
|
|
|
const rateLimitHits = new Map<string, number[]>();
|
|
|
|
export function _resetRateLimit(): void {
|
|
rateLimitHits.clear();
|
|
}
|
|
|
|
export function clientIp(request: NextRequest): string {
|
|
const cloudflareIp = request.headers.get("cf-connecting-ip")?.trim();
|
|
if (cloudflareIp) {
|
|
return cloudflareIp;
|
|
}
|
|
const forwardedFor = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
|
if (forwardedFor) {
|
|
return forwardedFor;
|
|
}
|
|
return "direct";
|
|
}
|
|
|
|
function isRateLimited(ip: string, now: number = Date.now()): boolean {
|
|
const cutoff = now - RATE_LIMIT_WINDOW_MS;
|
|
const hits = (rateLimitHits.get(ip) ?? []).filter((timestamp) => timestamp > cutoff);
|
|
if (hits.length >= RATE_LIMIT_MAX_REQUESTS) {
|
|
rateLimitHits.set(ip, hits);
|
|
return true;
|
|
}
|
|
hits.push(now);
|
|
rateLimitHits.set(ip, hits);
|
|
return false;
|
|
}
|
|
|
|
function rateLimited(origin: string | null): NextResponse {
|
|
return NextResponse.json(
|
|
{ error: "Too many requests" },
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
"Cache-Control": "no-store",
|
|
"Retry-After": String(Math.ceil(RATE_LIMIT_WINDOW_MS / 1000)),
|
|
...corsHeaders(origin),
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function handleApi(request: NextRequest): NextResponse {
|
|
const origin = request.headers.get("origin");
|
|
if (isRateLimited(clientIp(request))) {
|
|
return rateLimited(origin);
|
|
}
|
|
if (!isAllowedOrigin(request)) {
|
|
return forbidden(origin);
|
|
}
|
|
if (request.method === "OPTIONS") {
|
|
const response = new NextResponse(null, { status: 204 });
|
|
withCorsHeaders(response, origin);
|
|
response.headers.set("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
response.headers.set("Access-Control-Max-Age", "86400");
|
|
return response;
|
|
}
|
|
return withCorsHeaders(NextResponse.next(), origin);
|
|
}
|
|
|
|
export function proxy(request: NextRequest): NextResponse {
|
|
if (request.nextUrl.pathname.startsWith("/api/")) {
|
|
return handleApi(request);
|
|
}
|
|
if (request.cookies.has(LOCALE_COOKIE)) {
|
|
return NextResponse.next();
|
|
}
|
|
const locale = matchLocale(request.headers.get("accept-language"));
|
|
const response = NextResponse.next();
|
|
response.cookies.set(LOCALE_COOKIE, locale, {
|
|
path: "/",
|
|
maxAge: 60 * 60 * 24 * 365,
|
|
sameSite: "lax",
|
|
});
|
|
return response;
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)).*)",
|
|
],
|
|
};
|