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,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 BASE_URL=https://tomodachi-share.trafficlunar.net
NEXTAUTH_URL=https://tomodachi-share.trafficlunar.net # This should be the same as BASE_URL NEXTAUTH_URL=https://tomodachi-share.trafficlunar.net # This should be the same as BASE_URL

View file

@ -2,7 +2,7 @@
"name": "tomodachi-share", "name": "tomodachi-share",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "pnpm@10.9.0", "packageManager": "pnpm@10.10.0",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
@ -23,6 +23,7 @@
"downshift": "^9.0.9", "downshift": "^9.0.9",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"file-type": "^20.4.1", "file-type": "^20.4.1",
"ioredis": "^5.6.1",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"next": "15.2.4", "next": "15.2.4",
"next-auth": "5.0.0-beta.25", "next-auth": "5.0.0-beta.25",

View file

@ -44,6 +44,9 @@ importers:
file-type: file-type:
specifier: ^20.4.1 specifier: ^20.4.1
version: 20.4.1 version: 20.4.1
ioredis:
specifier: ^5.6.1
version: 5.6.1
jsqr: jsqr:
specifier: ^1.4.0 specifier: ^1.4.0
version: 1.4.0 version: 1.4.0
@ -614,6 +617,9 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@ioredis/commands@1.2.0':
resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==}
'@napi-rs/wasm-runtime@0.2.8': '@napi-rs/wasm-runtime@0.2.8':
resolution: {integrity: sha512-OBlgKdX7gin7OIq4fadsjpg+cp2ZphvAIKucHsNfTdJiqdOmOEwQd/bHi0VwNrcw5xpBJyUw6cK/QilCqy1BSg==} resolution: {integrity: sha512-OBlgKdX7gin7OIq4fadsjpg+cp2ZphvAIKucHsNfTdJiqdOmOEwQd/bHi0VwNrcw5xpBJyUw6cK/QilCqy1BSg==}
@ -1100,6 +1106,10 @@ packages:
client-only@0.0.1: client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} 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: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@ -1180,6 +1190,10 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
dequal@2.0.3: dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -1547,6 +1561,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'} 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: is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -1775,6 +1793,12 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} 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: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@ -2025,6 +2049,14 @@ packages:
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
engines: {node: '>=0.10.0'} 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: redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
@ -2140,6 +2172,9 @@ packages:
stable-hash@0.0.5: stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
streamsearch@1.1.0: streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
@ -2687,6 +2722,8 @@ snapshots:
'@img/sharp-win32-x64@0.34.1': '@img/sharp-win32-x64@0.34.1':
optional: true optional: true
'@ioredis/commands@1.2.0': {}
'@napi-rs/wasm-runtime@0.2.8': '@napi-rs/wasm-runtime@0.2.8':
dependencies: dependencies:
'@emnapi/core': 1.4.0 '@emnapi/core': 1.4.0
@ -3161,6 +3198,8 @@ snapshots:
client-only@0.0.1: {} client-only@0.0.1: {}
cluster-key-slot@1.1.2: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@ -3239,6 +3278,8 @@ snapshots:
has-property-descriptors: 1.0.2 has-property-descriptors: 1.0.2
object-keys: 1.1.1 object-keys: 1.1.1
denque@2.1.0: {}
dequal@2.0.3: {} dequal@2.0.3: {}
detect-libc@2.0.3: {} detect-libc@2.0.3: {}
@ -3786,6 +3827,20 @@ snapshots:
hasown: 2.0.2 hasown: 2.0.2
side-channel: 1.1.0 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: is-array-buffer@3.0.5:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.8
@ -4003,6 +4058,10 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.defaults@4.2.0: {}
lodash.isarguments@3.1.0: {}
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
loose-envify@1.4.0: loose-envify@1.4.0:
@ -4231,6 +4290,12 @@ snapshots:
react@19.1.0: {} react@19.1.0: {}
redis-errors@1.2.0: {}
redis-parser@3.0.0:
dependencies:
redis-errors: 1.2.0
redux@5.0.1: {} redux@5.0.1: {}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
@ -4420,6 +4485,8 @@ snapshots:
stable-hash@0.0.5: {} stable-hash@0.0.5: {}
standard-as-callback@2.1.0: {}
streamsearch@1.1.0: {} streamsearch@1.1.0: {}
string.prototype.includes@2.0.1: string.prototype.includes@2.0.1:

