rateLimiter.ts
⎇
Raw
1import config from "../config.ts";
2
3interface Bucket {
4 count: number;
5 resetAt: number;
6}
7
8export type RateLimitKind =
9 | "login"
10 | "passkey"
11 | "git-auth"
12 | "comment"
13 | "reaction"
14 | "upload"
15 | "register"
16 | "issue-create"
17 | "patch-create"
18 | "label-write";
19
20const buckets = new Map<string, Bucket>();
21
22function sweep() {
23 const now = Date.now();
24 for (const [key, bucket] of buckets) {
25 if (now > bucket.resetAt) buckets.delete(key);
26 }
27}
28
29// Periodic sweep so expired buckets do not accumulate.
30setInterval(sweep, 60 * 1000).unref();
31
32export function checkRateLimit(
33 ip: string | null,
34 kind: RateLimitKind,
35 maxRequests: number,
36 windowMs: number,
37): boolean {
38 if (config.RATE_LIMIT_DISABLED || !ip) return true;
39 const key = `${ip}|${kind}`;
40 const now = Date.now();
41 const bucket = buckets.get(key);
42 if (!bucket || now > bucket.resetAt) {
43 buckets.set(key, { count: 1, resetAt: now + windowMs });
44 return true;
45 }
46 if (bucket.count >= maxRequests) return false;
47 bucket.count++;
48 return true;
49}
50
51export function getClientIp(
52 request: Request,
53 server: Bun.Server<unknown> | null,
54): string | null {
55 if (config.TRUSTED_PROXY) {
56 return (
57 request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
58 null
59 );
60 }
61 return server?.requestIP(request)?.address ?? null;
62}
63
64/**
65 * Rate-limit a mutating handler. Returns null if the request is allowed,
66 * or a 429 Response if it isn't. The bucket is keyed on the user id when
67 * available (so a single attacker can't bypass by rotating source IPs)
68 * and on the IP when not.
69 */
70export function rateLimit(
71 request: Request,
72 server: Bun.Server<unknown> | null,
73 userId: number | null,
74 kind: RateLimitKind,
75 maxRequests: number,
76 windowMs: number,
77): Response | null {
78 const key = userId !== null ? `u${userId}` : getClientIp(request, server);
79 if (checkRateLimit(key, kind, maxRequests, windowMs)) return null;
80 return new Response("Too many requests. Please slow down.", {
81 status: 429,
82 headers: { "Content-Type": "text/plain; charset=utf-8" },
83 });
84}
85