fix: grouped pathnames for rate limit

This commit is contained in:
trafficlunar 2025-07-26 22:45:07 +01:00
parent 701f038971
commit 78320fdd56
6 changed files with 16 additions and 12 deletions

View file

@ -14,7 +14,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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(); const check = await rateLimit.handle();
if (check) return check; if (check) return check;

View file

@ -29,8 +29,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// todo: rate limit by mii const rateLimit = new RateLimit(request, 1); // no grouped pathname; edit each mii 1 time a minute
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle(); const check = await rateLimit.handle();
if (check) return check; if (check) return check;

View file

@ -9,7 +9,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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(); const check = await rateLimit.handle();
if (check) return check; if (check) return check;

View file

@ -19,7 +19,7 @@ const searchParamsSchema = z.object({
}); });
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 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(); const check = await rateLimit.handle();
if (check) return check; if (check) return check;

View file

@ -7,7 +7,7 @@ import { idSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 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(); const check = await rateLimit.handle();
if (check) return check; if (check) return check;

View file

@ -16,11 +16,13 @@ interface RateLimitData {
export class RateLimit { export class RateLimit {
private request: NextRequest; private request: NextRequest;
private maxRequests: number; 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; private data: RateLimitData;
constructor(request: NextRequest, maxRequests: number) { constructor(request: NextRequest, maxRequests: number, pathname?: string) {
this.request = request; this.request = request;
this.maxRequests = maxRequests; this.maxRequests = maxRequests;
this.pathname = pathname ? pathname : this.request.nextUrl.pathname;
this.data = { this.data = {
success: true, success: true,
limit: maxRequests, limit: maxRequests,
@ -31,8 +33,7 @@ export class RateLimit {
// Check and update rate limit // Check and update rate limit
async check(identifier: string): Promise<RateLimitData> { async check(identifier: string): Promise<RateLimitData> {
const pathname = this.request.nextUrl.pathname; const key = `ratelimit:${this.pathname}:${identifier}`;
const key = `ratelimit:${pathname}:${identifier}`;
const now = Date.now(); const now = Date.now();
const seconds = Math.floor(now / 1000); const seconds = Math.floor(now / 1000);
@ -46,8 +47,12 @@ export class RateLimit {
tx.expireat(key, expireAt); tx.expireat(key, expireAt);
// Execute transaction and get the count // 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 success = count <= this.maxRequests;
const remaining = Math.max(0, this.maxRequests - count); const remaining = Math.max(0, this.maxRequests - count);
@ -55,7 +60,7 @@ export class RateLimit {
} catch (error) { } catch (error) {
console.error("Rate limit check failed", error); console.error("Rate limit check failed", error);
return { return {
success: true, success: false,
limit: this.maxRequests, limit: this.maxRequests,
remaining: this.maxRequests, remaining: this.maxRequests,
expires: expireAt, expires: expireAt,
@ -84,7 +89,7 @@ export class RateLimit {
async handle(): Promise<NextResponse<object | unknown> | undefined> { async handle(): Promise<NextResponse<object | unknown> | undefined> {
const session = await auth(); const session = await auth();
const ip = this.request.headers.get("CF-Connecting-IP") || this.request.headers.get("X-Forwarded-For")?.split(",")[0]; 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); this.data = await this.check(identifier);