diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts index 710c6d6..cf862d5 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -220,7 +220,8 @@ export async function POST(request: NextRequest) { // Download the image of the Mii (3DS) if (platform === "THREE_DS") { const studioUrl = conversion?.mii.studioUrl({ width: 512 }); - const studioResponse = await fetch(studioUrl!); + if (!studioUrl || new URL(studioUrl).hostname !== "studio.mii.nintendo.com") throw new Error("Invalid studio URL"); + const studioResponse = await fetch(studioUrl); if (!studioResponse.ok) { throw new Error(`Failed to fetch Mii image ${studioResponse.status}`); diff --git a/src/app/mii/[id]/image/route.ts b/src/app/mii/[id]/image/route.ts index ced1e92..da9a272 100644 --- a/src/app/mii/[id]/image/route.ts +++ b/src/app/mii/[id]/image/route.ts @@ -20,7 +20,7 @@ const searchParamsSchema = z.object({ export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const rateLimit = new RateLimit(request, 200, "/mii/image"); - const check = await rateLimit.handle(); + const check = await rateLimit.handleByIp(); if (check) return check; const { id: slugId } = await params; @@ -107,9 +107,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ }); } + // mii, features are purged on edit; qr-code is immutable. imageN isn't purged, so keep its TTL short. + const isStableType = imageType === "mii" || imageType === "qr-code" || imageType === "features"; + return rateLimit.sendResponse(buffer, 200, { "Content-Type": "image/png", "X-Robots-Tag": "noindex, noimageindex, nofollow", - "Cache-Control": "public, max-age=60, stale-while-revalidate=30", + "Cache-Control": isStableType ? "public, max-age=3600, stale-while-revalidate=86400" : "public, max-age=60, stale-while-revalidate=30", }); } diff --git a/src/app/mii/[id]/page.tsx b/src/app/mii/[id]/page.tsx index 17863ac..ec31c1e 100644 --- a/src/app/mii/[id]/page.tsx +++ b/src/app/mii/[id]/page.tsx @@ -1,3 +1,4 @@ +import { cache } from "react"; import { Metadata } from "next"; import Image from "next/image"; import Link from "next/link"; @@ -5,7 +6,6 @@ import { redirect } from "next/navigation"; import { Icon } from "@iconify/react"; -import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { MiiPlatform } from "@prisma/client"; @@ -25,24 +25,21 @@ interface Props { params: Promise<{ id: string }>; } +export const revalidate = 300; + +const getMii = cache(async (id: number) => + prisma.mii.findUnique({ + where: { id }, + include: { + user: { select: { name: true } }, + _count: { select: { likedBy: true } }, + }, + }), +); + export async function generateMetadata({ params }: Props): Promise { const { id } = await params; - - const mii = await prisma.mii.findUnique({ - where: { - id: Number(id), - }, - include: { - user: { - select: { - name: true, - }, - }, - _count: { - select: { likedBy: true }, // Get total like count - }, - }, - }); + const mii = await getMii(Number(id)); // Bots get redirected anyways if (!mii) return {}; @@ -90,31 +87,7 @@ export async function generateMetadata({ params }: Props): Promise { export default async function MiiPage({ params }: Props) { const { id } = await params; - const session = await auth(); - - const mii = await prisma.mii.findUnique({ - where: { - id: Number(id), - }, - include: { - user: { - select: { - name: true, - }, - }, - likedBy: session?.user - ? { - where: { - userId: Number(session.user.id), - }, - select: { userId: true }, - } - : false, - _count: { - select: { likedBy: true }, // Get total like count - }, - }, - }); + const mii = await getMii(Number(id)); if (!mii) redirect("/404"); @@ -333,7 +306,7 @@ export default async function MiiPage({ params }: Props) { {/* Submission name */}

{mii.name}

{/* Like button */} - 0} big /> + {/* Tags */}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b965236..f090e8a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -13,10 +13,11 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ signIn: "/login", }, callbacks: { - async signIn({ user }) { + async signIn({ user, account, profile }) { const blacklist = process.env.BLACKLISTED_EMAILS ? process.env.BLACKLISTED_EMAILS.split(",").map((item) => item.trim().toLowerCase()) : []; const email = user?.email?.toLowerCase(); if (!email) return false; + if (account?.provider === "google" && (profile as { email_verified?: boolean })?.email_verified === false) return false; if (blacklist?.some((blocked) => email.endsWith(blocked))) return false; return true; }, diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index b8a53c4..b24d3b0 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -107,4 +107,13 @@ export class RateLimit { if (!this.data.success) return this.sendResponse({ error: "Rate limit exceeded. Please try again later." }, 429); return; } + + // IP-only variant — skips the session lookup for anonymous read paths like images + async handleByIp(): Promise | undefined> { + const ip = this.request.headers.get("CF-Connecting-IP") || this.request.headers.get("X-Forwarded-For")?.split(",")[0] || "anonymous"; + this.data = await this.check(ip); + + if (!this.data.success) return this.sendResponse({ error: "Rate limit exceeded. Please try again later." }, 429); + return; + } }