feat: move images to nginx

This commit is contained in:
trafficlunar 2026-04-19 17:48:51 +01:00
parent a42a4126ec
commit c951c7d755
11 changed files with 29 additions and 169 deletions

View file

@ -5,7 +5,8 @@ REDIS_URL="redis://localhost:6379/0"
# Used for metadata, sitemaps, etc. # Used for metadata, sitemaps, etc.
NEXT_PUBLIC_BASE_URL=http://localhost:3000 NEXT_PUBLIC_BASE_URL=http://localhost:3000
FRONTEND_URL=http://localhost:4321 NEXT_PUBLIC_FRONTEND_URL=http://localhost:5173
NEXT_PUBLIC_STATIC_URL=
CLOUDFLARE_ZONE_ID=XXXXXXXXXXXXXXXX CLOUDFLARE_ZONE_ID=XXXXXXXXXXXXXXXX
CLOUDFLARE_API_TOKEN=XXXXXXXXXXXXXXXX CLOUDFLARE_API_TOKEN=XXXXXXXXXXXXXXXX

View file

@ -246,8 +246,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
body: JSON.stringify({ body: JSON.stringify({
files: [ files: [
`${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}`, `${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}`,
`${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}/image?type=mii`, `${process.env.NEXT_PUBLIC_STATIC_URL}/mii/${miiId}/mii.png`,
`${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}/image?type=features`, `${process.env.NEXT_PUBLIC_STATIC_URL}/mii/${miiId}/features.png`,
], ],
}), }),
}).catch((err) => { }).catch((err) => {

View file

@ -1,115 +0,0 @@
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 "@tomodachi-share/shared/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.handle();
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",
});
}
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",
});
}

View file

@ -1,27 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import { idSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit";
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const rateLimit = new RateLimit(request, 16, "/profile/picture");
const check = await rateLimit.handle();
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 userId = parsed.data;
const filePath = path.join(process.cwd(), "uploads", "user", `${userId}.png`);
try {
const buffer = await fs.readFile(filePath);
return new NextResponse(new Uint8Array(buffer)); // convert to Uint8Array due to weird types issue
} catch {
return rateLimit.sendResponse({ error: "Image not found" }, 404);
}
}

View file

