feat: rate limiting api routes

This commit is contained in:
trafficlunar 2025-04-28 21:30:16 +01:00
parent 50c18a342d
commit 2c13f60a50
13 changed files with 252 additions and 49 deletions

View file

@ -1,20 +1,25 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit";
export async function DELETE() {
export async function DELETE(request: NextRequest) {
const session = await auth();
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 1);
const check = await rateLimit.handle();
if (check) return check;
try {
await prisma.user.delete({
where: { id: Number(session.user.id) },
});
} catch (error) {
console.error("Failed to delete user:", error);
return NextResponse.json({ error: "Failed to delete account" }, { status: 500 });
return rateLimit.sendResponse({ error: "Failed to delete account" }, 500);
}
return NextResponse.json({ success: true });
return rateLimit.sendResponse({ success: true });
}

View file

@ -4,19 +4,24 @@ import { profanity } from "@2toad/profanity";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { displayNameSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 1);
const check = await rateLimit.handle();
if (check) return check;
const { displayName } = await request.json();
if (!displayName) return NextResponse.json({ error: "New display name is required" }, { status: 400 });
if (!displayName) return rateLimit.sendResponse({ error: "New display name is required" }, 400);
const validation = displayNameSchema.safeParse(displayName);
if (!validation.success) return NextResponse.json({ error: validation.error.errors[0].message }, { status: 400 });
if (!validation.success) return rateLimit.sendResponse({ error: validation.error.errors[0].message }, 400);
// Check for inappropriate words
if (profanity.exists(displayName)) return NextResponse.json({ error: "Display name contains inappropriate words" }, { status: 400 });
if (profanity.exists(displayName)) return rateLimit.sendResponse({ error: "Display name contains inappropriate words" }, 400);
try {
await prisma.user.update({
@ -25,8 +30,8 @@ export async function PATCH(request: NextRequest) {
});
} catch (error) {
console.error("Failed to update display name:", error);
return NextResponse.json({ error: "Failed to update display name" }, { status: 500 });
return rateLimit.sendResponse({ error: "Failed to update display name" }, 500);
}
return NextResponse.json({ success: true });
return rateLimit.sendResponse({ success: true });
}

View file

@ -6,13 +6,18 @@ import { profanity } from "@2toad/profanity";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { usernameSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 1);
const check = await rateLimit.handle();
if (check) return check;
const { username } = await request.json();
if (!username) return NextResponse.json({ error: "New username is required" }, { status: 400 });
if (!username) return rateLimit.sendResponse({ error: "New username is required" }, 400);
// Check if username was updated in the last 90 days
const user = await prisma.user.findUnique({ where: { email: session.user?.email ?? undefined } });
@ -20,17 +25,17 @@ export async function PATCH(request: NextRequest) {
const timePeriod = dayjs().subtract(90, "days");
const lastUpdate = dayjs(user.usernameUpdatedAt);
if (lastUpdate.isAfter(timePeriod)) return NextResponse.json({ error: "Username was changed in the last 90 days" }, { status: 400 });
if (lastUpdate.isAfter(timePeriod)) return rateLimit.sendResponse({ error: "Username was changed in the last 90 days" }, 400);
}
const validation = usernameSchema.safeParse(username);
if (!validation.success) return NextResponse.json({ error: validation.error.errors[0].message }, { status: 400 });
if (!validation.success) return rateLimit.sendResponse({ error: validation.error.errors[0].message }, 400);
// Check for inappropriate words
if (profanity.exists(username)) return NextResponse.json({ error: "Username contains inappropriate words" }, { status: 400 });
if (profanity.exists(username)) return rateLimit.sendResponse({ error: "Username contains inappropriate words" }, 400);
const existingUser = await prisma.user.findUnique({ where: { username } });
if (existingUser) return NextResponse.json({ error: "Username is already taken" }, { status: 400 });
if (existingUser) return rateLimit.sendResponse({ error: "Username is already taken" }, 400);
try {
await prisma.user.update({
@ -39,8 +44,8 @@ export async function PATCH(request: NextRequest) {
});
} catch (error) {
console.error("Failed to update username:", error);
return NextResponse.json({ error: "Failed to update username" }, { status: 500 });
return rateLimit.sendResponse({ error: "Failed to update username" }, 500);
}
return NextResponse.json({ success: true });
return rateLimit.sendResponse({ success: true });
}

View file

@ -6,6 +6,7 @@ import path from "path";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { idSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
const uploadsDirectory = path.join(process.cwd(), "public", "mii");
@ -13,10 +14,14 @@ 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 check = await rateLimit.handle();
if (check) return check;
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return NextResponse.json({ error: parsed.error.errors[0].message }, { status: 400 });
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.errors[0].message }, 400);
const miiId = parsed.data;
const miiUploadsDirectory = path.join(uploadsDirectory, miiId.toString());
@ -27,7 +32,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
});
} catch (error) {
console.error("Failed to delete Mii from database:", error);
return NextResponse.json({ error: "Failed to delete Mii" }, { status: 500 });
return rateLimit.sendResponse({ error: "Failed to delete Mii" }, 500);
}
try {
@ -36,5 +41,5 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
console.warn("Failed to delete Mii image files:", error);
}
return NextResponse.json({ success: true });
return rateLimit.sendResponse({ success: true });
}

