From 8b355da066f69219ce604bd7c12b349add11926d Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 17 Mar 2026 09:01:09 +0000 Subject: [PATCH 1/3] [#232] Replace in-memory rate limit with durable Supabase-based check Replace volatile in-memory IP rate limiter with a durable session-based rate limit that queries page_views count per session per hour. Survives serverless cold starts and doesn't rely on x-forwarded-for. Fixes #232 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/views/route.ts | 54 ++++++++++---------------------------- 1 file changed, 14 insertions(+), 40 deletions(-) diff --git a/src/app/api/views/route.ts b/src/app/api/views/route.ts index 7d1895f7..e1c4d59d 100644 --- a/src/app/api/views/route.ts +++ b/src/app/api/views/route.ts @@ -7,40 +7,10 @@ function error(message: string, status = 400) { } // --------------------------------------------------------------------------- -// In-memory rate limiter: max 10 POST requests per IP per storyline per hour +// Rate limit constants // --------------------------------------------------------------------------- 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 @@ -98,22 +68,26 @@ 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(); + // Durable rate limit: max 10 views per session per hour (survives cold starts) + const { count: recentCount } = await serverClient.from("page_views") + .select("id", { count: "exact", head: true }) + .eq("session_id", sessionId) + .gte("viewed_at", oneHourAgo); + + if (recentCount !== null && recentCount >= RATE_LIMIT_MAX) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429, headers: { "Retry-After": "3600" } }, + ); + } + // Dedup: check if this session already viewed this page in the last hour let dedupQuery = serverClient.from("page_views") .select("id") From a0c5bd5eb889b4142e30a817a8a1515f6a18735c Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 17 Mar 2026 09:03:14 +0000 Subject: [PATCH 2/3] [#232] Handle rate-limit count query error before using result Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/views/route.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/api/views/route.ts b/src/app/api/views/route.ts index e1c4d59d..8dd6c18b 100644 --- a/src/app/api/views/route.ts +++ b/src/app/api/views/route.ts @@ -76,11 +76,13 @@ export async function POST(req: NextRequest) { const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); // Durable rate limit: max 10 views per session per hour (survives cold starts) - const { count: recentCount } = await serverClient.from("page_views") + const { count: recentCount, error: countError } = await serverClient.from("page_views") .select("id", { count: "exact", head: true }) .eq("session_id", sessionId) .gte("viewed_at", oneHourAgo); + if (countError) return error(`Database error: ${countError.message}`, 500); + if (recentCount !== null && recentCount >= RATE_LIMIT_MAX) { return NextResponse.json( { error: "Too many requests" }, From 7fce5516a0eab9d0029c5c8e5fac97f0a8fd8247 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 17 Mar 2026 09:04:56 +0000 Subject: [PATCH 3/3] [#232] Scope rate limit per storyline, add session rate index - Add storyline_id filter to rate limit query (prevents blocking users who browse many storylines) - Add index on (session_id, viewed_at) for efficient rate limit counts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/views/route.ts | 3 ++- supabase/migrations/00011_session_rate_limit_index.sql | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/00011_session_rate_limit_index.sql diff --git a/src/app/api/views/route.ts b/src/app/api/views/route.ts index 8dd6c18b..5202bdff 100644 --- a/src/app/api/views/route.ts +++ b/src/app/api/views/route.ts @@ -75,10 +75,11 @@ export async function POST(req: NextRequest) { const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); - // Durable rate limit: max 10 views per session per hour (survives cold starts) + // Durable rate limit: max 10 views per session per storyline per hour const { count: recentCount, error: countError } = await serverClient.from("page_views") .select("id", { count: "exact", head: true }) .eq("session_id", sessionId) + .eq("storyline_id", storylineId) .gte("viewed_at", oneHourAgo); if (countError) return error(`Database error: ${countError.message}`, 500); diff --git a/supabase/migrations/00011_session_rate_limit_index.sql b/supabase/migrations/00011_session_rate_limit_index.sql new file mode 100644 index 00000000..18ec3aad --- /dev/null +++ b/supabase/migrations/00011_session_rate_limit_index.sql @@ -0,0 +1,3 @@ +-- Index for durable session-based rate limiting on page_views +CREATE INDEX IF NOT EXISTS idx_page_views_session_rate + ON page_views(session_id, viewed_at);