Compare commits

...

9 commits

Author SHA1 Message Date
79b19f4807
Merge pull request #31 from NigelWD/performanceHelp
Performance: ISR, query dedup, rate-limit and image cache improvements
2026-04-17 13:46:22 +01:00
NigelWD
7a018c077f 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)
2026-04-16 22:09:52 -04:00
7f52773bd9 fix: build errors AGAIN
aefusuhgurg
2026-04-17 01:19:21 +01:00
0c2dcf3192 fix: build errors 2026-04-17 01:14:58 +01:00
b66fbd305a temp disable likes 2026-04-17 01:11:49 +01:00
c72dab1962 refactor: remove random sort 2026-04-17 00:54:13 +01:00
8ebc480233 feat: prisma migration 2026-04-17 00:44:26 +01:00
b00ce4dc3b
Merge pull request #28 from AlexHelo/fix/cloudflare-caching-and-query-performance
Fix Cloudflare RSC caching bug and reduce database load
2026-04-17 00:39:44 +01:00
AlexHelo
8615a4d864 Fix Cloudflare RSC caching bug and reduce database load 2026-04-16 17:28:37 -06:00
17 changed files with 165 additions and 235 deletions

View file

@ -5,6 +5,17 @@ const nextConfig: NextConfig = {
images: { images: {
unoptimized: true, unoptimized: true,
}, },
async headers() {
return [
{
// Prevent Cloudflare from serving cached HTML for RSC navigation requests
source: "/:path*",
headers: [
{ key: "Vary", value: "RSC, Next-Router-State-Tree, Next-Router-Prefetch" },
],
},
];
},
}; };
export default nextConfig; export default nextConfig;

View file

@ -32,7 +32,6 @@
"react-image-crop": "^11.0.10", "react-image-crop": "^11.0.10",
"redis": "^5.11.0", "redis": "^5.11.0",
"satori": "^0.26.0", "satori": "^0.26.0",
"seedrandom": "^3.0.5",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"sjcl-with-all": "1.0.8", "sjcl-with-all": "1.0.8",
"swr": "^2.4.1", "swr": "^2.4.1",
@ -46,7 +45,6 @@
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/seedrandom": "^3.0.8",
"@types/sjcl": "^1.0.34", "@types/sjcl": "^1.0.34",
"eslint": "^10.2.0", "eslint": "^10.2.0",
"eslint-config-next": "16.2.3", "eslint-config-next": "16.2.3",

View file

@ -71,9 +71,6 @@ importers:
satori: satori:
specifier: ^0.26.0 specifier: ^0.26.0
version: 0.26.0 version: 0.26.0
seedrandom:
specifier: ^3.0.5
version: 3.0.5
sharp: sharp:
specifier: ^0.34.5 specifier: ^0.34.5
version: 0.34.5 version: 0.34.5
@ -108,9 +105,6 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^19.2.3 specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14) version: 19.2.3(@types/react@19.2.14)
'@types/seedrandom':
specifier: ^3.0.8
version: 3.0.8
'@types/sjcl': '@types/sjcl':
specifier: ^1.0.34 specifier: ^1.0.34
version: 1.0.34 version: 1.0.34
@ -797,9 +791,6 @@ packages:
'@types/react@19.2.14': '@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/seedrandom@3.0.8':
resolution: {integrity: sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==}
'@types/sjcl@1.0.34': '@types/sjcl@1.0.34':
resolution: {integrity: sha512-bQHEeK5DTQRunIfQeUMgtpPsNNCcZyQ9MJuAfW1I7iN0LDunTc78Fu17STbLMd7KiEY/g2zHVApippa70h6HoQ==} resolution: {integrity: sha512-bQHEeK5DTQRunIfQeUMgtpPsNNCcZyQ9MJuAfW1I7iN0LDunTc78Fu17STbLMd7KiEY/g2zHVApippa70h6HoQ==}
@ -2252,9 +2243,6 @@ packages:
schema-dts@2.0.0: schema-dts@2.0.0:
resolution: {integrity: sha512-t7NoCy3Rn5GHGx6p7s1qIYK/AeIb8ZxJNR9WUNFkwMv2CiiGZBmqqYWc2FlZVm5ZbiHMY4OvBWhj7QtyrFO2Jw==} resolution: {integrity: sha512-t7NoCy3Rn5GHGx6p7s1qIYK/AeIb8ZxJNR9WUNFkwMv2CiiGZBmqqYWc2FlZVm5ZbiHMY4OvBWhj7QtyrFO2Jw==}
seedrandom@3.0.5:
resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==}
semver@6.3.1: semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
@ -3111,8 +3099,6 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@types/seedrandom@3.0.8': {}
'@types/sjcl@1.0.34': {} '@types/sjcl@1.0.34': {}
'@types/use-sync-external-store@0.0.6': {} '@types/use-sync-external-store@0.0.6': {}
@ -4701,8 +4687,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
seedrandom@3.0.5: {}
semver@6.3.1: {} semver@6.3.1: {}
semver@7.7.4: {} semver@7.7.4: {}

