| 1 | import { NextResponse, type NextRequest } from "next/server"; |
| 2 | |
| 3 | |
| 4 | const WINDOW_MS = 60_000; |
| 5 | const MAX_REQ = 120; |
| 6 | |
| 7 | type Bucket = { count: number; resetAt: number }; |
| 8 | const g = globalThis as unknown as { __docketBuckets?: Map<string, Bucket> }; |
| 9 | const buckets: Map<string, Bucket> = (g.__docketBuckets ??= new Map()); |
| 10 | |
| 11 | function clientIp(req: NextRequest): string { |
| 12 | const cf = req.headers.get("cf-connecting-ip"); |
| 13 | if (cf) return cf.trim(); |
| 14 | const real = req.headers.get("x-real-ip"); |
| 15 | if (real) return real.trim(); |
| 16 | const xff = req.headers.get("x-forwarded-for"); |
| 17 | if (xff) return xff.split(",")[0].trim(); |
| 18 | return "unknown"; |
| 19 | } |
| 20 | |
| 21 | export function middleware(req: NextRequest) { |
| 22 | if (buckets.size > 10_000) { |
| 23 | const now = Date.now(); |
| 24 | for (const [k, b] of buckets) if (b.resetAt < now) buckets.delete(k); |
| 25 | } |
| 26 | |
| 27 | const ip = clientIp(req); |
| 28 | const now = Date.now(); |
| 29 | const b = buckets.get(ip); |
| 30 | |
| 31 | if (!b || b.resetAt < now) { |
| 32 | buckets.set(ip, { count: 1, resetAt: now + WINDOW_MS }); |
| 33 | return NextResponse.next(); |
| 34 | } |
| 35 | |
| 36 | b.count += 1; |
| 37 | if (b.count > MAX_REQ) { |
| 38 | const retry = Math.ceil((b.resetAt - now) / 1000); |
| 39 | return new NextResponse( |
| 40 | JSON.stringify({ error: "Rate limit exceeded. Slow down." }), |
| 41 | { |
| 42 | status: 429, |
| 43 | headers: { |
| 44 | "Content-Type": "application/json", |
| 45 | "Retry-After": String(retry), |
| 46 | "X-RateLimit-Limit": String(MAX_REQ), |
| 47 | "X-RateLimit-Remaining": "0", |
| 48 | }, |
| 49 | } |
| 50 | ); |
| 51 | } |
| 52 | return NextResponse.next(); |
| 53 | } |
| 54 | |
| 55 | export const config = { |
| 56 | matcher: ["/api/:path*"], |
| 57 | }; |