mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-06-28 14:44:15 +00:00
feat: rate limiting api routes
This commit is contained in:
parent
50c18a342d
commit
2c13f60a50
13 changed files with 252 additions and 49 deletions
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue