tomodachi-share/src/app/mii/[id]/image/route.ts
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

118 lines
3.7 KiB
TypeScript

import { NextRequest } from "next/server";
import { Prisma } from "@prisma/client";
import fs from "fs/promises";
import path from "path";
import { z } from "zod";
import { idSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { generateMetadataImage } from "@/lib/images";
import { prisma } from "@/lib/prisma";
const searchParamsSchema = z.object({
type: z
.enum(["mii", "qr-code", "features", "image0", "image1", "image2", "metadata"], {
message: "Image type must be either 'mii', 'qr-code', 'features', 'image[number from 0 to 2]' or 'metadata'",
})
.default("mii"),
});
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const rateLimit = new RateLimit(request, 200, "/mii/image");
const check = await rateLimit.handleByIp();
if (check) return check;
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const miiId = parsed.data;
const searchParamsParsed = searchParamsSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
if (!searchParamsParsed.success) return rateLimit.sendResponse({ error: searchParamsParsed.error.issues[0].message }, 400);
const { type: imageType } = searchParamsParsed.data;
const filePath = path.join(process.cwd(), "uploads", "mii", miiId.toString(), `${imageType}.png`);
let buffer: Buffer | undefined;
// Only find Mii if image type is 'metadata'
let mii: Prisma.MiiGetPayload<{
include: {
user: {
select: {
name: true;
};
};
};
}> | null = null;
if (imageType === "metadata") {
mii = await prisma.mii.findUnique({
where: {
id: miiId,
},
include: {
user: {
select: {
name: true,
},
},
},
});
if (!mii) {
return rateLimit.sendResponse({ error: "Mii not found" }, 404);
}
}
try {
// Try to read file
buffer = await fs.readFile(filePath);
} catch {
// If the readFile() fails, that probably means it doesn't exist
if (imageType === "metadata" && mii) {
// Metadata images were added after 1274 Miis were submitted, so we generate it on-the-fly
console.log(`Metadata image not found for mii ID ${miiId}, generating metadata image...`);
const { buffer: metadataBuffer, error, status } = await generateMetadataImage(mii, mii.user.name!);
if (error) {
return rateLimit.sendResponse({ error }, status);
}
buffer = metadataBuffer;
} else {
return rateLimit.sendResponse({ error: "Image not found" }, 404);
}
}
if (!buffer) return rateLimit.sendResponse({ error: "Image not found" }, 404);
// Set the file name for the metadata image in the response for SEO
if (mii && imageType === "metadata") {
const slugify = (str: string) =>
str
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-") // replace non-alphanumeric with hyphens
.replace(/^-+|-+$/g, "");
const name = slugify(mii.name);
const tags = mii.tags.map(slugify).join("-");
const filename = `${name}-mii-${tags}.png`;
return rateLimit.sendResponse(buffer, 200, {
"Content-Type": "image/png",
"Content-Disposition": `inline; filename="${filename}"`,
"Cache-Control": "public, max-age=31536000",
});
}
// 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": isStableType ? "public, max-age=3600, stale-while-revalidate=86400" : "public, max-age=60, stale-while-revalidate=30",
});
}