From 0ef8c695a57f9008232ff631011f3ad0d56bd3c6 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 16 Mar 2026 20:30:01 +0000 Subject: [PATCH 1/2] [#219] Add per-IP rate limiting to view count API In-memory rate limiter: max 10 POST /api/views requests per IP per storyline per hour. Returns 429 with Retry-After header on excess. Includes periodic pruning of expired entries to prevent memory leak. Fixes #219 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/views/route.ts | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/app/api/views/route.ts b/src/app/api/views/route.ts index 075a938e..d631edf2 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): boolean { + const key = `${ip}:${storylineId}`; + 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,6 +97,16 @@ 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"; + if (!checkRateLimit(ip, storylineId)) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429, headers: { "Retry-After": "3600" } }, + ); + } + const serverClient = createServerClient(); if (!serverClient) return error("Supabase not configured", 500); From 8135b5efb5674a46250b0e7a21f7758caae866a5 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 16 Mar 2026 20:31:49 +0000 Subject: [PATCH 2/2] [#219] Scope rate limit key by plotIndex Include plotIndex in rate limit key so a story with 10+ plots doesn't exhaust the limit on first load. Each page surface (storyline vs individual plot) now has independent limits. Addresses T2a review feedback on PR #224. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/views/route.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/api/views/route.ts b/src/app/api/views/route.ts index d631edf2..124e9199 100644 --- a/src/app/api/views/route.ts +++ b/src/app/api/views/route.ts @@ -14,8 +14,8 @@ const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour const rateLimitMap = new Map(); -function checkRateLimit(ip: string, storylineId: number): boolean { - const key = `${ip}:${storylineId}`; +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); @@ -100,7 +100,8 @@ export async function POST(req: NextRequest) { // Rate limit: max 10 requests per IP per storyline per hour pruneIfNeeded(); const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; - if (!checkRateLimit(ip, storylineId)) { + const plotVal = plotIndex ?? null; + if (!checkRateLimit(ip, storylineId, plotVal)) { return NextResponse.json( { error: "Too many requests" }, { status: 429, headers: { "Retry-After": "3600" } }, @@ -111,7 +112,6 @@ export async function POST(req: NextRequest) { 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