feat: rate limiting api routes
This commit is contained in:
parent
50c18a342d
commit
2c13f60a50
13 changed files with 252 additions and 49 deletions
|
|
@ -1,4 +1,6 @@
|
|||
DATABASE_URL="postgresql://postgres:frieren@localhost:5432/tomodachi-share?schema=public"
|
||||
DATABASE_URL="postgresql://frieren:frieren@localhost:5432/tomodachi-share?schema=public"
|
||||
REDIS_URL="redis://localhost:6379/0"
|
||||
|
||||
BASE_URL=https://tomodachi-share.trafficlunar.net
|
||||
|
||||
NEXTAUTH_URL=https://tomodachi-share.trafficlunar.net # This should be the same as BASE_URL
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"name": "tomodachi-share",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.9.0",
|
||||
"packageManager": "pnpm@10.10.0",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
"downshift": "^9.0.9",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"file-type": "^20.4.1",
|
||||
"ioredis": "^5.6.1",
|
||||
"jsqr": "^1.4.0",
|
||||
"next": "15.2.4",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ importers:
|
|||
file-type:
|
||||
specifier: ^20.4.1
|
||||
version: 20.4.1
|
||||
ioredis:
|
||||
specifier: ^5.6.1
|
||||
version: 5.6.1
|
||||
jsqr:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
|
|
@ -614,6 +617,9 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@ioredis/commands@1.2.0':
|
||||
resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.8':
|
||||
resolution: {integrity: sha512-OBlgKdX7gin7OIq4fadsjpg+cp2ZphvAIKucHsNfTdJiqdOmOEwQd/bHi0VwNrcw5xpBJyUw6cK/QilCqy1BSg==}
|
||||
|
||||
|
|
@ -1100,6 +1106,10 @@ packages:
|
|||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
cluster-key-slot@1.1.2:
|
||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
|
|
@ -1180,6 +1190,10 @@ packages:
|
|||
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
denque@2.1.0:
|
||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -1547,6 +1561,10 @@ packages:
|
|||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ioredis@5.6.1:
|
||||
resolution: {integrity: sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
|
||||
is-array-buffer@3.0.5:
|
||||
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -1775,6 +1793,12 @@ packages:
|
|||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
lodash.defaults@4.2.0:
|
||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||
|
||||
lodash.isarguments@3.1.0:
|
||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
|
|
@ -2025,6 +2049,14 @@ packages:
|
|||
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
redis-errors@1.2.0:
|
||||
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
redis-parser@3.0.0:
|
||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
redux@5.0.1:
|
||||
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||
|
||||
|
|
@ -2140,6 +2172,9 @@ packages:
|
|||
stable-hash@0.0.5:
|
||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||
|
||||
standard-as-callback@2.1.0:
|
||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||
|
||||
streamsearch@1.1.0:
|
||||
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
|
@ -2687,6 +2722,8 @@ snapshots:
|
|||
'@img/sharp-win32-x64@0.34.1':
|
||||
optional: true
|
||||
|
||||
'@ioredis/commands@1.2.0': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.8':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.4.0
|
||||
|
|
@ -3161,6 +3198,8 @@ snapshots:
|
|||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
cluster-key-slot@1.1.2: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
|
@ -3239,6 +3278,8 @@ snapshots:
|
|||
has-property-descriptors: 1.0.2
|
||||
object-keys: 1.1.1
|
||||
|
||||
denque@2.1.0: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
detect-libc@2.0.3: {}
|
||||
|
|
@ -3786,6 +3827,20 @@ snapshots:
|
|||
hasown: 2.0.2
|
||||
side-channel: 1.1.0
|
||||
|
||||
ioredis@5.6.1:
|
||||
dependencies:
|
||||
'@ioredis/commands': 1.2.0
|
||||
cluster-key-slot: 1.1.2
|
||||
debug: 4.4.0
|
||||
denque: 2.1.0
|
||||
lodash.defaults: 4.2.0
|
||||
lodash.isarguments: 3.1.0
|
||||
redis-errors: 1.2.0
|
||||
redis-parser: 3.0.0
|
||||
standard-as-callback: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
is-array-buffer@3.0.5:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
|
|
@ -4003,6 +4058,10 @@ snapshots:
|
|||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
|
||||
lodash.defaults@4.2.0: {}
|
||||
|
||||
lodash.isarguments@3.1.0: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
|
|
@ -4231,6 +4290,12 @@ snapshots:
|
|||
|
||||
react@19.1.0: {}
|
||||
|
||||
redis-errors@1.2.0: {}
|
||||
|
||||
redis-parser@3.0.0:
|
||||
dependencies:
|
||||
redis-errors: 1.2.0
|
||||
|
||||
redux@5.0.1: {}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
|
|
@ -4420,6 +4485,8 @@ snapshots:
|
|||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
standard-as-callback@2.1.0: {}
|
||||
|
||||
streamsearch@1.1.0: {}
|
||||
|
||||
string.prototype.includes@2.0.1:
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
87
src/lib/rate-limit.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue