mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-03-28 19:23:15 +00:00
feat: random stuff
This commit is contained in:
parent
2af1bf18a6
commit
cd34fb983d
7 changed files with 36 additions and 65 deletions
1
API.md
1
API.md
|
|
@ -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!",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue