mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 13:17:45 +00:00
Compare commits
6 commits
f4bfb59430
...
5f45f205f4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f45f205f4 | ||
| 79b19f4807 | |||
|
|
7a018c077f | ||
| 7f52773bd9 | |||
| 0c2dcf3192 | |||
| b66fbd305a |
9 changed files with 124 additions and 136 deletions
|
|
@ -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 rateLimit.sendResponse({ success: true, liked: result.liked, count: result.count });
|
return NextResponse.json({ success: false });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,25 +4,26 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,8 @@ 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 });
|
||||||
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) {
|
if (!studioResponse.ok) {
|
||||||
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`);
|
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`);
|
||||||
|
|
|
||||||
|
|
@ -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.handle();
|
const check = await rateLimit.handleByIp();
|
||||||
if (check) return check;
|
if (check) return check;
|
||||||
|
|
||||||
const { id: slugId } = await params;
|
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, {
|
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": "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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
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";
|
||||||
|
|
@ -5,7 +6,6 @@ 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,24 +25,21 @@ 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 {};
|
||||||
|
|
@ -90,31 +87,7 @@ 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 session = await auth();
|
const mii = await getMii(Number(id));
|
||||||
|
|
||||||
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");
|
||||||
|
|
||||||
|
|
@ -333,7 +306,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={(mii.likedBy ?? []).length > 0} big />
|
<LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={false} 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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(data ?? []);
|
const likedIds = new Set([0]);
|
||||||
|
|
||||||
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">
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,11 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
signIn: "/login",
|
signIn: "/login",
|
||||||
},
|
},
|
||||||
callbacks: {
|
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 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;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -107,4 +107,13 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue