Files
Epicnabbo-Catalogus-Updated…/src/lib/rate-limit.ts
T
SimoandCursor c46dadeda4 chore: harden deps, env validation, admin errors, and redis warnings
Align nodemailer with Auth.js peers, bump patch deps, validate env on deploy builds, add admin error boundary, and warn when Redis is missing in production.

Co-authored-by: Cursor <[email protected]>
2026-07-21 20:19:05 +02:00

103 lines
2.4 KiB
TypeScript

import { headers } from "next/headers";
import { redis } from "@/lib/redis";
type Bucket = { count: number; resetAt: number };
const buckets = new Map<string, Bucket>();
export interface RateLimitResult {
ok: boolean;
retryAfter: number;
}
const CLEANUP_INTERVAL_MS = 300_000;
const MAX_BUCKETS = 10_000;
let lastCleanup = Date.now();
let redisFailWarned = false;
function cleanup(): void {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
lastCleanup = now;
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,
);
const keysToRemove = sorted
.slice(0, Math.floor(sorted.length * 0.2))
.map((entry) => entry[0]);
for (const key of keysToRemove) buckets.delete(key);
}
}
export async function rateLimit(
key: string,
limit: number,
windowMs: number,
): Promise<RateLimitResult> {
const now = Date.now();
if (redis) {
try {
const windowKey = `ratelimit:${key}`;
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
if (process.env.NODE_ENV === "production" && !redisFailWarned) {
redisFailWarned = true;
console.error(
"[rate-limit] Redis error — falling back to in-process buckets. Limits are not shared across instances until Redis recovers.",
);
}
}
}
cleanup();
const windowKey = `mem:${key}`;
const bucket = buckets.get(windowKey);
if (!bucket || now >= bucket.resetAt) {
buckets.set(windowKey, { count: 1, resetAt: now + windowMs });
return { ok: true, retryAfter: 0 };
}
const newCount = bucket.count + 1;
if (newCount > limit) {
return {
ok: false,
retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
};
}
bucket.count = newCount;
return { ok: true, retryAfter: 0 };
}
export async function clientIp(): Promise<string> {
try {
const h = await headers();
return (
h.get("x-real-client-ip") ??
h.get("cf-connecting-ip") ??
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
h.get("x-real-ip") ??
"0.0.0.0"
);
} catch {
return "0.0.0.0";
}
}