perf: add bans.user_id index, Redis cache layer, rate-limit improvements, radio contest/giveaway columns, and tests
- Add DB index on bans.user_id to speed up per-request ban lookups (migration 0008) - Replace in-process rate limiter with Redis-backed implementation with in-memory fallback - Add Redis caching layer for site settings with TTL invalidation (migration 0009) - Add rate limiting to resetPassword to prevent token brute-force attacks - Update all rateLimit callers to await the now-async function - Flesh out RadioContests and RadioGiveaways models with title, description, prize, date, and winner columns - Update radio contest/giveaway pages to display new fields - Add tests for rate limiter (4 tests) and password-reset actions (3 tests) - Add REDIS_URL environment variable (optional, falls back to in-memory)
This commit is contained in:
1 parent
43c0ba6614
commit
5c638cd6bc
25 files changed
+449
-63
No files matched your search
+29
-10
@@ -1,12 +1,11 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
/**
|
||||
* Tiny in-process fixed-window rate limiter for abuse-prone server actions
|
||||
* (register, password reset, login). It's per-node (not shared across
|
||||
* instances) — fine for a single-server retro hotel; swap for Redis if you
|
||||
* ever scale out. Keys are typically `${action}:${ip}`.
|
||||
* Fixed-window rate limiter with optional Redis backend. Falls back to in-process
|
||||
* Map when Redis is unavailable or unconfigured — fine for single-server deployments.
|
||||
*
|
||||
* Periodic cleanup runs every 5 minutes to keep the map bounded.
|
||||
* Periodic cleanup runs every 5 minutes to keep the in-process map bounded.
|
||||
*/
|
||||
type Bucket = { count: number; resetAt: number };
|
||||
const buckets = new Map<string, Bucket>();
|
||||
@@ -17,19 +16,18 @@ export interface RateLimitResult {
|
||||
retryAfter: number;
|
||||
}
|
||||
|
||||
let lastCleanup = Date.now();
|
||||
const CLEANUP_INTERVAL_MS = 300_000; // 5 min
|
||||
const CLEANUP_INTERVAL_MS = 300_000;
|
||||
const MAX_BUCKETS = 10_000;
|
||||
|
||||
let lastCleanup = Date.now();
|
||||
|
||||
function cleanup(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
|
||||
lastCleanup = now;
|
||||
if (buckets.size <= MAX_BUCKETS) {
|
||||
// Quick eviction of completely expired entries
|
||||
for (const [k, b] of buckets) if (now >= b.resetAt) buckets.delete(k);
|
||||
} else {
|
||||
// Aggressive: clear all expired, then delete oldest 20% if still too large
|
||||
for (const [k, b] of buckets) if (now >= b.resetAt) buckets.delete(k);
|
||||
if (buckets.size > MAX_BUCKETS) {
|
||||
const sorted = [...buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt);
|
||||
@@ -39,8 +37,29 @@ function cleanup(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
|
||||
export async function rateLimit(
|
||||
key: string,
|
||||
limit: number,
|
||||
windowMs: number,
|
||||
): Promise<RateLimitResult> {
|
||||
const now = Date.now();
|
||||
|
||||
if (redis) {
|
||||
try {
|
||||
const windowKey = `ratelimit:${key}`;
|
||||
const windowSec = Math.ceil(windowMs / 1000);
|
||||
const current = await redis.incr(windowKey);
|
||||
if (current === 1) await redis.pexpire(windowKey, windowMs);
|
||||
const ttl = current === 1 ? windowMs : Math.max(0, await redis.pttl(windowKey));
|
||||
if (current > limit) {
|
||||
return { ok: false, retryAfter: Math.ceil(ttl / 1000) };
|
||||
}
|
||||
return { ok: true, retryAfter: 0 };
|
||||
} catch {
|
||||
// Redis unavailable — fall through to in-memory
|
||||
}
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
||||
const bucket = buckets.get(key);
|
||||
|
||||
Reference in new issue
Block a user