From 78320fdd5643631f9d11e2ec0fa08f5181af1cc8 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Sat, 26 Jul 2025 22:45:07 +0100 Subject: [PATCH] fix: grouped pathnames for rate limit --- src/app/api/mii/[id]/delete/route.ts | 2 +- src/app/api/mii/[id]/edit/route.ts | 3 +-- src/app/api/mii/[id]/like/route.ts | 2 +- src/app/mii/[id]/image/route.ts | 2 +- src/app/profile/[id]/picture/route.ts | 2 +- src/lib/rate-limit.ts | 17 +++++++++++------ 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/app/api/mii/[id]/delete/route.ts b/src/app/api/mii/[id]/delete/route.ts index c737777..39ae42e 100644 --- a/src/app/api/mii/[id]/delete/route.ts +++ b/src/app/api/mii/[id]/delete/route.ts @@ -14,7 +14,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - const rateLimit = new RateLimit(request, 10); + const rateLimit = new RateLimit(request, 30, "/api/mii/delete"); const check = await rateLimit.handle(); if (check) return check; diff --git a/src/app/api/mii/[id]/edit/route.ts b/src/app/api/mii/[id]/edit/route.ts index 2a3f8e6..6e42dc8 100644 --- a/src/app/api/mii/[id]/edit/route.ts +++ b/src/app/api/mii/[id]/edit/route.ts @@ -29,8 +29,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - // todo: rate limit by mii - const rateLimit = new RateLimit(request, 3); + const rateLimit = new RateLimit(request, 1); // no grouped pathname; edit each mii 1 time a minute const check = await rateLimit.handle(); if (check) return check; diff --git a/src/app/api/mii/[id]/like/route.ts b/src/app/api/mii/[id]/like/route.ts index e50ef47..3cba66c 100644 --- a/src/app/api/mii/[id]/like/route.ts +++ b/src/app/api/mii/[id]/like/route.ts @@ -9,7 +9,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - const rateLimit = new RateLimit(request, 100); + const rateLimit = new RateLimit(request, 100, "/api/mii/like"); const check = await rateLimit.handle(); if (check) return check; diff --git a/src/app/mii/[id]/image/route.ts b/src/app/mii/[id]/image/route.ts index c361d82..050cff9 100644 --- a/src/app/mii/[id]/image/route.ts +++ b/src/app/mii/[id]/image/route.ts @@ -19,7 +19,7 @@ const searchParamsSchema = z.object({ }); export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const rateLimit = new RateLimit(request, 200); + const rateLimit = new RateLimit(request, 200, "/mii/image"); const check = await rateLimit.handle(); if (check) return check; diff --git a/src/app/profile/[id]/picture/route.ts b/src/app/profile/[id]/picture/route.ts index 6084517..3f15f22 100644 --- a/src/app/profile/[id]/picture/route.ts +++ b/src/app/profile/[id]/picture/route.ts @@ -7,7 +7,7 @@ import { idSchema } from "@/lib/schemas"; import { RateLimit } from "@/lib/rate-limit"; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const rateLimit = new RateLimit(request, 16); + const rateLimit = new RateLimit(request, 16, "/profile/picture"); const check = await rateLimit.handle(); if (check) return check; diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index c690121..6a335e8 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -16,11 +16,13 @@ interface RateLimitData { export class RateLimit { private request: NextRequest; private maxRequests: number; + private pathname: string; // instead of using the request's pathname, use this custom one to group all routes together private data: RateLimitData; - constructor(request: NextRequest, maxRequests: number) { + constructor(request: NextRequest, maxRequests: number, pathname?: string) { this.request = request; this.maxRequests = maxRequests; + this.pathname = pathname ? pathname : this.request.nextUrl.pathname; this.data = { success: true, limit: maxRequests, @@ -31,8 +33,7 @@ export class RateLimit { // Check and update rate limit async check(identifier: string): Promise { - const pathname = this.request.nextUrl.pathname; - const key = `ratelimit:${pathname}:${identifier}`; + const key = `ratelimit:${this.pathname}:${identifier}`; const now = Date.now(); const seconds = Math.floor(now / 1000); @@ -46,8 +47,12 @@ export class RateLimit { tx.expireat(key, expireAt); // Execute transaction and get the count - const [count] = (await tx.exec().then((results) => results?.map((res) => res[1]))) as [number]; + const results = await tx.exec(); + if (!results) { + throw new Error("Redis transaction failed"); + } + const count = results[0][1] as number; const success = count <= this.maxRequests; const remaining = Math.max(0, this.maxRequests - count); @@ -55,7 +60,7 @@ export class RateLimit { } catch (error) { console.error("Rate limit check failed", error); return { - success: true, + success: false, limit: this.maxRequests, remaining: this.maxRequests, expires: expireAt, @@ -84,7 +89,7 @@ export class RateLimit { async handle(): Promise | undefined> { const session = await auth(); const ip = this.request.headers.get("CF-Connecting-IP") || this.request.headers.get("X-Forwarded-For")?.split(",")[0]; - const identifier = (session ? session.user.id : ip) ?? "null"; + const identifier = (session ? session.user.id : ip) ?? "anonymous"; this.data = await this.check(identifier);