// This file's extension is .tsx because JSX is used for satori to generate images // Warnings below are disabled since 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, 128]; const MAX_IMAGE_DIMENSIONS = [1920, 1080]; const MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4 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[0] || metadata.width > MAX_IMAGE_DIMENSIONS[0] || metadata.height < MIN_IMAGE_DIMENSIONS[1] || metadata.height > MAX_IMAGE_DIMENSIONS[1] ) { return { valid: false, error: "Image dimensions are invalid. Resolution must be between 128x128 and 1920x1080", }; } // 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 = { regular: null, medium: null, semiBold: null, bold: null, extraBold: null, black: null, }; // Load fonts only once and cache them const loadFonts = async (): Promise => { 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 { 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 = (
{/* Mii image */}
{/* QR code */}
{/* Mii name */} {mii.name} {/* Tags */}
{mii.tags.map((tag) => ( {tag} ))}
{/* Author */}
By{" "} {author}
{/* Watermark */}
{/* I tried using text-orange-400 but it wasn't correct..? */} TomodachiShare
); 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 // 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); return buffer; } //#endregion