@ -7,8 +7,9 @@ export const revalidate = 43200; // update every 12 hours
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
if (!baseUrl) { const staticUrl = process.env.NEXT_PUBLIC_STATIC_URL;
console.error("NEXT_PUBLIC_BASE_URL environment variable missing"); if (!baseUrl || !staticUrl) {
console.error("NEXT_PUBLIC_BASE_URL or NEXT_PUBLIC_STATIC_URL environment variable missing");
return []; return [];
} }
@ -34,7 +35,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
lastModified: mii.createdAt, lastModified: mii.createdAt,
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.7, priority: 0.7,
images: [`${baseUrl}/mii/${mii.id}/image?type=metadata`], images: [`${staticUrl}/mii/${mii.id}/metadata.png`],
}) as SitemapRoute, }) as SitemapRoute,
), ),
...users.map( ...users.map(

View file

@ -85,7 +85,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
<p className="text-sm text-zinc-500">Are you sure? This will delete your Mii permanently. This action cannot be undone.</p> <p className="text-sm text-zinc-500">Are you sure? This will delete your Mii permanently. This action cannot be undone.</p>
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 flex overflow-hidden"> <div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 flex overflow-hidden">
<img src={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=mii`} alt="mii image" width={128} height={128} /> <img src={`${import.meta.env.VITE_STATIC_URL}/mii/${miiId}/mii.png`} alt="mii image" width={128} height={128} />
<div className="p-4 min-w-0"> <div className="p-4 min-w-0">
<p className="text-xl font-bold line-clamp-3 wrap-anywhere" title={miiName}> <p className="text-xl font-bold line-clamp-3 wrap-anywhere" title={miiName}>
{miiName} {miiName}

View file

@ -126,7 +126,7 @@ export default function MiiList({ parentPage, userId }: Props) {
{parentPage !== "admin" ? ( {parentPage !== "admin" ? (
<Link to={`/mii/${mii.id}`} className="overflow-hidden rounded-xl bg-zinc-300 shrink-0"> <Link to={`/mii/${mii.id}`} className="overflow-hidden rounded-xl bg-zinc-300 shrink-0">
<img <img
src={`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`} src={`${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/mii.png`}
width={240} width={240}
height={160} height={160}
alt="mii image" alt="mii image"
@ -136,11 +136,11 @@ export default function MiiList({ parentPage, userId }: Props) {
) : ( ) : (
<div className="grid grid-cols-2 gap-1 rounded-xl bg-zinc-200"> <div className="grid grid-cols-2 gap-1 rounded-xl bg-zinc-200">
{[ {[
`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`, `${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/mii.png`,
mii.platform === "THREE_DS" mii.platform === "THREE_DS"
? `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=qr-code` ? `${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/qr-code.png`
: `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=features`, : `${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/features.png`,
...Array.from({ length: mii.imageCount }, (_, i) => `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=image${i}`), ...Array.from({ length: mii.imageCount }, (_, i) => `${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/image${i}.png`),
].map((src, i) => ( ].map((src, i) => (
<img key={i} src={src} alt="mii image" className="w-full bg-zinc-200" /> <img key={i} src={src} alt="mii image" className="w-full bg-zinc-200" />
))} ))}

View file

@ -28,7 +28,7 @@ export default function ShareMiiButton({ miiId }: Props) {
}; };
const handleCopyImage = async () => { const handleCopyImage = async () => {
const response = await fetch(`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`); const response = await fetch(`${import.meta.env.VITE_STATIC_URL}/mii/${miiId}/metadata.png`);
const blob = await response.blob(); const blob = await response.blob();
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
@ -118,7 +118,7 @@ export default function ShareMiiButton({ miiId }: Props) {
<div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg"> <div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg">
<img <img
src={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`} src={`${import.meta.env.VITE_STATIC_URL}/mii/${miiId}/metadata.png`}
alt="mii 'metadata' image" alt="mii 'metadata' image"
width={248} width={248}
height={248} height={248}
@ -130,7 +130,7 @@ export default function ShareMiiButton({ miiId }: Props) {
<div className="flex gap-2 w-full"> <div className="flex gap-2 w-full">
{/* Save button */} {/* Save button */}
<Link <Link
to={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`} to={`${import.meta.env.VITE_STATIC_URL}/mii/${miiId}/metadata.png`}
className="pill button p-0! aspect-square size-11 cursor-pointer text-xl" className="pill button p-0! aspect-square size-11 cursor-pointer text-xl"
aria-label="Save Image" aria-label="Save Image"
data-tooltip="Save Image" data-tooltip="Save Image"

View file

@ -149,7 +149,7 @@ export default function EditMiiPage() {
try { try {
const existing = await Promise.all( const existing = await Promise.all(
Array.from({ length: mii.imageCount }, async (_, index) => { Array.from({ length: mii.imageCount }, async (_, index) => {
const path = `${API_URL}/mii/${mii.id}/image?type=image${index}`; const path = `${STATIC_URL}/mii/${mii.id}/image${index}.png`;
const response = await fetch(path); const response = await fetch(path);
const blob = await response.blob(); const blob = await response.blob();
@ -167,6 +167,7 @@ export default function EditMiiPage() {
}, [mii, mii?.id, mii?.imageCount]); }, [mii, mii?.id, mii?.imageCount]);
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
const STATIC_URL = import.meta.env.VITE_STATIC_URL;
useEffect(() => { useEffect(() => {
fetch(`${API_URL}/api/mii/${id}/info`) fetch(`${API_URL}/api/mii/${id}/info`)
@ -181,8 +182,8 @@ export default function EditMiiPage() {
setDescription(data.description); setDescription(data.description);
setGender(data.gender ?? "MALE"); setGender(data.gender ?? "MALE");
setMakeup(data.makeup ?? "PARTIAL"); setMakeup(data.makeup ?? "PARTIAL");
setMiiPortraitUri(`${API_URL}/mii/${data.id}/image?type=mii`); setMiiPortraitUri(`${STATIC_URL}/mii/${data.id}/mii.png`);
setMiiFeaturesUri(`${API_URL}/mii/${data.id}/image?type=features`); setMiiFeaturesUri(`${STATIC_URL}/mii/${data.id}/features.png`);
setYouTubeId(data.youtubeId ?? ""); setYouTubeId(data.youtubeId ?? "");
setQuarantined(data.quarantined); setQuarantined(data.quarantined);
instructions.current = deepMerge(defaultInstructions, (data.instructions as object) ?? {}); instructions.current = deepMerge(defaultInstructions, (data.instructions as object) ?? {});
@ -208,10 +209,8 @@ export default function EditMiiPage() {
<div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3"> <div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<Carousel <Carousel
images={[ images={[
miiPortraitUri ?? `${API_URL}/mii/${mii.id}/image?type=mii`, miiPortraitUri ?? `${STATIC_URL}/mii/${mii.id}/mii.png`,
...(mii.platform === "THREE_DS" ...(mii.platform === "THREE_DS" ? [`${STATIC_URL}/mii/${mii.id}/qr-code.png`] : [miiFeaturesUri ?? `${STATIC_URL}/mii/${mii.id}/features.png`]),
? [`${API_URL}/mii/${mii.id}/image?type=qr-code`]
: [miiFeaturesUri ?? `${API_URL}/mii/${mii.id}/image?type=features`]),
...files.map((file) => URL.createObjectURL(file)), ...files.map((file) => URL.createObjectURL(file)),
]} ]}
/> />

View file

@ -23,6 +23,7 @@ export default function MiiPage() {
const [isLiked, setIsLiked] = useState(false); const [isLiked, setIsLiked] = useState(false);
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
const STATIC_URL = import.meta.env.VITE_STATIC_URL;
useEffect(() => { useEffect(() => {
fetch(`${API_URL}/api/mii/${id}/info`) fetch(`${API_URL}/api/mii/${id}/info`)
@ -50,7 +51,7 @@ export default function MiiPage() {
}, [id]); }, [id]);
if (loading || !mii) return <div className="p-6 text-center">Loading...</div>; if (loading || !mii) return <div className="p-6 text-center">Loading...</div>;
const images = [...Array.from({ length: mii.imageCount ?? 0 }, (_, index) => `${API_URL}/mii/${mii.id}/image?type=image${index}`)]; const images = [...Array.from({ length: mii.imageCount ?? 0 }, (_, index) => `${STATIC_URL}/mii/${mii.id}/image${index}.png`)];
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
@ -76,7 +77,7 @@ export default function MiiPage() {
{/* Mii Image */} {/* Mii Image */}
<div className="bg-linear-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center"> <div className="bg-linear-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center">
<ImageViewer <ImageViewer
src={`${API_URL}/mii/${mii.id}/image?type=mii`} src={`${STATIC_URL}/mii/${mii.id}/mii.png`}
alt="mii headshot" alt="mii headshot"
width={250} width={250}
height={250} height={250}
@ -87,7 +88,7 @@ export default function MiiPage() {
{mii.platform === "THREE_DS" ? ( {mii.platform === "THREE_DS" ? (
<div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2"> <div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2">
<ImageViewer <ImageViewer
src={`${API_URL}/mii/${mii.id}/image?type=qr-code`} src={`${STATIC_URL}/mii/${mii.id}/qr-code.png`}
alt="mii qr code" alt="mii qr code"
width={128} width={128}
height={128} height={128}
@ -96,7 +97,7 @@ export default function MiiPage() {
</div> </div>
) : ( ) : (
<ImageViewer <ImageViewer
src={`${API_URL}/mii/${mii.id}/image?type=features`} src={`${STATIC_URL}/mii/${mii.id}/features.png`}
alt="mii features" alt="mii features"
width={300} width={300}
height={300} height={300}

View file

@ -64,7 +64,7 @@ export default function ReportMiiPage() {
<hr className="border-zinc-300" /> <hr className="border-zinc-300" />
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex"> <div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
<img src={`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`} alt="mii image" width={128} height={128} /> <img src={`${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/mii.png`} alt="mii image" width={128} height={128} />
<div className="p-4"> <div className="p-4">
<p className="text-xl font-bold line-clamp-1">{mii.name}</p> <p className="text-xl font-bold line-clamp-1">{mii.name}</p>
</div> </div>