View file

@ -1,20 +1,25 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; 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(); const session = await auth();
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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 { try {
await prisma.user.delete({ await prisma.user.delete({
where: { id: Number(session.user.id) }, where: { id: Number(session.user.id) },
}); });
} catch (error) { } catch (error) {
console.error("Failed to delete user:", 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 { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { displayNameSchema } from "@/lib/schemas"; import { displayNameSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
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, 1);
const check = await rateLimit.handle();
if (check) return check;
const { displayName } = await request.json(); 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); 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 // 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 { try {
await prisma.user.update({ await prisma.user.update({
@ -25,8 +30,8 @@ export async function PATCH(request: NextRequest) {
}); });
} catch (error) { } catch (error) {
console.error("Failed to update display name:", 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 { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { usernameSchema } from "@/lib/schemas"; import { usernameSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
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, 1);
const check = await rateLimit.handle();
if (check) return check;
const { username } = await request.json(); 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 // Check if username was updated in the last 90 days
const user = await prisma.user.findUnique({ where: { email: session.user?.email ?? undefined } }); 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 timePeriod = dayjs().subtract(90, "days");
const lastUpdate = dayjs(user.usernameUpdatedAt); 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); 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 // 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 } }); 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 { try {
await prisma.user.update({ await prisma.user.update({
@ -39,8 +44,8 @@ export async function PATCH(request: NextRequest) {
}); });
} catch (error) { } catch (error) {
console.error("Failed to update username:", 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 { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { idSchema } from "@/lib/schemas"; import { idSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
const uploadsDirectory = path.join(process.cwd(), "public", "mii"); 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(); 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 check = await rateLimit.handle();
if (check) return check;
const { id: slugId } = await params; const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId); 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 miiId = parsed.data;
const miiUploadsDirectory = path.join(uploadsDirectory, miiId.toString()); const miiUploadsDirectory = path.join(uploadsDirectory, miiId.toString());
@ -27,7 +32,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
}); });
} catch (error) { } catch (error) {
console.error("Failed to delete Mii from database:", 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 { try {
@ -36,5 +41,5 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
console.warn("Failed to delete Mii image files:", error); 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 { z } from "zod";
import { Mii } from "@prisma/client"; import { Mii } from "@prisma/client";
@ -11,8 +11,8 @@ import { profanity } from "@2toad/profanity";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { idSchema, nameSchema, tagsSchema } from "@/lib/schemas"; import { idSchema, nameSchema, tagsSchema } from "@/lib/schemas";
import { validateImage } from "@/lib/images"; import { validateImage } from "@/lib/images";
import { RateLimit } from "@/lib/rate-limit";
const uploadsDirectory = path.join(process.cwd(), "public", "mii"); 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(), 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(); 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, 3);
const check = await rateLimit.handle();
if (check) return check;
// Get Mii ID // Get Mii ID
const { id: slugId } = await params; const { id: slugId } = await params;
const parsedId = idSchema.safeParse(slugId); 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; const miiId = parsedId.data;
// Check ownership of Mii // 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 (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404);
if (Number(session.user.id) !== mii.userId) return NextResponse.json({ error: "You don't have ownership of that Mii" }, { status: 403 }); if (Number(session.user.id) !== mii.userId) return rateLimit.sendResponse({ error: "You don't have ownership of that Mii" }, 403);
// Parse form data // Parse form data
const formData = await request.formData(); const formData = await request.formData();
@ -53,7 +57,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const value = formData.get("tags"); const value = formData.get("tags");
if (value) rawTags = JSON.parse(value as string); if (value) rawTags = JSON.parse(value as string);
} catch { } catch {
return NextResponse.json({ error: "Invalid JSON in tags" }, { status: 400 }); return rateLimit.sendResponse({ error: "Invalid JSON in tags" }, 400);
} }
const parsed = editSchema.safeParse({ const parsed = editSchema.safeParse({
@ -64,7 +68,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
image3: formData.get("image3"), 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; const { name, tags, image1, image2, image3 } = parsed.data;
// Validate image files // Validate image files
@ -77,7 +81,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
if (imageValidation.valid) { if (imageValidation.valid) {
images.push(img); images.push(img);
} else { } 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 (tags !== undefined) updateData.tags = tags.map((t) => profanity.censor(t)); // Same here
if (images.length > 0) updateData.imageCount = images.length; 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({ await prisma.mii.update({
where: { where: {
id: miiId, id: miiId,
@ -118,9 +122,9 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
); );
} catch (error) { } catch (error) {
console.error("Error uploading user images:", 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 { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { idSchema } from "@/lib/schemas"; import { idSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
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 check = await rateLimit.handle();
if (check) return check;
const { id: slugId } = await params; const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId); 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 miiId = parsed.data;
const result = await prisma.$transaction(async (tx) => { 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 { 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 { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { querySchema } from "@/lib/schemas"; import { querySchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
const searchSchema = z.object({ const searchSchema = z.object({
q: querySchema.optional(), q: querySchema.optional(),
@ -42,8 +43,12 @@ const searchSchema = z.object({
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const session = await auth(); 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)); 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; 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 }), prisma.mii.findMany({ where, orderBy, select, skip: (page - 1) * limit, take: limit }),
]); ]);
return NextResponse.json({ return rateLimit.sendResponse({
total: totalCount, total: totalCount,
filtered: filteredCount, filtered: filteredCount,
lastPage: Math.ceil(totalCount / limit), 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 { z } from "zod";
import fs from "fs/promises"; import fs from "fs/promises";
@ -11,6 +11,7 @@ import { profanity } from "@2toad/profanity";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { nameSchema, tagsSchema } from "@/lib/schemas"; import { nameSchema, tagsSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { validateImage } from "@/lib/images"; import { validateImage } from "@/lib/images";
import { convertQrCode } from "@/lib/qr-codes"; import { convertQrCode } from "@/lib/qr-codes";
@ -30,10 +31,14 @@ const submitSchema = z.object({
image3: z.union([z.instanceof(File), z.any()]).optional(), 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(); 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, 1);
const check = await rateLimit.handle();
if (check) return check;
const formData = await request.formData(); const formData = await request.formData();
let rawTags: string[]; let rawTags: string[];
@ -42,7 +47,7 @@ export async function POST(request: Request) {
rawTags = JSON.parse(formData.get("tags") as string); rawTags = JSON.parse(formData.get("tags") as string);
rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string); rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string);
} catch { } 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({ const parsed = submitSchema.safeParse({
@ -54,7 +59,7 @@ export async function POST(request: Request) {
image3: formData.get("image3"), 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; const { name: uncensoredName, tags: uncensoredTags, qrBytesRaw, image1, image2, image3 } = parsed.data;
// Censor potential inappropriate words // Censor potential inappropriate words
@ -71,7 +76,7 @@ export async function POST(request: Request) {
if (imageValidation.valid) { if (imageValidation.valid) {
images.push(img); images.push(img);
} else { } 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 { try {
conversion = convertQrCode(qrBytes); conversion = convertQrCode(qrBytes);
} catch (error) { } catch (error) {
return NextResponse.json({ error }, { status: 400 }); return rateLimit.sendResponse({ error }, 400);
} }
// Create Mii in database // Create Mii in database
@ -120,7 +125,7 @@ export async function POST(request: Request) {
await prisma.mii.delete({ where: { id: miiRecord.id } }); await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Failed to download Mii image:", error); 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 { try {
@ -151,7 +156,7 @@ export async function POST(request: Request) {
await prisma.mii.delete({ where: { id: miiRecord.id } }); await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Error processing Mii files:", error); 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 // Compress and upload user images
@ -177,8 +182,8 @@ export async function POST(request: Request) {
}); });
} catch (error) { } catch (error) {
console.error("Error uploading user images:", 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 { 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(); const count = await prisma.mii.count();
if (count === 0) redirect("/"); 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;
}
}