karbe/src/lib/rate-limit.ts
Claude Integration a373bd60ad
All checks were successful
CI / test (pull_request) Successful in 2m10s
feat(hardening): rate limit (signup/reset/bookings) + tâches cron + backup PostgreSQL nocturne
2026-06-01 20:16:57 +00:00

86 lines
2.2 KiB
TypeScript

/**
* Token-bucket en mémoire — best-effort par instance.
*
* Pour un déploiement multi-instance, swap pour un store partagé (Redis).
* Ici on tourne en mono-instance Next derrière nginx-proxy-manager, donc
* une Map locale suffit.
*
* Usage :
* const r = await rateLimit({ key: ip + ":signup", windowMs: 60_000, limit: 5 });
* if (!r.ok) return tooManyRequests(r.retryAfter);
*/
type Bucket = {
count: number;
resetAt: number;
};
const buckets = new Map<string, Bucket>();
const SWEEP_INTERVAL_MS = 60_000;
let lastSweep = 0;
function sweep(now: number) {
if (now - lastSweep < SWEEP_INTERVAL_MS) return;
lastSweep = now;
for (const [k, b] of buckets) {
if (b.resetAt <= now) buckets.delete(k);
}
}
export type RateLimitOpts = {
key: string;
/** Fenêtre glissante en ms. */
windowMs: number;
/** Nombre max d'appels par fenêtre. */
limit: number;
};
export type RateLimitResult = {
ok: boolean;
remaining: number;
retryAfter: number; // secondes
};
export function rateLimit(opts: RateLimitOpts): RateLimitResult {
const now = Date.now();
sweep(now);
const b = buckets.get(opts.key);
if (!b || b.resetAt <= now) {
buckets.set(opts.key, { count: 1, resetAt: now + opts.windowMs });
return { ok: true, remaining: opts.limit - 1, retryAfter: 0 };
}
if (b.count >= opts.limit) {
return {
ok: false,
remaining: 0,
retryAfter: Math.max(1, Math.ceil((b.resetAt - now) / 1000)),
};
}
b.count++;
return { ok: true, remaining: opts.limit - b.count, retryAfter: 0 };
}
/** Extract a client IP from a request, fallback to a safe default. */
export function getClientIp(req: Request): string {
// nginx-proxy-manager pose x-forwarded-for, x-real-ip
const xff = req.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
const xri = req.headers.get("x-real-ip");
if (xri) return xri.trim();
return "unknown";
}
/** Helper pratique : extract IP + applique le bucket. */
export function rateLimitRequest(
req: Request,
bucket: string,
windowMs: number,
limit: number,
): RateLimitResult {
const ip = getClientIp(req);
return rateLimit({ key: `${ip}:${bucket}`, windowMs, limit });
}