diff --git a/src/app/api/views/route.ts b/src/app/api/views/route.ts index 7d1895f7..5202bdff 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,29 @@ 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 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); + + 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") 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);