Compare commits

..

1 commit

Author SHA1 Message Date
Alex Helo
f4bfb59430
Merge 8df69bcd79 into c72dab1962 2026-04-16 18:02:59 -06:00
9 changed files with 136 additions and 124 deletions

View file

@ -6,54 +6,54 @@ import { idSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit"; 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, "/api/mii/like"); const rateLimit = new RateLimit(request, 100, "/api/mii/like");
// const check = await rateLimit.handle(); const check = await rateLimit.handle();
// if (check) return check; 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 rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400); if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[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) => {
// const existingLike = await tx.like.findUnique({ const existingLike = await tx.like.findUnique({
// where: { where: {
// userId_miiId: { userId_miiId: {
// userId: Number(session.user?.id), userId: Number(session.user?.id),
// miiId, miiId,
// }, },
// }, },
// }); });
// if (existingLike) { if (existingLike) {
// // Remove the like if it exists // Remove the like if it exists
// await tx.like.delete({ await tx.like.delete({
// where: { where: {
// userId_miiId: { userId_miiId: {
// userId: Number(session.user?.id), userId: Number(session.user?.id),
// miiId, miiId,
// }, },
// }, },
// }); });
// } else { } else {
// // Add a like if it doesn't exist // Add a like if it doesn't exist
// await tx.like.create({ await tx.like.create({
// data: { data: {
// userId: Number(session.user?.id), userId: Number(session.user?.id),
// miiId, miiId,
// }, },
// }); });
// } }
// const likeCount = await tx.like.count({ const likeCount = await tx.like.count({
// where: { miiId }, where: { miiId },
// }); });
// return { liked: !existingLike, count: likeCount }; return { liked: !existingLike, count: likeCount };
// }); });
return NextResponse.json({ success: false }); return rateLimit.sendResponse({ success: true, liked: result.liked, count: result.count });
} }

View file

@ -4,26 +4,25 @@ import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
export async function GET(request: NextRequest) { export async function GET(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, 50, "/api/mii/like_get"); const rateLimit = new RateLimit(request, 50, "/api/mii/like_get");
// const check = await rateLimit.handle(); const check = await rateLimit.handle();
// if (check) return check; if (check) return check;
// const idsParam = new URL(request.url).searchParams.get("ids"); const idsParam = new URL(request.url).searchParams.get("ids");
// if (!idsParam) return NextResponse.json({ error: "Missing IDs parameter" }, { status: 400 }); if (!idsParam) return NextResponse.json({ error: "Missing IDs parameter" }, { status: 400 });
// const ids = idsParam.split(",").map(Number).filter(Boolean); const ids = idsParam.split(",").map(Number).filter(Boolean);
// if (!ids.length) return NextResponse.json({ error: "No valid IDs provided" }, { status: 400 }); if (!ids.length) return NextResponse.json({ error: "No valid IDs provided" }, { status: 400 });
// if (ids.length > 100) return NextResponse.json({ error: "Too many IDs, maximum is 100" }, { status: 400 }); if (ids.length > 100) return NextResponse.json({ error: "Too many IDs, maximum is 100" }, { status: 400 });
// const liked = await prisma.like.findMany({ const liked = await prisma.like.findMany({
// where: { userId: Number(session.user?.id), miiId: { in: ids } }, where: { userId: Number(session.user?.id), miiId: { in: ids } },
// select: { miiId: true }, select: { miiId: true },
// }); });
// // Return only Miis that are liked // Return only Miis that are liked
// return NextResponse.json(liked.map((l) => l.miiId)); return NextResponse.json(liked.map((l) => l.miiId));
return NextResponse.json({ success: false }, { status: 500 });
} }

View file

