86 lines
2.2 KiB
TypeScript
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 });
|
|
}
|