diff --git a/app/api/map/route.ts b/app/api/map/route.ts index c6493ef..6c5cfe9 100644 --- a/app/api/map/route.ts +++ b/app/api/map/route.ts @@ -10,10 +10,31 @@ import { getConfig } from '@/lib/config'; import { imageUrl } from '@/lib/urls'; import { isAuthenticated } from '@/lib/auth'; import { getMapData } from '@/lib/mapService'; +import { checkRateLimit } from '@/lib/rate-limit'; export const dynamic = 'force-dynamic'; -export async function GET(_request: NextRequest) { +export async function GET(request: NextRequest) { + // Rate limit: 30 requests/minute per IP + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? + request.headers.get('x-real-ip') ?? + '127.0.0.1'; + const rl = checkRateLimit(ip, 30); + if (!rl.success) { + return NextResponse.json( + { error: 'Too many requests' }, + { + status: 429, + headers: { + 'Retry-After': String(Math.ceil((rl.resetAt - Date.now()) / 1000)), + 'X-RateLimit-Limit': '30', + 'X-RateLimit-Remaining': '0', + }, + }, + ); + } + const config = getConfig(); if (!config.map) { diff --git a/app/layout.tsx b/app/layout.tsx index 9db1f45..580fcd6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,15 +5,20 @@ */ import type { Metadata } from 'next'; +import dynamic from 'next/dynamic'; import Link from 'next/link'; import './globals.css'; import { SubpageNav } from '@/components/SubpageNav'; import { ThemeToggle } from '@/components/ThemeToggle'; import { ScrollToTop } from '@/components/ScrollToTop'; import { Footer } from '@/components/Footer'; -import { DevToolbar } from '@/components/DevToolbar'; import { getConfig, getGoogleFontsUrl, AppConfig } from '@/lib/config'; +// Lazy-load DevToolbar — keeps 17KB+ out of the production bundle +const DevToolbar = dynamic(() => import('@/components/DevToolbar').then(m => ({ default: m.DevToolbar })), { + ssr: false, +}); + export async function generateMetadata(): Promise { const config = getConfig(); const siteTitle = config.seo.title; @@ -141,7 +146,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( (); +// Hard ceiling to prevent unbounded memory growth under DDoS conditions +const MAX_STORE_ENTRIES = 10_000; + // Evict expired entries periodically (every 60s) let lastCleanup = Date.now(); const CLEANUP_INTERVAL = 60_000; @@ -29,6 +32,15 @@ function cleanup() { } } +/** + * Evict oldest entry when store is at capacity (FIFO). + */ +function evictIfFull() { + if (store.size < MAX_STORE_ENTRIES) return; + const firstKey = store.keys().next().value; + if (firstKey) store.delete(firstKey); +} + /** * Check if a request should be allowed. * @param ip - Client IP address @@ -49,6 +61,7 @@ export function checkRateLimit( // New window or expired if (!entry || now > entry.expiresAt) { + evictIfFull(); const resetAt = now + windowMs; store.set(key, { count: 1, expiresAt: resetAt }); return { success: true, remaining: maxRpm - 1, resetAt };