This commit is contained in:
trafficlunar 2026-04-19 21:26:09 +01:00
parent d58054a587
commit 90a65102c1
11 changed files with 256 additions and 28 deletions

View file

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

View file

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

View file

@ -0,0 +1,115 @@
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

@ -0,0 +1,115 @@
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

@ -7,9 +7,8 @@ export const revalidate = 43200; // update every 12 hours
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
const staticUrl = process.env.NEXT_PUBLIC_STATIC_URL;
if (!baseUrl || !staticUrl) {
console.error("NEXT_PUBLIC_BASE_URL or NEXT_PUBLIC_STATIC_URL environment variable missing");
if (!baseUrl) {
console.error("NEXT_PUBLIC_BASE_URL environment variable missing");
return [];
}
@ -35,7 +34,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
lastModified: mii.createdAt,
changeFrequency: "weekly",
priority: 0.7,
images: [`${staticUrl}/mii/${mii.id}/metadata.png`],
images: [`${baseUrl}/mii/${mii.id}/image?type=metadata`],
}) as SitemapRoute,
),
...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>
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 flex overflow-hidden">
<img src={`${import.meta.env.VITE_STATIC_URL}/mii/${miiId}/mii.png`} alt="mii image" width={128} height={128} />
<img src={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=mii`} alt="mii image" width={128} height={128} />
<div className="p-4 min-w-0">
<p className="text-xl font-bold line-clamp-3 wrap-anywhere" title={miiName}>
{miiName}

View file

@ -126,7 +126,7 @@ export default function MiiList({ parentPage, userId }: Props) {
{parentPage !== "admin" ? (
<Link to={`/mii/${mii.id}`} className="overflow-hidden rounded-xl bg-zinc-300 shrink-0">
<img
src={`${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/mii.png`}
src={`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`}
width={240}
height={160}
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">
{[
`${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/mii.png`,
`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`,
mii.platform === "THREE_DS"
? `${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/qr-code.png`
: `${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/features.png`,
...Array.from({ length: mii.imageCount }, (_, i) => `${import.meta.env.VITE_STATIC_URL}/mii/${mii.id}/image${i}.png`),
? `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=qr-code`
: `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=features`,
...Array.from({ length: mii.imageCount }, (_, i) => `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=image${i}`),
].map((src, i) => (
<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 response = await fetch(`${import.meta.env.VITE_STATIC_URL}/mii/${miiId}/metadata.png`);
const response = await fetch(`${import.meta.env.VITE_BASE_URL}/mii/${miiId}/image?type=metadata`);
const blob = await response.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">
<img
src={`${import.meta.env.VITE_STATIC_URL}/mii/${miiId}/metadata.png`}
src={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`}
alt="mii 'metadata' image"
width={248}
height={248}
@ -130,7 +130,7 @@ export default function ShareMiiButton({ miiId }: Props) {
<div className="flex gap-2 w-full">
{/* Save button */}
<Link
to={`${import.meta.env.VITE_STATIC_URL}/mii/${miiId}/metadata.png`}
to={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`}
className="pill button p-0! aspect-square size-11 cursor-pointer text-xl"
aria-label="Save Image"
data-tooltip="Save Image"

View file

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

View file

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

View file

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