View file

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { Mii } from "@prisma/client";
@ -11,8 +11,8 @@ import { profanity } from "@2toad/profanity";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { idSchema, nameSchema, tagsSchema } from "@/lib/schemas";
import { validateImage } from "@/lib/images";
import { RateLimit } from "@/lib/rate-limit";
const uploadsDirectory = path.join(process.cwd(), "public", "mii");
@ -24,15 +24,19 @@ const editSchema = z.object({
image3: z.union([z.instanceof(File), z.any()]).optional(),
});
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
if (check) return check;
// Get Mii ID
const { id: slugId } = await params;
const parsedId = idSchema.safeParse(slugId);
if (!parsedId.success) return NextResponse.json({ error: parsedId.error.errors[0].message }, { status: 400 });
if (!parsedId.success) return rateLimit.sendResponse({ error: parsedId.error.errors[0].message }, 400);
const miiId = parsedId.data;
// Check ownership of Mii
@ -42,8 +46,8 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
},
});
if (!mii) return NextResponse.json({ error: "Mii not found" }, { status: 404 });
if (Number(session.user.id) !== mii.userId) return NextResponse.json({ error: "You don't have ownership of that Mii" }, { status: 403 });
if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404);
if (Number(session.user.id) !== mii.userId) return rateLimit.sendResponse({ error: "You don't have ownership of that Mii" }, 403);
// Parse form data
const formData = await request.formData();
@ -53,7 +57,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const value = formData.get("tags");
if (value) rawTags = JSON.parse(value as string);
} catch {
return NextResponse.json({ error: "Invalid JSON in tags" }, { status: 400 });
return rateLimit.sendResponse({ error: "Invalid JSON in tags" }, 400);
}
const parsed = editSchema.safeParse({
@ -64,7 +68,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
image3: formData.get("image3"),
});
if (!parsed.success) return NextResponse.json({ error: parsed.error.errors[0].message }, { status: 400 });
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.errors[0].message }, 400);
const { name, tags, image1, image2, image3 } = parsed.data;
// Validate image files
@ -77,7 +81,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
if (imageValidation.valid) {
images.push(img);
} else {
return NextResponse.json({ error: imageValidation.error }, { status: imageValidation.status ?? 400 });
return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
}
}
@ -87,7 +91,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
if (tags !== undefined) updateData.tags = tags.map((t) => profanity.censor(t)); // Same here
if (images.length > 0) updateData.imageCount = images.length;
if (Object.keys(updateData).length == 0) return NextResponse.json({ error: "Nothing was changed" }, { status: 400 });
if (Object.keys(updateData).length == 0) return rateLimit.sendResponse({ error: "Nothing was changed" }, 400);
await prisma.mii.update({
where: {
id: miiId,
@ -118,9 +122,9 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
);
} catch (error) {
console.error("Error uploading user images:", error);
return NextResponse.json({ error: "Failed to store user images" }, { status: 500 });
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
}
}
return NextResponse.json({ success: true });
return rateLimit.sendResponse({ success: true });
}

View file

@ -3,15 +3,20 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { idSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 100);
const check = await rateLimit.handle();
if (check) return check;
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return NextResponse.json({ error: parsed.error.errors[0].message }, { status: 400 });
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.errors[0].message }, 400);
const miiId = parsed.data;
const result = await prisma.$transaction(async (tx) => {
@ -51,5 +56,5 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
return { liked: !existingLike, count: likeCount };
});
return NextResponse.json({ success: true, liked: result.liked, count: result.count });
return rateLimit.sendResponse({ success: true, liked: result.liked, count: result.count });
}

View file

