aboutsummaryrefslogtreecommitdiff
path: root/src/lib/Error/rateLimit.ts
blob: d3508aba0ac2459bcac37fb99c31b9457a08da93 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import type { RequestEvent } from "@sveltejs/kit";
import {
	RateLimiter,
	type RateLimiterStore,
} from "sveltekit-rate-limiter/server";

// Storage. The library's default store is in-memory and PER INSTANCE. On
// Vercel's serverless runtime, counters do not aggregate across lambda
// instances or regions, so these limiters are best-effort backstops there.
// Provision a shared store (Upstash Redis / Vercel KV) implementing
// RateLimiterStore and return it here for durable limits. Coarse per-IP abuse
// protection also runs at the edge via the Vercel WAF (see
// docs/vercel-firewall.md); these app-level limiters add what the WAF cannot
// express — per-user windows and the per-(IP, badge) click counter.
const createStore = (): RateLimiterStore | undefined => {
	// e.g. `if (env.UPSTASH_REDIS_REST_URL) return new UpstashStore(...)`
	return undefined;
};

const store = createStore();

// Module-level singletons: the counters must persist across requests. (The
// previous implementation built a new RateLimiter on every call, so its store
// was always empty and it never actually limited anything.)
const limiters = {
	auth: new RateLimiter({ IP: [10, "m"], store }),
	mutation: new RateLimiter({ IP: [30, "m"], store }),
	read: new RateLimiter({ IP: [60, "m"], store }),
} satisfies Record<string, RateLimiter>;

export type RateLimitClass = keyof typeof limiters;

const tooManyRequests = () =>
	new Response("Too Many Requests", { status: 429 });

export const checkRateLimit = async (
	event: RequestEvent,
	limit: RateLimitClass = "mutation",
): Promise<Response | null> =>
	(await limiters[limit].isLimited(event)) ? tooManyRequests() : null;

// Click counter: bound inflation of a single badge without throttling a viewer
// browsing many DIFFERENT badges. Keyed on (IP, badge) — which the edge WAF
// can't express, since it keys on IP alone.
const clickCounterLimiter = new RateLimiter({
	store,
	plugins: [
		{
			rate: [2, "m"],
			hash: async (event) => {
				const badgeId = event.url.searchParams.get("incrementClickCount");

				return badgeId ? `click:${event.getClientAddress()}:${badgeId}` : true;
			},
		},
	],
});

export const checkClickCounterLimit = async (
	event: RequestEvent,
): Promise<Response | null> =>
	(await clickCounterLimiter.isLimited(event)) ? tooManyRequests() : null;