diff --git a/prisma/migrations/20250913120915_switch_sequel/migration.sql b/prisma/migrations/20250913120915_switch_sequel/migration.sql new file mode 100644 index 0000000..f16a8bd --- /dev/null +++ b/prisma/migrations/20250913120915_switch_sequel/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "public"."MiiPlatform" AS ENUM ('SWITCH', 'THREE_DS'); + +-- AlterTable +ALTER TABLE "public"."miis" ADD COLUMN "platform" "public"."MiiPlatform" NOT NULL DEFAULT 'THREE_DS', +ALTER COLUMN "firstName" DROP NOT NULL, +ALTER COLUMN "lastName" DROP NOT NULL, +ALTER COLUMN "islandName" DROP NOT NULL, +ALTER COLUMN "allowedCopying" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ebd21f2..e3f09b0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,18 +68,20 @@ model Session { } model Mii { - id Int @id @default(autoincrement()) - userId Int - name String @db.VarChar(64) - imageCount Int @default(0) - tags String[] - description String? @db.VarChar(256) + id Int @id @default(autoincrement()) + userId Int - firstName String - lastName String + name String @db.VarChar(64) + imageCount Int @default(0) + tags String[] + description String? @db.VarChar(256) + platform MiiPlatform @default(THREE_DS) + + firstName String? + lastName String? gender MiiGender? - islandName String - allowedCopying Boolean + islandName String? + allowedCopying Boolean? createdAt DateTime @default(now()) @@ -153,6 +155,11 @@ model Punishment { @@map("punishments") } +enum MiiPlatform { + SWITCH + THREE_DS // can't start with a number +} + enum MiiGender { MALE FEMALE diff --git a/src/app/api/mii/[id]/edit/route.ts b/src/app/api/mii/[id]/edit/route.ts index 6e42dc8..293714b 100644 --- a/src/app/api/mii/[id]/edit/route.ts +++ b/src/app/api/mii/[id]/edit/route.ts @@ -136,7 +136,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } } else if (description === undefined) { // If images or description were not changed, regenerate the metadata image - await generateMetadataImage(mii, mii.user.username!); + try { + await generateMetadataImage(mii, mii.user.username!); + } catch (error) { + console.error(error); + return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500); + } } return rateLimit.sendResponse({ success: true }); diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts index fa611ed..070546c 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -7,7 +7,7 @@ import sharp from "sharp"; import qrcode from "qrcode-generator"; import { profanity } from "@2toad/profanity"; -import { MiiGender } from "@prisma/client"; +import { MiiGender, MiiPlatform } from "@prisma/client"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; @@ -21,29 +21,52 @@ import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii"; const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); -const submitSchema = z.object({ - name: nameSchema, - tags: tagsSchema, - description: z.string().trim().max(256).optional(), - qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, { error: "QR code size is not a valid Tomodachi Life QR code" }), - image1: z.union([z.instanceof(File), z.any()]).optional(), - image2: z.union([z.instanceof(File), z.any()]).optional(), - image3: z.union([z.instanceof(File), z.any()]).optional(), -}); +const submitSchema = z + .object({ + platform: z.enum(MiiPlatform).default("THREE_DS"), + name: nameSchema, + tags: tagsSchema, + description: z.string().trim().max(256).optional(), + + // Switch + gender: z.enum(MiiGender).default("MALE"), + miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(), + + // QR code + qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, { error: "QR code size is not a valid Tomodachi Life QR code" }), + + // Custom images + image1: z.union([z.instanceof(File), z.any()]).optional(), + image2: z.union([z.instanceof(File), z.any()]).optional(), + image3: z.union([z.instanceof(File), z.any()]).optional(), + }) + .refine( + (data) => { + // If platform is Switch, gender and miiPortraitImage must be present + if (data.platform === "SWITCH") { + return data.gender !== undefined && data.miiPortraitImage !== undefined; + } + return true; + }, + { + message: "Gender and Mii portrait image are required for Switch platform", + path: ["gender", "miiPortraitImage"], + } + ); export async function POST(request: NextRequest) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - const rateLimit = new RateLimit(request, 2); + const rateLimit = new RateLimit(request, 3); const check = await rateLimit.handle(); if (check) return check; 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); + if (!value) return rateLimit.sendResponse({ error: "Submissions are temporarily disabled" }, 503); - // Parse data + // Parse tags and QR code as JSON const formData = await request.formData(); let rawTags: string[]; @@ -52,64 +75,85 @@ export async function POST(request: NextRequest) { rawTags = JSON.parse(formData.get("tags") as string); rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string); } catch { - return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR bytes" }, 400); + return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400); } + // Parse and check all submission info const parsed = submitSchema.safeParse({ + platform: formData.get("platform"), name: formData.get("name"), tags: rawTags, description: formData.get("description"), + + gender: formData.get("gender") ?? undefined, // ZOD MOMENT + miiPortraitImage: formData.get("miiPortraitImage"), + qrBytesRaw: rawQrBytesRaw, + image1: formData.get("image1"), image2: formData.get("image2"), image3: formData.get("image3"), }); if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400); - const { name: uncensoredName, tags: uncensoredTags, description: uncensoredDescription, qrBytesRaw, image1, image2, image3 } = parsed.data; + const data = parsed.data; // Censor potential inappropriate words - const name = profanity.censor(uncensoredName); - const tags = uncensoredTags.map((t) => profanity.censor(t)); - const description = uncensoredDescription && profanity.censor(uncensoredDescription); + const name = profanity.censor(data.name); + const tags = data.tags.map((t) => profanity.censor(t)); + const description = data.description && profanity.censor(data.description); // Validate image files - const images: File[] = []; + const customImages: File[] = []; - for (const img of [image1, image2, image3]) { + for (const img of [data.image1, data.image2, data.image3]) { if (!img) continue; const imageValidation = await validateImage(img); if (imageValidation.valid) { - images.push(img); + customImages.push(img); } else { return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400); } } - const qrBytes = new Uint8Array(qrBytesRaw); + // Check Mii portrait image as well (Switch) + if (data.platform === "SWITCH") { + const imageValidation = await validateImage(data.miiPortraitImage); + if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400); + } - // Convert QR code to JS - let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; - try { - conversion = convertQrCode(qrBytes); - } catch (error) { - return rateLimit.sendResponse({ error }, 400); + const qrBytes = new Uint8Array(data.qrBytesRaw); + + // Convert QR code to JS (3DS) + let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | undefined; + if (data.platform === "THREE_DS") { + try { + conversion = convertQrCode(qrBytes); + } catch (error) { + return rateLimit.sendResponse({ error }, 400); + } } // Create Mii in database const miiRecord = await prisma.mii.create({ data: { userId: Number(session.user.id), + platform: data.platform, name, tags, description, + gender: data.gender ?? "MALE", - firstName: conversion.tomodachiLifeMii.firstName, - lastName: conversion.tomodachiLifeMii.lastName, - gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE, - islandName: conversion.tomodachiLifeMii.islandName, - allowedCopying: conversion.mii.allowCopying, + // Automatically detect certain information if on 3DS + ...(data.platform === "THREE_DS" && + conversion && { + firstName: conversion.tomodachiLifeMii.firstName, + lastName: conversion.tomodachiLifeMii.lastName, + gender: conversion.mii.gender == 0 ? MiiGender.MALE : MiiGender.FEMALE, + islandName: conversion.tomodachiLifeMii.islandName, + allowedCopying: conversion.mii.allowCopying, + }), }, }); @@ -117,33 +161,37 @@ export async function POST(request: NextRequest) { const miiUploadsDirectory = path.join(uploadsDirectory, miiRecord.id.toString()); await fs.mkdir(miiUploadsDirectory, { recursive: true }); - // Download the image of the Mii - let studioBuffer: Buffer; try { - const studioUrl = conversion.mii.studioUrl({ width: 512 }); - const studioResponse = await fetch(studioUrl); + let portraitBuffer: Buffer | undefined; - if (!studioResponse.ok) { - throw new Error(`Failed to fetch Mii image ${studioResponse.status}`); + // Download the image of the Mii (3DS) + if (data.platform === "THREE_DS") { + const studioUrl = conversion?.mii.studioUrl({ width: 512 }); + const studioResponse = await fetch(studioUrl!); + + if (!studioResponse.ok) { + throw new Error(`Failed to fetch Mii image ${studioResponse.status}`); + } + + portraitBuffer = Buffer.from(await studioResponse.arrayBuffer()); + } else if (data.platform === "SWITCH") { + portraitBuffer = Buffer.from(await data.miiPortraitImage.arrayBuffer()); } - const studioArrayBuffer = await studioResponse.arrayBuffer(); - studioBuffer = Buffer.from(studioArrayBuffer); + if (!portraitBuffer) throw Error("Mii portrait buffer not initialised"); + const webpBuffer = await sharp(portraitBuffer).webp({ quality: 85 }).toBuffer(); + const fileLocation = path.join(miiUploadsDirectory, "mii.webp"); + + await fs.writeFile(fileLocation, webpBuffer); } catch (error) { // Clean up if something went wrong await prisma.mii.delete({ where: { id: miiRecord.id } }); - console.error("Failed to download Mii image:", error); - return rateLimit.sendResponse({ error: "Failed to download Mii image" }, 500); + console.error("Failed to download/store Mii portrait:", error); + return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500); } try { - // Compress and store - const studioWebpBuffer = await sharp(studioBuffer).webp({ quality: 85 }).toBuffer(); - const studioFileLocation = path.join(miiUploadsDirectory, "mii.webp"); - - await fs.writeFile(studioFileLocation, studioWebpBuffer); - // Generate a new QR code for aesthetic reasons const byteString = String.fromCharCode(...qrBytes); const generatedCode = qrcode(0, "L"); @@ -160,19 +208,25 @@ export async function POST(request: NextRequest) { 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 } }); - console.error("Error processing Mii files:", error); - return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500); + console.error("Error generating QR code:", error); + return rateLimit.sendResponse({ error: "Failed to generate QR code" }, 500); + } + + try { + await generateMetadataImage(miiRecord, session.user.username!); + } catch (error) { + console.error(error); + return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}` }, 500); } // Compress and store user images try { await Promise.all( - images.map(async (image, index) => { + customImages.map(async (image, index) => { const buffer = Buffer.from(await image.arrayBuffer()); const webpBuffer = await sharp(buffer).webp({ quality: 85 }).toBuffer(); const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`); @@ -187,7 +241,7 @@ export async function POST(request: NextRequest) { id: miiRecord.id, }, data: { - imageCount: images.length, + imageCount: customImages.length, }, }); } catch (error) { diff --git a/src/app/mii/[id]/image/route.ts b/src/app/mii/[id]/image/route.ts index 5fa4526..9591a4a 100644 --- a/src/app/mii/[id]/image/route.ts +++ b/src/app/mii/[id]/image/route.ts @@ -66,13 +66,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.username!); - if (error) { - return rateLimit.sendResponse({ error }, status); + try { + buffer = await generateMetadataImage(mii, mii.user.username!); + } catch (error) { + console.error(error); + return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500); } - - buffer = metadataBuffer; } else { return rateLimit.sendResponse({ error: "Image not found" }, 404); } diff --git a/src/app/mii/[id]/page.tsx b/src/app/mii/[id]/page.tsx index 5351772..8e7381b 100644 --- a/src/app/mii/[id]/page.tsx +++ b/src/app/mii/[id]/page.tsx @@ -109,13 +109,13 @@ export default async function MiiPage({ params }: Props) {
{/* Mii Image */} -
+
{/* QR Code */} @@ -131,23 +131,25 @@ export default async function MiiPage({ params }: Props) {
{/* Mii Info */} -
    -
  • - Name:{" "} - - {mii.firstName} {mii.lastName} - -
  • -
  • - From: {mii.islandName} Island -
  • -
  • - Allowed Copying: -
  • -
+ {mii.platform === "THREE_DS" && ( +
    +
  • + Name:{" "} + + {mii.firstName} {mii.lastName} + +
  • +
  • + From: {mii.islandName} Island +
  • +
  • + Allowed Copying: +
  • +
+ )} {/* Mii Gender */} -
+
("SWITCH"); + const [name, setName] = useState(""); + const [tags, setTags] = useState([]); + const [description, setDescription] = useState(""); + const [gender, setGender] = useState("MALE"); + const [qrBytesRaw, setQrBytesRaw] = useState([]); + + const [miiPortraitUri, setMiiPortraitUri] = useState(); + const [generatedQrCodeUri, setGeneratedQrCodeUri] = useState(); + + const [error, setError] = useState(undefined); const [files, setFiles] = useState([]); const handleDrop = useCallback( @@ -34,17 +48,6 @@ export default function SubmitForm() { [files.length] ); - const [isQrScannerOpen, setIsQrScannerOpen] = useState(false); - const [studioUrl, setStudioUrl] = useState(); - const [generatedQrCodeUrl, setGeneratedQrCodeUrl] = useState(); - - const [error, setError] = useState(undefined); - - const [name, setName] = useState(""); - const [tags, setTags] = useState([]); - const [description, setDescription] = useState(""); - const [qrBytesRaw, setQrBytesRaw] = useState([]); - const handleSubmit = async () => { // Validate before sending request const nameValidation = nameSchema.safeParse(name); @@ -60,6 +63,7 @@ export default function SubmitForm() { // Send request to server const formData = new FormData(); + formData.append("platform", platform); formData.append("name", name); formData.append("tags", JSON.stringify(tags)); formData.append("description", description); @@ -69,6 +73,14 @@ export default function SubmitForm() { formData.append(`image${index + 1}`, file); }); + if (platform === "SWITCH") { + const response = await fetch(miiPortraitUri!); + const blob = await response.blob(); + + formData.append("gender", gender); + formData.append("miiPortraitImage", blob); + } + const response = await fetch("/api/submit", { method: "POST", body: formData, @@ -96,38 +108,41 @@ export default function SubmitForm() { return; } - // Convert QR code to JS - let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; - try { - conversion = convertQrCode(qrBytes); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - return; + // Convert QR code to JS (3DS) + if (platform === "THREE_DS") { + let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; + try { + conversion = convertQrCode(qrBytes); + setMiiPortraitUri(conversion.mii.studioUrl({ width: 512 })); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + return; + } } + // Generate a new QR code for aesthetic reasons try { - setStudioUrl(conversion.mii.studioUrl({ width: 512 })); - - // Generate a new QR code for aesthetic reasons const byteString = String.fromCharCode(...qrBytes); const generatedCode = qrcode(0, "L"); generatedCode.addData(byteString, "Byte"); generatedCode.make(); - setGeneratedQrCodeUrl(generatedCode.createDataURL()); + setGeneratedQrCodeUri(generatedCode.createDataURL()); } catch { - setError("Failed to get and/or generate Mii images"); + setError("Failed to regenerate QR code"); } }; preview(); - }, [qrBytesRaw]); + }, [qrBytesRaw, platform]); return (
- URL.createObjectURL(file))]} /> + URL.createObjectURL(file))]} + />

@@ -162,6 +177,46 @@ export default function SubmitForm() {

+ {/* Platform select */} +
+ +
+ {/* Animated indicator */} +
+ + {/* Switch button */} + + + {/* 3DS button */} + +
+
+ + {/* Name */}
+ {/* Description */}
-