Skip to content
Merged
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
48 changes: 47 additions & 1 deletion src/app/api/views/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 @@ -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
Expand Down
Loading