Files
fristydev/proxy.ts
T
fristy 864b9226fc
Build & Push / Lint & Build (pull_request) Failing after 52s
Build & Push / Build & Push Image (pull_request) Skipped
Build & Push / Trigger Deploy (pull_request) Skipped
desloppify: code health overhaul plus abuse hardening (140 green, strict 87.2)
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.
2026-09-11 23:54:59 +03:00

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)).*)",
],
};