Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 17 additions & 40 deletions src/app/api/views/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { count: number; resetAt: number }>();

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
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions supabase/migrations/00011_session_rate_limit_index.sql
Original file line number Diff line number Diff line change
@@ -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);
Loading