@ -220,8 +220,7 @@ export async function POST(request: NextRequest) {
// Download the image of the Mii (3DS) // Download the image of the Mii (3DS)
if (platform === "THREE_DS") { if (platform === "THREE_DS") {
const studioUrl = conversion?.mii.studioUrl({ width: 512 }); const studioUrl = conversion?.mii.studioUrl({ width: 512 });
if (!studioUrl || new URL(studioUrl).hostname !== "studio.mii.nintendo.com") throw new Error("Invalid studio URL"); const studioResponse = await fetch(studioUrl!);
const studioResponse = await fetch(studioUrl);
if (!studioResponse.ok) { if (!studioResponse.ok) {
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`); throw new Error(`Failed to fetch Mii image ${studioResponse.status}`);

View file

@ -20,7 +20,7 @@ const searchParamsSchema = z.object({
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const rateLimit = new RateLimit(request, 200, "/mii/image"); const rateLimit = new RateLimit(request, 200, "/mii/image");
const check = await rateLimit.handleByIp(); const check = await rateLimit.handle();
if (check) return check; if (check) return check;
const { id: slugId } = await params; const { id: slugId } = await params;
@ -107,12 +107,9 @@ 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, { return rateLimit.sendResponse(buffer, 200, {
"Content-Type": "image/png", "Content-Type": "image/png",
"X-Robots-Tag": "noindex, noimageindex, nofollow", "X-Robots-Tag": "noindex, noimageindex, nofollow",
"Cache-Control": isStableType ? "public, max-age=3600, stale-while-revalidate=86400" : "public, max-age=60, stale-while-revalidate=30", "Cache-Control": "public, max-age=60, stale-while-revalidate=30",
}); });
} }

View file

@ -1,4 +1,3 @@
import { cache } from "react";
import { Metadata } from "next"; import { Metadata } from "next";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@ -6,6 +5,7 @@ import { redirect } from "next/navigation";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { MiiPlatform } from "@prisma/client"; import { MiiPlatform } from "@prisma/client";
@ -25,21 +25,24 @@ interface Props {
params: Promise<{ id: string }>; 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<Metadata> { export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params; const { id } = await params;
const mii = await getMii(Number(id));
const mii = await prisma.mii.findUnique({
where: {
id: Number(id),
},
include: {
user: {
select: {
name: true,
},
},
_count: {
select: { likedBy: true }, // Get total like count
},
},
});
// Bots get redirected anyways // Bots get redirected anyways
if (!mii) return {}; if (!mii) return {};
@ -87,7 +90,31 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
export default async function MiiPage({ params }: Props) { export default async function MiiPage({ params }: Props) {
const { id } = await params; const { id } = await params;
const mii = await getMii(Number(id)); 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
},
},
});
if (!mii) redirect("/404"); if (!mii) redirect("/404");
@ -306,7 +333,7 @@ export default async function MiiPage({ params }: Props) {
{/* Submission name */} {/* Submission name */}
<h1 className="text-4xl font-extrabold wrap-break-word whitespace-break-spaces text-amber-700 flex-1 min-w-0">{mii.name}</h1> <h1 className="text-4xl font-extrabold wrap-break-word whitespace-break-spaces text-amber-700 flex-1 min-w-0">{mii.name}</h1>
{/* Like button */} {/* Like button */}
<LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={false} big /> <LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={(mii.likedBy ?? []).length > 0} big />
</div> </div>
{/* Tags */} {/* Tags */}
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs"> <div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">

View file

@ -24,31 +24,31 @@ export default function LikeButton({ likes, isLiked, miiId, disabled, abbreviate
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false);
const onClick = async () => { const onClick = async () => {
// if (disabled) return; if (disabled) return;
// if (!session.data?.user) { if (!session.data?.user) {
// router.push("/login"); router.push("/login");
// return; return;
// } }
// setIsLikedState(!isLikedState); setIsLikedState(!isLikedState);
// setLikesState(isLikedState ? likesState - 1 : likesState + 1); setLikesState(isLikedState ? likesState - 1 : likesState + 1);
// // Trigger animation // Trigger animation
// if (!isLikedState) { if (!isLikedState) {
// setIsAnimating(true); setIsAnimating(true);
// setTimeout(() => setIsAnimating(false), 1000); // match animation duration setTimeout(() => setIsAnimating(false), 1000); // match animation duration
// } }
// const response = await fetch(`/api/mii/${miiId}/like`, { method: "PATCH" }); const response = await fetch(`/api/mii/${miiId}/like`, { method: "PATCH" });
// if (response.ok) { if (response.ok) {
// const { liked, count } = await response.json(); const { liked, count } = await response.json();
// setIsLikedState(liked); setIsLikedState(liked);
// setLikesState(count); setLikesState(count);
// } else { } else {
// setIsLikedState(isLikedState); setIsLikedState(isLikedState);
// setLikesState(likesState); setLikesState(likesState);
// } }
}; };
// Preload like button icons // Preload like button icons
@ -56,9 +56,9 @@ export default function LikeButton({ likes, isLiked, miiId, disabled, abbreviate
loadIcons(["icon-park-solid:like", "icon-park-outline:like"]); loadIcons(["icon-park-solid:like", "icon-park-outline:like"]);
}, []); }, []);
// useEffect(() => { useEffect(() => {
// setIsLikedState(isLiked); setIsLikedState(isLiked);
// }, [isLiked]); }, [isLiked]);
return ( return (
<button <button

View file

@ -23,12 +23,12 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
const session = useSession(); const session = useSession();
const router = useRouter(); const router = useRouter();
// const ids = miis.map((m) => m.id).join(","); const ids = miis.map((m) => m.id).join(",");
// const { data } = useSWR<number[]>(session.data?.user && miis.length > 0 ? `/api/mii/has-liked?ids=${ids}` : null, fetcher, { const { data } = useSWR<number[]>(session.data?.user && miis.length > 0 ? `/api/mii/has-liked?ids=${ids}` : null, fetcher, {
// revalidateOnFocus: false, revalidateOnFocus: false,
// revalidateOnReconnect: false, revalidateOnReconnect: false,
// }); });
const likedIds = new Set([0]); const likedIds = new Set(data ?? []);
return ( return (
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1"> <div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">

View file

@ -13,11 +13,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
signIn: "/login", signIn: "/login",
}, },
callbacks: { callbacks: {
async signIn({ user, account, profile }) { async signIn({ user }) {
const blacklist = process.env.BLACKLISTED_EMAILS ? process.env.BLACKLISTED_EMAILS.split(",").map((item) => item.trim().toLowerCase()) : []; const blacklist = process.env.BLACKLISTED_EMAILS ? process.env.BLACKLISTED_EMAILS.split(",").map((item) => item.trim().toLowerCase()) : [];
const email = user?.email?.toLowerCase(); const email = user?.email?.toLowerCase();
if (!email) return false; 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; if (blacklist?.some((blocked) => email.endsWith(blocked))) return false;
return true; return true;
}, },

View file

@ -107,13 +107,4 @@ export class RateLimit {
if (!this.data.success) return this.sendResponse({ error: "Rate limit exceeded. Please try again later." }, 429); if (!this.data.success) return this.sendResponse({ error: "Rate limit exceeded. Please try again later." }, 429);
return; return;
} }
// IP-only variant — skips the session lookup for anonymous read paths like images
async handleByIp(): Promise<NextResponse<object | unknown> | 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;
}
} }