diff --git a/prisma/migrations/20250407113906_images_to_image_count/migration.sql b/prisma/migrations/20250407113906_images_to_image_count/migration.sql new file mode 100644 index 0000000..d2474de --- /dev/null +++ b/prisma/migrations/20250407113906_images_to_image_count/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `images` on the `miis` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "miis" DROP COLUMN "images", +ADD COLUMN "imageCount" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5d4d98f..0b10cfe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -61,11 +61,11 @@ model Session { } model Mii { - id Int @id @default(autoincrement()) - userId Int - name String @db.VarChar(64) - images String[] - tags String[] + id Int @id @default(autoincrement()) + userId Int + name String @db.VarChar(64) + imageCount Int @default(0) + tags String[] firstName String lastName String diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts index ced0c0a..a8aed1d 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -4,14 +4,14 @@ import fs from "fs/promises"; import path from "path"; import sharp from "sharp"; -import { AES_CCM } from "@trafficlunar/asmcrypto.js"; import qrcode from "qrcode-generator"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; -import { MII_DECRYPTION_KEY } from "@/lib/constants"; import { nameSchema, tagsSchema } from "@/lib/schemas"; +import { validateImage } from "@/lib/images"; +import { convertQrCode } from "@/lib/qr-codes"; import Mii from "@/lib/mii.js/mii"; import TomodachiLifeMii from "@/lib/tomodachi-life-mii"; @@ -21,63 +21,50 @@ export async function POST(request: Request) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - const { name, tags, qrBytesRaw } = await request.json(); + const formData = await request.formData(); + + const name = formData.get("name") as string; + const tags: string[] = JSON.parse(formData.get("tags") as string); + const qrBytesRaw: number[] = JSON.parse(formData.get("qrBytesRaw") as string); + + const image1 = formData.get("image1") as File; + const image2 = formData.get("image2") as File; + const image3 = formData.get("image3") as File; + if (!name) return NextResponse.json({ error: "Name is required" }, { status: 400 }); if (!tags || tags.length == 0) return NextResponse.json({ error: "At least one tag is required" }, { status: 400 }); if (!qrBytesRaw || qrBytesRaw.length == 0) return NextResponse.json({ error: "A QR code is required" }, { status: 400 }); const nameValidation = nameSchema.safeParse(name); if (!nameValidation.success) return NextResponse.json({ error: nameValidation.error.errors[0].message }, { status: 400 }); + const tagsValidation = tagsSchema.safeParse(tags); if (!tagsValidation.success) return NextResponse.json({ error: tagsValidation.error.errors[0].message }, { status: 400 }); - // Validate QR code size if (qrBytesRaw.length !== 372) return NextResponse.json({ error: "QR code size is not a valid Tomodachi Life QR code" }, { status: 400 }); + // Validate image files + const images: File[] = []; + + for (const img of [image1, image2, image3]) { + if (!img) break; + + const imageValidation = await validateImage(img); + if (imageValidation.valid) { + images.push(img); + } else { + return NextResponse.json({ error: imageValidation.error }, { status: imageValidation.status ?? 400 }); + } + } + const qrBytes = new Uint8Array(qrBytesRaw); - // Decrypt the Mii part of the QR code - // (Credits to kazuki-4ys) - const nonce = qrBytes.subarray(0, 8); - const content = qrBytes.subarray(8, 0x70); - - const nonceWithZeros = new Uint8Array(12); - nonceWithZeros.set(nonce, 0); - - let decrypted: Uint8Array = new Uint8Array(); + // Convert QR code to JS + let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; try { - decrypted = AES_CCM.decrypt(content, MII_DECRYPTION_KEY, nonceWithZeros, undefined, 16); + conversion = convertQrCode(qrBytes); } catch (error) { - console.warn("Failed to decrypt QR code:", error); - return NextResponse.json({ error: "Failed to decrypt QR code. It may be invalid or corrupted." }, { status: 400 }); - } - - const result = new Uint8Array(96); - result.set(decrypted.subarray(0, 12), 0); - result.set(nonce, 12); - result.set(decrypted.subarray(12), 20); - - // Check if QR code is valid (after decryption) - if (result.length !== 0x60 || (result[0x16] !== 0 && result[0x17] !== 0)) - return NextResponse.json({ error: "QR code is not a valid Mii QR code" }, { status: 400 }); - - // Convert to Mii class - let mii: Mii; - let tomodachiLifeMii: TomodachiLifeMii; - - try { - const buffer = Buffer.from(result); - mii = new Mii(buffer); - tomodachiLifeMii = TomodachiLifeMii.fromBytes(qrBytes); - - if (tomodachiLifeMii.hairDyeEnabled) { - mii.hairColor = tomodachiLifeMii.studioHairColor; - mii.eyebrowColor = tomodachiLifeMii.studioHairColor; - mii.facialHairColor = tomodachiLifeMii.studioHairColor; - } - } catch (error) { - console.warn("Mii data is not valid:", error); - return NextResponse.json({ error: "Mii data is not valid" }, { status: 400 }); + return NextResponse.json({ error }, { status: 400 }); } // Create Mii in database @@ -87,10 +74,10 @@ export async function POST(request: Request) { name, tags, - firstName: tomodachiLifeMii.firstName, - lastName: tomodachiLifeMii.lastName, - islandName: tomodachiLifeMii.islandName, - allowedCopying: mii.allowCopying, + firstName: conversion.tomodachiLifeMii.firstName, + lastName: conversion.tomodachiLifeMii.lastName, + islandName: conversion.tomodachiLifeMii.islandName, + allowedCopying: conversion.mii.allowCopying, }, }); @@ -101,7 +88,7 @@ export async function POST(request: Request) { // Download the image of the Mii let studioBuffer: Buffer; try { - const studioUrl = mii.studioUrl({ width: 512 }); + const studioUrl = conversion.mii.studioUrl({ width: 512 }); const studioResponse = await fetch(studioUrl); if (!studioResponse.ok) { @@ -113,6 +100,7 @@ export async function POST(request: Request) { } 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 NextResponse.json({ error: "Failed to download Mii image" }, { status: 500 }); } @@ -138,14 +126,41 @@ export async function POST(request: Request) { // Compress and upload const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer(); const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp"); + await fs.writeFile(codeFileLocation, codeWebpBuffer); - - // todo: upload user images - - return NextResponse.json({ success: true, id: miiRecord.id }); } catch (error) { + // Clean up if something went wrong await prisma.mii.delete({ where: { id: miiRecord.id } }); + console.error("Error processing Mii files:", error); return NextResponse.json({ error: "Failed to process and store Mii files" }, { status: 500 }); } + + // Compress and upload user images + try { + await Promise.all( + images.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`); + + await fs.writeFile(fileLocation, webpBuffer); + }) + ); + + // Update database to tell it how many images exist + await prisma.mii.update({ + where: { + id: miiRecord.id, + }, + data: { + imageCount: images.length, + }, + }); + } catch (error) { + console.error("Error uploading user images:", error); + return NextResponse.json({ error: "Failed to store user images" }, { status: 500 }); + } + + return NextResponse.json({ success: true, id: miiRecord.id }); } diff --git a/src/app/components/mii-list.tsx b/src/app/components/mii-list.tsx index 6a4afe5..0420be6 100644 --- a/src/app/components/mii-list.tsx +++ b/src/app/components/mii-list.tsx @@ -132,7 +132,13 @@ export default async function MiiList({ searchParams, userId, where }: Props) { key={mii.id} className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3 transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600" > - + `/mii/${mii.id}/image${index}.webp`), + ]} + />
diff --git a/src/app/components/submit-form.tsx b/src/app/components/submit-form.tsx index 414315a..21364c7 100644 --- a/src/app/components/submit-form.tsx +++ b/src/app/components/submit-form.tsx @@ -6,12 +6,10 @@ import { useCallback, useEffect, useState } from "react"; import { FileWithPath, useDropzone } from "react-dropzone"; import { Icon } from "@iconify/react"; -import { AES_CCM } from "@trafficlunar/asmcrypto.js"; import qrcode from "qrcode-generator"; -import { MII_DECRYPTION_KEY } from "@/lib/constants"; import { nameSchema, tagsSchema } from "@/lib/schemas"; - +import { convertQrCode } from "@/lib/qr-codes"; import Mii from "@/lib/mii.js/mii"; import TomodachiLifeMii from "@/lib/tomodachi-life-mii"; @@ -61,10 +59,18 @@ export default function SubmitForm() { } // Send request to server + const formData = new FormData(); + formData.append("name", name); + formData.append("tags", JSON.stringify(tags)); + formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw)); + files.forEach((file, index) => { + // image1, image2, etc. + formData.append(`image${index + 1}`, file); + }); + const response = await fetch("/api/submit", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, tags, qrBytesRaw }), + body: formData, }); const { id, error } = await response.json(); @@ -80,7 +86,7 @@ export default function SubmitForm() { if (qrBytesRaw.length == 0) return; const qrBytes = new Uint8Array(qrBytesRaw); - const decode = async () => { + const preview = async () => { setError(""); // Validate QR code size @@ -89,47 +95,17 @@ export default function SubmitForm() { return; } - // Decrypt the Mii part of the QR code - // (Credits to kazuki-4ys) - const nonce = qrBytes.subarray(0, 8); - const content = qrBytes.subarray(8, 0x70); - - const nonceWithZeros = new Uint8Array(12); - nonceWithZeros.set(nonce, 0); - - let decrypted: Uint8Array = new Uint8Array(); + // Convert QR code to JS + let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; try { - decrypted = AES_CCM.decrypt(content, MII_DECRYPTION_KEY, nonceWithZeros, undefined, 16); + conversion = convertQrCode(qrBytes); } catch (error) { - console.warn("Failed to decrypt QR code:", error); - setError("Failed to decrypt QR code. It may be invalid or corrupted."); + setError(error as string); return; } - const result = new Uint8Array(96); - result.set(decrypted.subarray(0, 12), 0); - result.set(nonce, 12); - result.set(decrypted.subarray(12), 20); - - // Check if QR code is valid (after decryption) - if (result.length !== 0x60 || (result[0x16] !== 0 && result[0x17] !== 0)) { - setError("QR code is not a valid Mii QR code"); - return; - } - - // Convert to Mii classes - const buffer = Buffer.from(result); - const mii = new Mii(buffer); - const tomodachiLifeMii = TomodachiLifeMii.fromBytes(qrBytes); - - if (tomodachiLifeMii.hairDyeEnabled) { - mii.hairColor = tomodachiLifeMii.studioHairColor; - mii.eyebrowColor = tomodachiLifeMii.studioHairColor; - mii.facialHairColor = tomodachiLifeMii.studioHairColor; - } - try { - setStudioUrl(mii.studioUrl({ width: 128 })); + setStudioUrl(conversion.mii.studioUrl({ width: 128 })); // Generate a new QR code for aesthetic reasons const byteString = String.fromCharCode(...qrBytes); @@ -139,12 +115,11 @@ export default function SubmitForm() { setGeneratedQrCodeUrl(generatedCode.createDataURL()); } catch (error) { - console.warn("Failed to get and/or generate Mii images:", error); setError("Failed to get and/or generate Mii images"); } }; - decode(); + preview(); }, [qrBytesRaw]); return ( diff --git a/src/app/mii/[slug]/page.tsx b/src/app/mii/[slug]/page.tsx index 4383b3f..e00c956 100644 --- a/src/app/mii/[slug]/page.tsx +++ b/src/app/mii/[slug]/page.tsx @@ -43,7 +43,11 @@ export default async function MiiPage({ params }: Props) { if (!mii) redirect("/404"); - const images = [`/mii/${mii.id}/mii.webp`, `/mii/${mii.id}/qr-code.webp`, ...mii.images]; + const images = [ + `/mii/${mii.id}/mii.webp`, + `/mii/${mii.id}/qr-code.webp`, + ...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image${index}.webp`), + ]; return (
diff --git a/src/lib/images.ts b/src/lib/images.ts new file mode 100644 index 0000000..791c405 --- /dev/null +++ b/src/lib/images.ts @@ -0,0 +1,68 @@ +// import * as tf from "@tensorflow/tfjs-node"; +// import * as nsfwjs from "nsfwjs"; +import sharp from "sharp"; + +const MIN_IMAGE_DIMENSIONS = 128; +const MAX_IMAGE_DIMENSIONS = 1024; +const MAX_IMAGE_SIZE = 1024 * 1024; // 1 MB + +const THRESHOLD = 0.5; + +// tf.enableProdMode(); + +// Load NSFW.JS model +// let _model: nsfwjs.NSFWJS | undefined = undefined; + +// async function loadModel() { +// if (!_model) { +// const model = await nsfwjs.load("MobileNetV2Mid"); +// _model = model; +// } +// return _model!; +// } + +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.type.startsWith("image/")) return { valid: false, error: "Invalid file type. Only images are allowed" }; + if (file.size > MAX_IMAGE_SIZE) + return { valid: false, error: `One or more of your images are too large. Maximum size is ${MAX_IMAGE_SIZE / (1024 * 1024)}MB` }; + + try { + const buffer = Buffer.from(await file.arrayBuffer()); + const metadata = await sharp(buffer).metadata(); + + // Check image dimensions + if ( + !metadata.width || + !metadata.height || + metadata.width < MIN_IMAGE_DIMENSIONS || + metadata.width > MAX_IMAGE_DIMENSIONS || + metadata.height < MIN_IMAGE_DIMENSIONS || + metadata.height > MAX_IMAGE_DIMENSIONS + ) { + return { valid: false, error: "Image dimensions are invalid. Width and height must be between 128px and 1024px" }; + } + + // Check for inappropriate content + // const image = tf.node.decodeImage(buffer, 3) as tf.Tensor3D; + // const model = await loadModel(); + // const predictions = await model.classify(image); + // image.dispose(); + + // for (const pred of predictions) { + // if ( + // (pred.className === "Porn" && pred.probability > THRESHOLD) || + // (pred.className === "Hentai" && pred.probability > THRESHOLD) || + // (pred.className === "Sexy" && pred.probability > THRESHOLD) + // ) { + // // reject image + // return { valid: false, error: "Image contains inappropriate content" }; + // } + // } + } catch (error) { + console.error("Error validating image:", error); + return { valid: false, error: "Failed to process image file.", status: 500 }; + } + + return { valid: true }; +} diff --git a/src/lib/qr-codes.ts b/src/lib/qr-codes.ts new file mode 100644 index 0000000..c47b370 --- /dev/null +++ b/src/lib/qr-codes.ts @@ -0,0 +1,46 @@ +import { AES_CCM } from "@trafficlunar/asmcrypto.js"; +import { MII_DECRYPTION_KEY } from "./constants"; +import Mii from "./mii.js/mii"; +import TomodachiLifeMii from "./tomodachi-life-mii"; + +export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } { + // Decrypt the Mii part of the QR code + // (Credits to kazuki-4ys) + const nonce = bytes.subarray(0, 8); + const content = bytes.subarray(8, 0x70); + + const nonceWithZeros = new Uint8Array(12); + nonceWithZeros.set(nonce, 0); + + let decrypted: Uint8Array = new Uint8Array(); + try { + decrypted = AES_CCM.decrypt(content, MII_DECRYPTION_KEY, nonceWithZeros, undefined, 16); + } catch (error) { + throw new Error("Failed to decrypt QR code. It may be invalid or corrupted"); + } + + const result = new Uint8Array(96); + result.set(decrypted.subarray(0, 12), 0); + result.set(nonce, 12); + result.set(decrypted.subarray(12), 20); + + // Check if QR code is valid (after decryption) + if (result.length !== 0x60 || (result[0x16] !== 0 && result[0x17] !== 0)) throw new Error("QR code is not a valid Mii QR code"); + + // Convert to Mii classes + try { + const buffer = Buffer.from(result); + const mii = new Mii(buffer); + const tomodachiLifeMii = TomodachiLifeMii.fromBytes(bytes); + + if (tomodachiLifeMii.hairDyeEnabled) { + mii.hairColor = tomodachiLifeMii.studioHairColor; + mii.eyebrowColor = tomodachiLifeMii.studioHairColor; + mii.facialHairColor = tomodachiLifeMii.studioHairColor; + } + + return { mii, tomodachiLifeMii }; + } catch (error) { + throw new Error("Mii data is not valid"); + } +}