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, "id": 1,
"name": "Frieren", "name": "Frieren",
"platform": "THREE_DS",
"imageCount": 3, "imageCount": 3,
"tags": ["anime", "frieren"], "tags": ["anime", "frieren"],
"description": "Frieren from 'Frieren: Beyond Journey's End'\r\nThe first Mii on the site!", "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) // Check Mii portrait image as well (Switch)
if (data.platform === "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); const imageValidation = await validateImage(data.miiPortraitImage);
if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400); 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"; import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { 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(); const check = await rateLimit.handle();
if (check) return check; if (check) return check;
@ -24,6 +24,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
likedBy: true, likedBy: true,
}, },
}, },
platform: true,
imageCount: true, imageCount: true,
tags: true, tags: true,
description: true, description: true,

View file

@ -74,13 +74,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (imageType === "metadata" && mii) { if (imageType === "metadata" && mii) {
// Metadata images were added after 1274 Miis were submitted, so we generate it on-the-fly // 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...`); 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 { if (error) {
buffer = await generateMetadataImage(mii, mii.user.name!); return rateLimit.sendResponse({ error }, status);
} catch (error) {
console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);
} }
buffer = metadataBuffer;
} else { } else {
return rateLimit.sendResponse({ error: "Image not found" }, 404); return rateLimit.sendResponse({ error: "Image not found" }, 404);
} }

View file

@ -30,6 +30,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
include: { include: {
user: { user: {
select: { select: {
name: true,
username: true, username: true,
}, },
}, },
@ -44,18 +45,16 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const metadataImageUrl = `/mii/${mii.id}/image?type=metadata`; const metadataImageUrl = `/mii/${mii.id}/image?type=metadata`;
const username = `@${mii.user.username}`;
return { return {
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!), metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: `${mii.name} - TomodachiShare`, 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], keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags],
creator: username, creator: mii.user.username,
openGraph: { openGraph: {
type: "article", type: "article",
title: `${mii.name} - TomodachiShare`, 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: [ images: [
{ {
url: metadataImageUrl, url: metadataImageUrl,
@ -63,19 +62,19 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}, },
], ],
publishedTime: mii.createdAt.toISOString(), publishedTime: mii.createdAt.toISOString(),
authors: username, authors: mii.user.username,
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: `${mii.name} - TomodachiShare`, 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: [ images: [
{ {
url: metadataImageUrl, url: metadataImageUrl,
alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`, alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`,
}, },
], ],
creator: username, creator: mii.user.username!,
}, },
alternates: { alternates: {
canonical: `/mii/${mii.id}`, 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 //#region Image validation
export async function validateImage(file: File): Promise<{ valid: boolean; error?: string; status?: number }> { 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 || file.size == 0) return { valid: false, error: "Empty image file" };
if (file.size > MAX_IMAGE_SIZE) if (file.size > MAX_IMAGE_SIZE) return { valid: false, error: `Image too large. Maximum size is ${MAX_IMAGE_SIZE / (1024 * 1024)}MB` };
return {
valid: false,
error: `Image too large. Maximum size is ${MAX_IMAGE_SIZE / (1024 * 1024)}MB`,
};
try { try {
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
@ -34,10 +30,7 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
// Check mime type // Check mime type
const fileType = await fileTypeFromBuffer(buffer); const fileType = await fileTypeFromBuffer(buffer);
if (!fileType || !ALLOWED_MIME_TYPES.includes(fileType.mime)) if (!fileType || !ALLOWED_MIME_TYPES.includes(fileType.mime))
return { return { valid: false, error: "Invalid image file type. Only .jpeg, .png, .gif, and .webp are allowed" };
valid: false,
error: "Invalid image file type. Only .jpeg, .png, .gif, and .webp are allowed",
};
let metadata: sharp.Metadata; let metadata: sharp.Metadata;
try { try {
@ -55,10 +48,7 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
metadata.height < MIN_IMAGE_DIMENSIONS[1] || metadata.height < MIN_IMAGE_DIMENSIONS[1] ||
metadata.height > MAX_IMAGE_DIMENSIONS[1] metadata.height > MAX_IMAGE_DIMENSIONS[1]
) { ) {
return { return { valid: false, error: "Image dimensions are invalid. Resolution must be between 128x128 and 1920x1080" };
valid: false,
error: "Image dimensions are invalid. Resolution must be between 128x128 and 1920x1080",
};
} }
// Check for inappropriate content // Check for inappropriate content
@ -72,11 +62,7 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
if (!moderationResponse.ok) { if (!moderationResponse.ok) {
console.error("Moderation API error"); console.error("Moderation API error");
return { return { valid: false, error: "Content moderation check failed", status: 500 };
valid: false,
error: "Content moderation check failed",
status: 500,
};
} }
const result = await moderationResponse.json(); const result = await moderationResponse.json();
@ -91,11 +77,7 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
return { valid: true }; return { valid: true };
} catch (error) { } catch (error) {
console.error("Error validating image:", error); console.error("Error validating image:", error);
return { return { valid: false, error: "Failed to process image file.", status: 500 };
valid: false,
error: "Failed to process image file.",
status: 500,
};
} }
} }
//#endregion //#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()); const miiUploadsDirectory = path.join(uploadsDirectory, mii.id.toString());
// Load assets concurrently // Load assets concurrently
@ -203,13 +185,7 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<B
))} ))}
</div> </div>
<div <div tw="absolute inset-0" style={{ position: "absolute", backgroundImage: "linear-gradient(to right, #fffbeb00 70%, #fffbeb);" }}></div>
tw="absolute inset-0"
style={{
position: "absolute",
backgroundImage: "linear-gradient(to right, #fffbeb00 70%, #fffbeb);",
}}
></div>
</div> </div>
{/* Author */} {/* Author */}
@ -242,11 +218,16 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<B
const buffer = await sharp(Buffer.from(svg)).png().toBuffer(); const buffer = await sharp(Buffer.from(svg)).png().toBuffer();
// Store the file // Store the file
// I tried using .webp here but the quality looked awful try {
// but it actually might be well-liked due to the hatred of .webp // I tried using .webp here but the quality looked awful
const fileLocation = path.join(miiUploadsDirectory, "metadata.png"); // but it actually might be well-liked due to the hatred of .webp
await fs.writeFile(fileLocation, buffer); 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 //#endregion

