diff --git a/.env.example b/.env.example index 4e1119c..ff3cced 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/package.json b/package.json index 238be86..3c4ff70 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a0fe89..0691021 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/app/api/auth/delete/route.ts b/src/app/api/auth/delete/route.ts index 30c8b63..c772e28 100644 --- a/src/app/api/auth/delete/route.ts +++ b/src/app/api/auth/delete/route.ts @@ -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 }); } diff --git a/src/app/api/auth/display-name/route.ts b/src/app/api/auth/display-name/route.ts index 3528aa2..be4539f 100644 --- a/src/app/api/auth/display-name/route.ts +++ b/src/app/api/auth/display-name/route.ts @@ -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 }); } diff --git a/src/app/api/auth/username/route.ts b/src/app/api/auth/username/route.ts index c784e8e..9616ff4 100644 --- a/src/app/api/auth/username/route.ts +++ b/src/app/api/auth/username/route.ts @@ -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 }); } diff --git a/src/app/api/mii/[id]/delete/route.ts b/src/app/api/mii/[id]/delete/route.ts index 4cf0978..2c64524 100644 --- a/src/app/api/mii/[id]/delete/route.ts +++ b/src/app/api/mii/[id]/delete/route.ts @@ -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 }); } diff --git a/src/app/api/mii/[id]/edit/route.ts b/src/app/api/mii/[id]/edit/route.ts index d7a975e..9728ea9 100644 --- a/src/app/api/mii/[id]/edit/route.ts +++ b/src/app/api/mii/[id]/edit/route.ts @@ -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 }); } diff --git a/src/app/api/mii/[id]/like/route.ts b/src/app/api/mii/[id]/like/route.ts index 3a8a868..c0a143a 100644 --- a/src/app/api/mii/[id]/like/route.ts +++ b/src/app/api/mii/[id]/like/route.ts @@ -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 }); } diff --git a/src/app/api/mii/list/route.ts b/src/app/api/mii/list/route.ts index 3b044c5..cb92f3f 100644 --- a/src/app/api/mii/list/route.ts +++ b/src/app/api/mii/list/route.ts @@ -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), diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts index 86e65a9..005442c 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -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 }); } diff --git a/src/app/random/route.ts b/src/app/random/route.ts index 888764f..5aaf311 100644 --- a/src/app/random/route.ts +++ b/src/app/random/route.ts @@ -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("/"); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..0e7ca0a --- /dev/null +++ b/src/lib/rate-limit.ts @@ -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 { + 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 { + 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 | 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; + } +}