feat: random stuff

This commit is contained in:
trafficlunar 2026-01-02 16:36:59 +00:00
parent 2af1bf18a6
commit cd34fb983d
7 changed files with 36 additions and 65 deletions

1
API.md
View file

@ -106,6 +106,7 @@ https://tomodachishare.com/mii/1/data
{
"id": 1,
"name": "Frieren",
"platform": "THREE_DS",
"imageCount": 3,
"tags": ["anime", "frieren"],
"description": "Frieren from 'Frieren: Beyond Journey's End'\r\nThe first Mii on the site!",

View file

@ -120,12 +120,8 @@ export async function POST(request: NextRequest) {
}
}
console.log(data.miiPortraitImage);
// Check Mii portrait image as well (Switch)
if (data.platform === "SWITCH") {
if (data.miiPortraitImage.length === 0) return rateLimit.sendResponse({ error: "No mii portrait found!" }, 400);
const imageValidation = await validateImage(data.miiPortraitImage);
if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
}

View file

@ -5,7 +5,7 @@ import { RateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const rateLimit = new RateLimit(request, 200, "/mii/data");
const rateLimit = new RateLimit(request, 3, "/mii/data");
const check = await rateLimit.handle();
if (check) return check;
@ -24,6 +24,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
likedBy: true,
},
},
platform: true,
imageCount: true,
tags: true,
description: true,

View file

@ -74,13 +74,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
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!);
try {
buffer = await generateMetadataImage(mii, mii.user.name!);
} catch (error) {
console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);
if (error) {
return rateLimit.sendResponse({ error }, status);
}
buffer = metadataBuffer;
} else {
return rateLimit.sendResponse({ error: "Image not found" }, 404);
}

View file

@ -30,6 +30,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
include: {
user: {
select: {
name: true,
username: true,
},
},
@ -44,18 +45,16 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const metadataImageUrl = `/mii/${mii.id}/image?type=metadata`;
const username = `@${mii.user.username}`;
return {
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 with ${mii._count.likedBy} likes.`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags],
creator: username,
creator: mii.user.username,
openGraph: {
type: "article",
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [
{
url: metadataImageUrl,
@ -63,19 +62,19 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
},
],
publishedTime: mii.createdAt.toISOString(),
authors: username,
authors: mii.user.username,
},
twitter: {
card: "summary_large_image",
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [
{
url: metadataImageUrl,
alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`,
},
],
creator: username,
creator: mii.user.username!,
},
alternates: {
canonical: `/mii/${mii.id}`,

View file

@ -22,11 +22,7 @@ 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`,
};
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());
@ -34,10 +30,7 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
// 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",
};
return { valid: false, error: "Invalid image file type. Only .jpeg, .png, .gif, and .webp are allowed" };
let metadata: sharp.Metadata;
try {
@ -55,10 +48,7 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
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",
};
return { valid: false, error: "Image dimensions are invalid. Resolution must be between 128x128 and 1920x1080" };
}
// Check for inappropriate content
@ -72,11 +62,7 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
if (!moderationResponse.ok) {
console.error("Moderation API error");
return {
valid: false,
error: "Content moderation check failed",
status: 500,
};
return { valid: false, error: "Content moderation check failed", status: 500 };
}
const result = await moderationResponse.json();
@ -91,11 +77,7 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
return { valid: true };
} catch (error) {
console.error("Error validating image:", error);
return {
valid: false,
error: "Failed to process image file.",
status: 500,
};
return { valid: false, error: "Failed to process image file.", status: 500 };
}
}
//#endregion
@ -139,7 +121,7 @@ const loadFonts = async (): Promise<Font[]> => {
);
};
export async function generateMetadataImage(mii: Mii, author: string): Promise<Buffer> {
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
@ -203,13 +185,7 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<B
))}
</div>
<div
tw="absolute inset-0"
style={{
position: "absolute",
backgroundImage: "linear-gradient(to right, #fffbeb00 70%, #fffbeb);",
}}
></div>
<div tw="absolute inset-0" style={{ position: "absolute", backgroundImage: "linear-gradient(to right, #fffbeb00 70%, #fffbeb);" }}></div>
</div>
{/* Author */}
@ -242,11 +218,16 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<B
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;
return { buffer };
}
//#endregion

View file

@ -35,10 +35,7 @@ export const tagsSchema = z
.min(1, { error: "There must be at least 1 tag" })
.max(8, { error: "There cannot be more than 8 tags" });
export const idSchema = z.coerce
.number({ error: "ID must be a number" })
.int({ error: "ID must be an integer" })
.positive({ error: "ID must be valid" });
export const idSchema = z.coerce.number({ error: "ID must be a number" }).int({ error: "ID must be an integer" }).positive({ error: "ID must be valid" });
export const searchSchema = z.object({
q: querySchema.optional(),
@ -62,11 +59,7 @@ gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }
.min(1, { error: "Limit must be at least 1" })
.max(100, { error: "Limit cannot be more than 100" })
.optional(),
page: z.coerce
.number({ error: "Page must be a number" })
.int({ error: "Page must be an integer" })
.min(1, { error: "Page must be at least 1" })
.optional(),
page: z.coerce.number({ error: "Page must be a number" }).int({ error: "Page must be an integer" }).min(1, { error: "Page must be at least 1" }).optional(),
// Random sort
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
});