feat: 'metadata' image generation for miis

for use on search engines
This commit is contained in:
trafficlunar 2025-05-21 22:02:35 +01:00
parent ada54d46c8
commit de2c281257
22 changed files with 463 additions and 97 deletions

View file

@ -14,7 +14,7 @@ import { prisma } from "@/lib/prisma";
import { nameSchema, tagsSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { validateImage } from "@/lib/images";
import { generateMetadataImage, validateImage } from "@/lib/images";
import { convertQrCode } from "@/lib/qr-codes";
import Mii from "@/lib/mii.js/mii";
import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii";
@ -41,7 +41,7 @@ export async function POST(request: NextRequest) {
const check = await rateLimit.handle();
if (check) return check;
const response = await fetch(`${process.env.BASE_URL}/api/admin/can-submit`);
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
const { value } = await response.json();
if (!value) return rateLimit.sendResponse({ error: "Submissions are disabled" }, 409);
@ -140,7 +140,7 @@ export async function POST(request: NextRequest) {
}
try {
// Compress and upload
// Compress and store
const studioWebpBuffer = await sharp(studioBuffer).webp({ quality: 85 }).toBuffer();
const studioFileLocation = path.join(miiUploadsDirectory, "mii.webp");
@ -152,16 +152,17 @@ export async function POST(request: NextRequest) {
generatedCode.addData(byteString, "Byte");
generatedCode.make();
// Upload QR code
// Store QR code
const codeDataUrl = generatedCode.createDataURL();
const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, "");
const codeBuffer = Buffer.from(codeBase64, "base64");
// Compress and upload
// Compress and store
const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer();
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp");
await fs.writeFile(codeFileLocation, codeWebpBuffer);
await generateMetadataImage(miiRecord, session.user.username!);
} catch (error) {
// Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } });
@ -170,7 +171,7 @@ export async function POST(request: NextRequest) {
return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
}
// Compress and upload user images
// Compress and store user images
try {
await Promise.all(
images.map(async (image, index) => {
@ -192,7 +193,7 @@ export async function POST(request: NextRequest) {
},
});
} catch (error) {
console.error("Error uploading user images:", error);
console.error("Error storing user images:", error);
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
}

View file

@ -14,7 +14,7 @@ const lexend = Lexend({
});
export const metadata: Metadata = {
metadataBase: new URL(process.env.BASE_URL!),
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: "TomodachiShare - home for Tomodachi Life Miis!",
description: "Discover and share Mii residents for your Tomodachi Life island!",
keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection"],

View file

@ -1,16 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import fs from "fs/promises";
import path from "path";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { idSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { generateMetadataImage } from "@/lib/images";
import { prisma } from "@/lib/prisma";
type MiiWithUser = Prisma.MiiGetPayload<{
include: {
user: {
select: {
username: true;
};
};
};
}>;
const searchParamsSchema = z.object({
type: z
.enum(["mii", "qr-code", "image0", "image1", "image2"], {
message: "Image type must be either 'mii', 'qr-code' or 'image[number from 0 to 2]'",
.enum(["mii", "qr-code", "image0", "image1", "image2", "metadata"], {
message: "Image type must be either 'mii', 'qr-code', 'image[number from 0 to 2]' or 'metadata'",
})
.default("mii"),
});
@ -29,12 +43,72 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (!searchParamsParsed.success) return rateLimit.sendResponse({ error: searchParamsParsed.error.errors[0].message }, 400);
const { type: imageType } = searchParamsParsed.data;
const filePath = path.join(process.cwd(), "uploads", "mii", miiId.toString(), `${imageType}.webp`);
const fileExtension = imageType === "metadata" ? ".png" : ".webp";
const filePath = path.join(process.cwd(), "uploads", "mii", miiId.toString(), `${imageType}${fileExtension}`);
let buffer: Buffer | undefined;
// Only find Mii if image type is 'metadata'
let mii: MiiWithUser | null = null;
if (imageType === "metadata") {
mii = await prisma.mii.findUnique({
where: {
id: miiId,
},
include: {
user: {
select: {
username: true,
},
},
},
});
if (!mii) {
return rateLimit.sendResponse({ error: "Mii not found" }, 404);
}
}
try {
const buffer = await fs.readFile(filePath);
return new NextResponse(buffer);
// Try to read file
buffer = await fs.readFile(filePath);
} catch {
return rateLimit.sendResponse({ error: "Image not found" }, 404);
// 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.username!);
if (error) {
return rateLimit.sendResponse({ error }, status);
}
buffer = metadataBuffer;
} else {
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 username = slugify(mii.user.username!);
const filename = `${name}-mii-${tags}-by-${username}.png`;
return new NextResponse(buffer, {
headers: {
"Content-Disposition": `inline; filename="${filename}"`,
},
});
}
return new NextResponse(buffer);
}

View file

@ -38,13 +38,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
// Bots get redirected anyways
if (!mii) return {};
const miiImageUrl = `/mii/${mii.id}/image?type=mii`;
const qrCodeUrl = `/mii/${mii.id}/image?type=qr-code`;
const metadataImageUrl = `/mii/${mii.id}/image?type=metadata`;
const username = `@${mii.user.username}`;
return {
metadataBase: new URL(process.env.BASE_URL!),
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`,
keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags],
@ -53,7 +52,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
type: "article",
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`,
images: [miiImageUrl, qrCodeUrl],
images: [metadataImageUrl],
publishedTime: mii.createdAt.toISOString(),
authors: username,
},
@ -61,7 +60,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
card: "summary_large_image",
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`,
images: [miiImageUrl, qrCodeUrl],
images: [metadataImageUrl],
creator: username,
},
alternates: {

View file

@ -19,7 +19,7 @@ export async function generateMetadata({ searchParams }: Props): Promise<Metadat
const description = `Discover Miis tagged '${tags}' for your Tomodachi Life island!`;
return {
metadataBase: new URL(process.env.BASE_URL!),
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: `Miis tagged with '${tags}' - TomodachiShare`,
description,
keywords: [...tags, "mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection"],

View file

@ -38,7 +38,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
});
return {
metadataBase: new URL(process.env.BASE_URL!),
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: `${user.name} (@${user.username}) - TomodachiShare`,
description: `View ${user.name}'s profile on TomodachiShare. Creator of ${user._count.miis} Miis. Member since ${joinDate}.`,
keywords: ["mii", "tomodachi life", "nintendo", "mii creator", "mii collection", "profile"],

View file

@ -7,6 +7,6 @@ export default function robots(): MetadataRoute.Robots {
allow: "/",
disallow: ["/*?page", "/create-username", "/edit/*", "/profile/settings", "/random", "/submit", "/report/mii/*", "/report/user/*", "/admin"],
},
sitemap: `${process.env.BASE_URL}/sitemap.xml`,
sitemap: `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`,
};
}

View file

@ -4,9 +4,9 @@ import type { MetadataRoute } from "next";
type SitemapRoute = MetadataRoute.Sitemap[0];
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.BASE_URL;
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
if (!baseUrl) {
console.error("BASE_URL environment variable missing");
console.error("NEXT_PUBLIC_BASE_URL environment variable missing");
return [];
}

View file

@ -23,7 +23,7 @@ export default async function SubmitPage() {
if (!session.user.username) redirect("/create-username");
// Check if submissions are disabled
const response = await fetch(`${process.env.BASE_URL}/api/admin/can-submit`);
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
const { value } = await response.json();
if (!value)

View file

@ -1,68 +0,0 @@
import sharp from "sharp";
import { fileTypeFromBuffer } from "file-type";
const MIN_IMAGE_DIMENSIONS = 128;
const MAX_IMAGE_DIMENSIONS = 1024;
const MAX_IMAGE_SIZE = 1024 * 1024; // 1 MB
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
export async function validateImage(file: File): Promise<{ valid: boolean; error?: string; status?: number }> {
if (!file || file.size == 0) return { valid: false, error: "Empty image file" };
if (file.size > MAX_IMAGE_SIZE) return { valid: false, error: `Image too large. Maximum size is ${MAX_IMAGE_SIZE / (1024 * 1024)}MB` };
try {
const buffer = Buffer.from(await file.arrayBuffer());
// Check mime type
const fileType = await fileTypeFromBuffer(buffer);
if (!fileType || !ALLOWED_MIME_TYPES.includes(fileType.mime))
return { valid: false, error: "Invalid image file type. Only .jpeg, .png, .gif, and .webp are allowed" };
let metadata: sharp.Metadata;
try {
metadata = await sharp(buffer).metadata();
} catch {
return { valid: false, error: "Invalid or corrupted image file" };
}
// Check image dimensions
if (
!metadata.width ||
!metadata.height ||
metadata.width < MIN_IMAGE_DIMENSIONS ||
metadata.width > MAX_IMAGE_DIMENSIONS ||
metadata.height < MIN_IMAGE_DIMENSIONS ||
metadata.height > MAX_IMAGE_DIMENSIONS
) {
return { valid: false, error: "Image dimensions are invalid. Width and height must be between 128px and 1024px" };
}
// Check for inappropriate content
// https://github.com/trafficlunar/api-moderation
try {
const blob = new Blob([buffer]);
const formData = new FormData();
formData.append("image", blob);
const moderationResponse = await fetch("https://api.trafficlunar.net/moderate/image", { method: "POST", body: formData });
if (!moderationResponse.ok) {
console.error("Moderation API error");
return { valid: false, error: "Content moderation check failed", status: 500 };
}
const result = await moderationResponse.json();
if (result.error) {
return { valid: false, error: result.error };
}
} catch (moderationError) {
console.error("Error fetching moderation API:", moderationError);
return { valid: false, error: "Moderation API is down", status: 503 };
}
return { valid: true };
} catch (error) {
console.error("Error validating image:", error);
return { valid: false, error: "Failed to process image file.", status: 500 };
}
}

212
src/lib/images.tsx Normal file
View file

@ -0,0 +1,212 @@
// This file's extension is .tsx because I am using JSX for satori to generate images
// These are disabled because satori is not Next.JS and is turned into an image anyways
/* eslint-disable jsx-a11y/alt-text */
/* eslint-disable @next/next/no-img-element */
import type { ReactNode } from "react";
import fs from "fs/promises";
import path from "path";
import sharp from "sharp";
import { fileTypeFromBuffer } from "file-type";
import satori, { Font } from "satori";
import { Mii } from "@prisma/client";
const MIN_IMAGE_DIMENSIONS = 128;
const MAX_IMAGE_DIMENSIONS = 1024;
const MAX_IMAGE_SIZE = 1024 * 1024; // 1 MB
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
//#region Image validation
export async function validateImage(file: File): Promise<{ valid: boolean; error?: string; status?: number }> {
if (!file || file.size == 0) return { valid: false, error: "Empty image file" };
if (file.size > MAX_IMAGE_SIZE) return { valid: false, error: `Image too large. Maximum size is ${MAX_IMAGE_SIZE / (1024 * 1024)}MB` };
try {
const buffer = Buffer.from(await file.arrayBuffer());
// Check mime type
const fileType = await fileTypeFromBuffer(buffer);
if (!fileType || !ALLOWED_MIME_TYPES.includes(fileType.mime))
return { valid: false, error: "Invalid image file type. Only .jpeg, .png, .gif, and .webp are allowed" };
let metadata: sharp.Metadata;
try {
metadata = await sharp(buffer).metadata();
} catch {
return { valid: false, error: "Invalid or corrupted image file" };
}
// Check image dimensions
if (
!metadata.width ||
!metadata.height ||
metadata.width < MIN_IMAGE_DIMENSIONS ||
metadata.width > MAX_IMAGE_DIMENSIONS ||
metadata.height < MIN_IMAGE_DIMENSIONS ||
metadata.height > MAX_IMAGE_DIMENSIONS
) {
return { valid: false, error: "Image dimensions are invalid. Width and height must be between 128px and 1024px" };
}
// Check for inappropriate content
// https://github.com/trafficlunar/api-moderation
try {
const blob = new Blob([buffer]);
const formData = new FormData();
formData.append("image", blob);
const moderationResponse = await fetch("https://api.trafficlunar.net/moderate/image", { method: "POST", body: formData });
if (!moderationResponse.ok) {
console.error("Moderation API error");
return { valid: false, error: "Content moderation check failed", status: 500 };
}
const result = await moderationResponse.json();
if (result.error) {
return { valid: false, error: result.error };
}
} catch (moderationError) {
console.error("Error fetching moderation API:", moderationError);
return { valid: false, error: "Moderation API is down", status: 503 };
}
return { valid: true };
} catch (error) {
console.error("Error validating image:", error);
return { valid: false, error: "Failed to process image file.", status: 500 };
}
}
//#endregion
//#region Generating 'metadata' image type
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
const fontCache: Record<string, Font | null> = {
regular: null,
medium: null,
semiBold: null,
bold: null,
extraBold: null,
black: null,
};
// Load fonts only once and cache them
const loadFonts = async (): Promise<Font[]> => {
const weights = [
["regular", 400],
["medium", 500],
["semiBold", 600],
["bold", 700],
["extraBold", 800],
["black", 900],
] as const;
return Promise.all(
weights.map(async ([weight, value]) => {
if (!fontCache[weight]) {
const filePath = path.join(process.cwd(), `public/fonts/lexend-${weight}.ttf`);
const data = await fs.readFile(filePath);
fontCache[weight] = {
name: "Lexend",
data,
weight: value,
};
}
return fontCache[weight]!;
})
);
};
export async function generateMetadataImage(mii: Mii, author: string): Promise<{ buffer?: Buffer; error?: string; status?: number }> {
const miiUploadsDirectory = path.join(uploadsDirectory, mii.id.toString());
// Load assets concurrently
const [miiImage, qrCodeImage, fonts] = await Promise.all([
// Read and convert the .webp images to .png (because satori doesn't support it)
fs.readFile(path.join(miiUploadsDirectory, "mii.webp")).then((buffer) =>
sharp(buffer)
.png()
.toBuffer()
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`)
),
fs.readFile(path.join(miiUploadsDirectory, "qr-code.webp")).then((buffer) =>
sharp(buffer)
.png()
.toBuffer()
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`)
),
loadFonts(),
]);
const jsx: ReactNode = (
<div tw="w-full h-full bg-amber-50 border-2 border-amber-500 rounded-2xl p-4 flex flex-col">
<div tw="flex w-full">
{/* Mii image */}
<div tw="w-80 rounded-xl flex justify-center mr-2" style={{ backgroundImage: "linear-gradient(to bottom, #fef3c7, #fde68a);" }}>
<img src={miiImage} width={248} height={248} style={{ filter: "drop-shadow(0 10px 8px #00000024) drop-shadow(0 4px 3px #00000024)" }} />
</div>
{/* QR code */}
<div tw="w-60 bg-amber-200 rounded-xl flex justify-center items-center">
<img src={qrCodeImage} width={190} height={190} tw="border-2 border-amber-300 rounded-lg" />
</div>
</div>
<div tw="flex flex-col w-full h-30 relative">
{/* Mii name */}
<span tw="text-4xl font-extrabold text-amber-700 mt-2" style={{ display: "block", lineClamp: 1, wordBreak: "break-word" }}>
{mii.name}
</span>
{/* Tags */}
<div id="tags" tw="flex flex-wrap mt-1 w-full">
{mii.tags.map((tag) => (
<span key={tag} tw="mr-1 px-2 py-1 bg-orange-300 rounded-full text-sm">
{tag}
</span>
))}
</div>
{/* Author */}
<div tw="flex text-sm mt-2">
By: <span tw="ml-1.5 font-semibold">@{author}</span>
</div>
{/* Watermark */}
<div tw="absolute bottom-0 right-0 flex items-center">
<img src={`${process.env.NEXT_PUBLIC_BASE_URL}/logo.svg`} height={40} />
{/* I tried using text-orange-400 but it wasn't correct..? */}
<span tw="ml-2 font-black text-xl" style={{ color: "#FF8904" }}>
TomodachiShare
</span>
</div>
</div>
</div>
);
const svg = await satori(jsx, {
width: 600,
height: 400,
fonts,
});
// Convert .svg to .png
const buffer = await sharp(Buffer.from(svg)).png().toBuffer();
// Store the file
try {
// I tried using .webp here but the quality looked awful
// but it actually might be well-liked due to the hatred of .webp
const fileLocation = path.join(miiUploadsDirectory, "metadata.png");
await fs.writeFile(fileLocation, buffer);
} catch (error) {
console.error("Error storing 'metadata' image type", error);
return { error: `Failed to store metadata image for ${mii.id}`, status: 500 };
}
return { buffer };
}
//#endregion