From 3ef964932281a0a3514b71dd475c52c7899a830b Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sat, 25 Apr 2026 14:29:57 +0800 Subject: [PATCH] feat(api): add application-level rate limiting middleware with IP-based tracking --- app/api/[[...slugs]]/route.ts | 2 + lib/auth/rate-limit.ts | 78 +++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 lib/auth/rate-limit.ts diff --git a/app/api/[[...slugs]]/route.ts b/app/api/[[...slugs]]/route.ts index 48dd0a5..ef1fa57 100644 --- a/app/api/[[...slugs]]/route.ts +++ b/app/api/[[...slugs]]/route.ts @@ -1,5 +1,6 @@ import { Elysia } from "elysia" import { auth } from "@/lib/auth" +import { rateLimit } from "@/lib/auth/rate-limit" import { healthRoutes } from "@/lib/api/health" const betterAuth = new Elysia({ name: "better-auth" }) @@ -31,6 +32,7 @@ export const app = new Elysia({ prefix: "/api" }) error: code === "NOT_FOUND" ? "Not found" : "Internal server error", } }) + .use(rateLimit(60, 100)) .use(betterAuth) .use(healthRoutes) .get("/", () => ({ diff --git a/lib/auth/rate-limit.ts b/lib/auth/rate-limit.ts new file mode 100644 index 0000000..d55f7e2 --- /dev/null +++ b/lib/auth/rate-limit.ts @@ -0,0 +1,78 @@ +import { Elysia } from "elysia" + +type RateLimitEntry = { + count: number + resetAt: number +} + +const store = new Map() + +// Clean up expired entries every 60 seconds +setInterval(() => { + const now = Date.now() + for (const [key, entry] of store) { + if (now > entry.resetAt) { + store.delete(key) + } + } +}, 60_000) + +function getClientIP(request: Request): string { + const forwarded = request.headers.get("x-forwarded-for") + if (forwarded) { + return forwarded.split(",")[0].trim() + } + return "unknown" +} + +function checkRateLimit( + key: string, + window: number, + max: number, +): { allowed: boolean; remaining: number; resetAt: number } { + const now = Date.now() + const entry = store.get(key) + + if (!entry || now > entry.resetAt) { + const resetAt = now + window * 1000 + store.set(key, { count: 1, resetAt }) + return { allowed: true, remaining: max - 1, resetAt } + } + + if (entry.count >= max) { + return { allowed: false, remaining: 0, resetAt: entry.resetAt } + } + + entry.count++ + return { allowed: true, remaining: max - entry.count, resetAt: entry.resetAt } +} + +export const rateLimit = ( + window: number = 60, + max: number = 100, +) => + new Elysia({ name: "rate-limit" }).onRequest(({ request, set }) => { + const ip = getClientIP(request) + const path = new URL(request.url).pathname + const key = `${ip}:${path}` + + const result = checkRateLimit(key, window, max) + + if (!result.allowed) { + const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000) + set.status = 429 + set.headers["Retry-After"] = String(retryAfter) + return { + error: "Too many requests", + retryAfter, + } + } + + // These headers are informational — clients can use them to throttle + set.headers = { + ...set.headers, + "X-RateLimit-Limit": String(max), + "X-RateLimit-Remaining": String(result.remaining), + "X-RateLimit-Reset": String(Math.ceil(result.resetAt / 1000)), + } + })