View file

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "miis_in_queue_quarantined_createdAt_idx" ON "miis"("in_queue", "quarantined", "createdAt" DESC);

View file

@ -104,6 +104,7 @@ model Mii {
@@index([gender]) @@index([gender])
@@index([makeup]) @@index([makeup])
@@index([quarantined, id]) @@index([quarantined, id])
@@index([in_queue, quarantined, createdAt(sort: Desc)])
@@map("miis") @@map("miis")
} }

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 rateLimit.sendResponse({ success: true, liked: result.liked, count: result.count }); return NextResponse.json({ success: false });
} }

View file

@ -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 });
} }

View file

@ -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}`);

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.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",
}); });
} }

View file

@ -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">

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

@ -1,8 +1,5 @@
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import crypto from "crypto";
import seedrandom from "seedrandom";
import { searchSchema } from "@/lib/schemas"; import { searchSchema } from "@/lib/schemas";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@ -23,7 +20,7 @@ export default async function MiiList({ searchParams, userId, parentPage }: Prop
const parsed = searchSchema.safeParse(searchParams); const parsed = searchSchema.safeParse(searchParams);
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>; if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, seed } = parsed.data; const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24 } = parsed.data;
// My Likes page // My Likes page
let miiIdsLiked: number[] | undefined = undefined; let miiIdsLiked: number[] | undefined = undefined;
@ -108,85 +105,39 @@ export default async function MiiList({ searchParams, userId, parentPage }: Prop
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
let totalCount: number; let totalCount: number;
let filteredCount: number;
let miis: Prisma.MiiGetPayload<{ select: typeof select }>[]; let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
if (sort === "random") { // Sorting by likes, newest, or oldest
// Get all IDs that match the where conditions let orderBy: Prisma.MiiOrderByWithRelationInput[];
const matchingIds = await prisma.mii.findMany({
where,
select: { id: true },
});
totalCount = matchingIds.length; if (sort === "likes") {
filteredCount = Math.max(0, Math.min(limit, totalCount - skip)); orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
} else if (sort === "oldest") {
if (matchingIds.length === 0) return; orderBy = [{ createdAt: "asc" }, { name: "asc" }];
// Use seed for consistent random results
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
const rng = seedrandom(randomSeed.toString());
// Randomize all IDs using the Durstenfeld algorithm
for (let i = matchingIds.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]];
}
// Convert to number[] array
const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id);
miis = await prisma.mii.findMany({
where: {
id: { in: selectedIds },
},
select,
});
} else { } else {
// Sorting by likes, newest, or oldest // default to newest
let orderBy: Prisma.MiiOrderByWithRelationInput[]; orderBy = [{ createdAt: "desc" }, { name: "asc" }];
if (sort === "likes") {
orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
} else if (sort === "oldest") {
orderBy = [{ createdAt: "asc" }, { name: "asc" }];
} else {
// default to newest
orderBy = [{ createdAt: "desc" }, { name: "asc" }];
}
[totalCount, filteredCount, miis] = await Promise.all([
prisma.mii.count({ where: { ...where, userId } }),
prisma.mii.count({ where, skip, take: limit }),
prisma.mii.findMany({
where,
orderBy,
select,
skip: (page - 1) * limit,
take: limit,
}),
]);
} }
[totalCount, miis] = await Promise.all([
prisma.mii.count({ where: { ...where, userId } }),
prisma.mii.findMany({
where,
orderBy,
select,
skip,
take: limit,
}),
]);
const lastPage = Math.ceil(totalCount / limit); const lastPage = Math.ceil(totalCount / limit);
return ( return (
<div className="w-full"> <div className="w-full">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{totalCount == filteredCount ? ( <span className="text-2xl font-bold text-amber-900">{totalCount}</span>
<> <span className="text-lg text-amber-700">{totalCount === 1 ? "Mii" : "Miis"}</span>
<span className="text-2xl font-bold text-amber-900">{totalCount}</span>
<span className="text-lg text-amber-700">{totalCount === 1 ? "Mii" : "Miis"}</span>
</>
) : (
<>
<span className="text-2xl font-bold text-amber-900">{filteredCount}</span>
<span className="text-sm text-amber-700">of</span>
<span className="text-lg font-semibold text-amber-800">{totalCount}</span>
<span className="text-lg text-amber-700">Miis</span>
</>
)}
</div> </div>
<div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center"> <div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">

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(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">

View file

@ -5,9 +5,9 @@ import { useTransition } from "react";
import { useSelect } from "downshift"; import { useSelect } from "downshift";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
type Sort = "likes" | "newest" | "oldest" | "random"; type Sort = "likes" | "newest" | "oldest";
const items = ["likes", "newest", "oldest", "random"]; const items = ["likes", "newest", "oldest"];
export default function SortSelect() { export default function SortSelect() {
const router = useRouter(); const router = useRouter();
@ -26,10 +26,6 @@ export default function SortSelect() {
params.set("page", "1"); params.set("page", "1");
params.set("sort", selectedItem); params.set("sort", selectedItem);
if (selectedItem == "random") {
params.set("seed", Math.floor(Math.random() * 1_000_000_000).toString());
}
startTransition(() => { startTransition(() => {
router.push(`?${params.toString()}`, { scroll: false }); router.push(`?${params.toString()}`, { scroll: false });
}); });

View file

@ -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;
}, },

View file

@ -68,9 +68,10 @@ export class RateLimit {
return { success, limit: this.maxRequests, remaining, expires: expireAt }; return { success, limit: this.maxRequests, remaining, expires: expireAt };
} catch (error) { } catch (error) {
// Fail open — don't block users when Redis is unreachable
console.error("Rate limit check failed", error); console.error("Rate limit check failed", error);
return { return {
success: false, success: true,
limit: this.maxRequests, limit: this.maxRequests,
remaining: this.maxRequests, remaining: this.maxRequests,
expires: expireAt, expires: expireAt,
@ -106,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;
}
} }

View file

@ -39,7 +39,7 @@ export const idSchema = z.coerce.number({ error: "ID must be a number" }).int({
export const searchSchema = z.object({ export const searchSchema = z.object({
q: querySchema.optional(), q: querySchema.optional(),
sort: z.enum(["likes", "newest", "oldest", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"), sort: z.enum(["likes", "newest", "oldest"], { error: "Sort must be either 'likes', 'newest', or 'oldest'" }).default("newest"),
tags: z tags: z
.string() .string()
.optional() .optional()
@ -72,8 +72,6 @@ export const searchSchema = z.object({
.max(100, { error: "Limit cannot be more than 100" }) .max(100, { error: "Limit cannot be more than 100" })
.optional(), .optional(),
page: z.coerce.number({ error: "Page must be a number" }).int({ error: "Page must be an integer" }).min(1, { error: "Page must be at least 1" }).optional(), page: z.coerce.number({ error: "Page must be a number" }).int({ error: "Page must be an integer" }).min(1, { error: "Page must be at least 1" }).optional(),
// Random sort
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
}); });
export const userNameSchema = z export const userNameSchema = z