@ -5,6 +5,7 @@ import { z } from "zod";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { querySchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
const searchSchema = z.object({
q: querySchema.optional(),
@ -42,8 +43,12 @@ const searchSchema = z.object({
export async function GET(request: NextRequest) {
const session = await auth();
const rateLimit = new RateLimit(request, 30);
const check = await rateLimit.handle();
if (check) return check;
const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
if (!parsed.success) return NextResponse.json({ error: parsed.error.errors[0].message }, { status: 400 });
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.errors[0].message }, 400);
const { q: query, sort, tags, userId, page = 1, limit = 24 } = parsed.data;
@ -97,7 +102,7 @@ export async function GET(request: NextRequest) {
prisma.mii.findMany({ where, orderBy, select, skip: (page - 1) * limit, take: limit }),
]);
return NextResponse.json({
return rateLimit.sendResponse({
total: totalCount,
filtered: filteredCount,
lastPage: Math.ceil(totalCount / limit),

View file

@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import fs from "fs/promises";
@ -11,6 +11,7 @@ import { profanity } from "@2toad/profanity";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { nameSchema, tagsSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { validateImage } from "@/lib/images";
import { convertQrCode } from "@/lib/qr-codes";
@ -30,10 +31,14 @@ const submitSchema = z.object({
image3: z.union([z.instanceof(File), z.any()]).optional(),
});
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 1);
const check = await rateLimit.handle();
if (check) return check;
const formData = await request.formData();
let rawTags: string[];
@ -42,7 +47,7 @@ export async function POST(request: Request) {
rawTags = JSON.parse(formData.get("tags") as string);
rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string);
} catch {
return NextResponse.json({ error: "Invalid JSON in tags or QR bytes" }, { status: 400 });
return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR bytes" }, 400);
}
const parsed = submitSchema.safeParse({
@ -54,7 +59,7 @@ export async function POST(request: Request) {
image3: formData.get("image3"),
});
if (!parsed.success) return NextResponse.json({ error: parsed.error.errors[0].message }, { status: 400 });
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.errors[0].message }, 400);
const { name: uncensoredName, tags: uncensoredTags, qrBytesRaw, image1, image2, image3 } = parsed.data;
// Censor potential inappropriate words
@ -71,7 +76,7 @@ export async function POST(request: Request) {
if (imageValidation.valid) {
images.push(img);
} else {
return NextResponse.json({ error: imageValidation.error }, { status: imageValidation.status ?? 400 });
return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
}
}
@ -82,7 +87,7 @@ export async function POST(request: Request) {
try {
conversion = convertQrCode(qrBytes);
} catch (error) {
return NextResponse.json({ error }, { status: 400 });
return rateLimit.sendResponse({ error }, 400);
}
// Create Mii in database
@ -120,7 +125,7 @@ export async function POST(request: Request) {
await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Failed to download Mii image:", error);
return NextResponse.json({ error: "Failed to download Mii image" }, { status: 500 });
return rateLimit.sendResponse({ error: "Failed to download Mii image" }, 500);
}
try {
@ -151,7 +156,7 @@ export async function POST(request: Request) {
await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Error processing Mii files:", error);
return NextResponse.json({ error: "Failed to process and store Mii files" }, { status: 500 });
return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
}
// Compress and upload user images
@ -177,8 +182,8 @@ export async function POST(request: Request) {
});
} catch (error) {
console.error("Error uploading user images:", error);
return NextResponse.json({ error: "Failed to store user images" }, { status: 500 });
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
}
return NextResponse.json({ success: true, id: miiRecord.id });
return rateLimit.sendResponse({ success: true, id: miiRecord.id });
}

View file

@ -1,7 +1,14 @@
import { NextRequest } from "next/server";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
export async function GET() {
import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit";
export async function GET(request: NextRequest) {
const rateLimit = new RateLimit(request, 16);
const check = await rateLimit.handle();
if (check) return check;
const count = await prisma.mii.count();
if (count === 0) redirect("/");

87
src/lib/rate-limit.ts Normal file
View file

@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { Redis } from "ioredis";
import { auth } from "./auth";
const redis = new Redis(process.env.REDIS_URL!);
const windowSize = 60;
interface RateLimitData {
success: boolean;
limit: number;
remaining: number;
expires: number;
}
// Fixed window implementation
export class RateLimit {
private request: NextRequest;
private maxRequests: number;
private data: RateLimitData;
constructor(request: NextRequest, maxRequests: number) {
this.request = request;
this.maxRequests = maxRequests;
this.data = {
success: true,
limit: maxRequests,
remaining: maxRequests,
expires: Date.now(),
};
}
// Check and update rate limit
async check(identifier: string): Promise<RateLimitData> {
const pathname = this.request.nextUrl.pathname;
const key = `ratelimit:${pathname}:${identifier}`;
const now = Date.now();
const seconds = Math.floor(now / 1000);
const currentWindow = Math.floor(seconds / windowSize) * windowSize;
const expireAt = currentWindow + windowSize;
try {
// Create a Redis transaction
const tx = redis.multi();
tx.incr(key);
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 success = count <= this.maxRequests;
const remaining = Math.max(0, this.maxRequests - count);
return { success, limit: this.maxRequests, remaining, expires: expireAt };
} catch (error) {
console.error("Rate limit check failed", error);
return {
success: true,
limit: this.maxRequests,
remaining: this.maxRequests,
expires: expireAt,
};
}
}
// Attach rate limit headers to a response
sendResponse(message: object, status: number = 200): NextResponse<object> {
const response = NextResponse.json(message, { status });
response.headers.set("X-RateLimit-Limit", this.data.limit.toString());
response.headers.set("X-RateLimit-Remaining", this.data.remaining.toString());
response.headers.set("X-RateLimit-Expires", this.data.expires.toString());
return response;
}
// Handle both functions above and identifier in one
async handle(): Promise<NextResponse<object> | 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";
this.data = await this.check(identifier);
console.log(this.data);
if (!this.data.success) return this.sendResponse({ success: false, error: "Rate limit exceeded. Please try again later." }, 429);
return;
}
}