View file

@ -35,10 +35,7 @@ export const tagsSchema = z
.min(1, { error: "There must be at least 1 tag" }) .min(1, { error: "There must be at least 1 tag" })
.max(8, { error: "There cannot be more than 8 tags" }); .max(8, { error: "There cannot be more than 8 tags" });
export const idSchema = z.coerce 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" });
.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({ export const searchSchema = z.object({
q: querySchema.optional(), q: querySchema.optional(),
@ -52,8 +49,8 @@ export const searchSchema = z.object({
.map((tag) => tag.trim()) .map((tag) => tag.trim())
.filter((tag) => tag.length > 0) .filter((tag) => tag.length > 0)
), ),
platform: z.enum(MiiPlatform, { error: "Platform must be either 'THREE_DS', or 'SWITCH'" }).optional(), platform: z.enum(MiiPlatform, { error: "Platform must be either 'THREE_DS', or 'SWITCH'" }).optional(),
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(), gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
// todo: incorporate tagsSchema // todo: incorporate tagsSchema
// Pages // Pages
limit: z.coerce limit: z.coerce
@ -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" }) .min(1, { error: "Limit must be at least 1" })
.max(100, { error: "Limit cannot be more than 100" }) .max(100, { error: "Limit cannot be more than 100" })
.optional(), .optional(),
page: z.coerce 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(),
.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 // Random sort
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(), seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
}); });