diff --git a/src/app/api/views/route.ts b/src/app/api/views/route.ts index 075a938e..124e9199 100644 --- a/src/app/api/views/route.ts +++ b/src/app/api/views/route.ts @@ -5,6 +5,42 @@ function error(message: string, status = 400) { return NextResponse.json({ error: message }, { status }); } +// --------------------------------------------------------------------------- +// In-memory rate limiter: max 10 POST requests per IP per storyline per hour +// --------------------------------------------------------------------------- + +const RATE_LIMIT_MAX = 10; +const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour + +const rateLimitMap = new Map(); + +function checkRateLimit(ip: string, storylineId: number, plotIndex: number | null): boolean { + const key = `${ip}:${storylineId}:${plotIndex ?? "s"}`; + const now = Date.now(); + const entry = rateLimitMap.get(key); + + if (!entry || now >= entry.resetAt) { + rateLimitMap.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); + return true; + } + + if (entry.count >= RATE_LIMIT_MAX) return false; + + entry.count++; + return true; +} + +// Periodically prune expired entries to prevent memory leak +let lastPrune = Date.now(); +function pruneIfNeeded() { + const now = Date.now(); + if (now - lastPrune < RATE_LIMIT_WINDOW_MS) return; + lastPrune = now; + for (const [key, entry] of rateLimitMap) { + if (now >= entry.resetAt) rateLimitMap.delete(key); + } +} + // --------------------------------------------------------------------------- // GET /api/views?storylineId=N // --------------------------------------------------------------------------- @@ -61,11 +97,21 @@ export async function POST(req: NextRequest) { return error("Missing or invalid sessionId"); } + // Rate limit: max 10 requests per IP per storyline per hour + pruneIfNeeded(); + const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + const plotVal = plotIndex ?? null; + if (!checkRateLimit(ip, storylineId, plotVal)) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429, headers: { "Retry-After": "3600" } }, + ); + } + const serverClient = createServerClient(); if (!serverClient) return error("Supabase not configured", 500); const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); - const plotVal = plotIndex ?? null; // Dedup: check if this session already viewed this page in the last hour // eslint-disable-next-line @typescript-eslint/no-explicit-any