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 | "repo-create"
17 | "issue-create"
18 | "patch-create"
19 | "label-write"
20 | "release-write"
21 | "file-edit";
22
23const buckets = new Map<string, Bucket>();
24
25function sweep() {
26 const now = Date.now();
27 for (const [key, bucket] of buckets) {
28 if (now > bucket.resetAt) buckets.delete(key);
29 }
30}
31
32// Periodic sweep so memory doesn't grow unboundedly when traffic is low
33// and the request-driven sweep never reaches its threshold.
34setInterval(sweep, 60 * 1000).unref();
35
36let sweepCounter = 0;
37function maybeSweep() {
38 if (++sweepCounter < 1000) return;
39 sweepCounter = 0;
40 sweep();
41}
42
43export function checkRateLimit(
44 ip: string | null,
45 kind: RateLimitKind,
46 maxRequests: number,
47 windowMs: number,
48): boolean {
49 if (config.RATE_LIMIT_DISABLED || !ip) return true;
50 const key = `${ip}|${kind}`;
51 const now = Date.now();
52 const bucket = buckets.get(key);
53 if (!bucket || now > bucket.resetAt) {
54 buckets.set(key, { count: 1, resetAt: now + windowMs });
55 maybeSweep();
56 return true;
57 }
58 if (bucket.count >= maxRequests) return false;
59 bucket.count++;
60 return true;
61}
62
63export function getClientIp(
64 request: Request,
65 server: Bun.Server<unknown> | null,
66): string | null {
67 if (config.TRUSTED_PROXY) {
68 return (
69 request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
70 null
71 );
72 }
73 return server?.requestIP(request)?.address ?? null;
74}
75
76/**
77 * Rate-limit a mutating handler. Returns null if the request is allowed,
78 * or a 429 Response if it isn't. The bucket is keyed on the user id when
79 * available (so a single attacker can't bypass by rotating source IPs)
80 * and on the IP when not.
81 */
82export function rateLimit(
83 request: Request,
84 server: Bun.Server<unknown> | null,
85 userId: number | null,
86 kind: RateLimitKind,
87 maxRequests: number,
88 windowMs: number,
89): Response | null {
90 const key = userId !== null ? `u${userId}` : getClientIp(request, server);
91 if (checkRateLimit(key, kind, maxRequests, windowMs)) return null;
92 return new Response("Too many requests. Please slow down.", {
93 status: 429,
94 headers: { "Content-Type": "text/plain; charset=utf-8" },
95 });
96}
97