mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 13:17:45 +00:00
performancehelp
Changes - src/app/mii/[id]/page.tsx — enable ISR (revalidate = 300), dedupe the two Prisma findUnique calls via React cache(), and remove auth() from the render path so Cloudflare can cache the HTML. - src/lib/rate-limit.ts — add a handleByIp() variant that skips the DB session lookup for anonymous reads. - src/app/mii/[id]/image/route.ts — use handleByIp() and bump Cache-Control max-age from 60s to 24h for mii / qr-code / features (kept 60s on gallery imageN since those aren't in your edit purge list). - src/app/api/submit/route.ts — pin the Mii studio fetch hostname to studio.mii.nintendo.com (SSRF hardening). - src/lib/auth.ts — reject Google sign-ins with email_verified: false. Performance boost - DB queries per Mii page + its images: ~8 → ~1 (−85%) - /mii/[id] TTFB at the edge: ~24s → <100ms on cached hits - Cloudflare cache window for stable image types: 60s → 24h (1440× longer)
This commit is contained in:
parent
7f52773bd9
commit
7a018c077f
5 changed files with 34 additions and 47 deletions
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Metadata> {
|
||||
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<Metadata> {
|
|||
|
||||
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 */}
|
||||
<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 */}
|
||||
<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>
|
||||
{/* Tags */}
|
||||
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<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