feat: 20f1c51f access key version

This commit is contained in:
trafficlunar 2025-10-29 17:18:18 +00:00
parent 76fecca011
commit 07f3bb35d8
10 changed files with 907 additions and 320 deletions

View file

@ -1,60 +1,66 @@
{ {
"name": "tomodachi-share", "name": "tomodachi-share",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "pnpm@10.14.0", "packageManager": "pnpm@10.14.0",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"postinstall": "prisma generate", "postinstall": "prisma generate",
"test": "vitest" "test": "vitest"
}, },
"dependencies": { "dependencies": {
"@2toad/profanity": "^3.1.1", "@2toad/profanity": "^3.1.1",
"@auth/prisma-adapter": "2.10.0", "@auth/prisma-adapter": "2.10.0",
"@bprogress/next": "^3.2.12", "@bprogress/next": "^3.2.12",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.16.1", "@prisma/client": "^6.16.1",
"bit-buffer": "^0.2.5", "bit-buffer": "^0.2.5",
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"downshift": "^9.0.10", "downshift": "^9.0.10",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"file-type": "^21.0.0", "file-type": "^21.0.0",
"ioredis": "^5.7.0", "ioredis": "^5.7.0",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"next": "15.5.3", "next": "16.0.0-beta.0",
"next-auth": "5.0.0-beta.25", "next-auth": "5.0.0-beta.25",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
"react": "^19.1.1", "react": "19.2.0",
"react-dom": "^19.1.1", "react-dom": "19.2.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-webcam": "^7.2.0", "react-webcam": "^7.2.0",
"satori": "^0.18.2", "satori": "^0.18.2",
"seedrandom": "^3.0.5", "seedrandom": "^3.0.5",
"sharp": "^0.34.3", "sharp": "^0.34.3",
"sjcl-with-all": "1.0.8", "sjcl-with-all": "1.0.8",
"swr": "^2.3.6", "swr": "^2.3.6",
"zod": "^4.1.8" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@iconify/react": "^6.0.1", "@iconify/react": "^6.0.1",
"@tailwindcss/postcss": "^4.1.13", "@tailwindcss/postcss": "^4.1.13",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "^24.3.1", "@types/node": "^24.3.1",
"@types/react": "^19.1.12", "@types/react": "19.2.2",
"@types/react-dom": "^19.1.9", "@types/react-dom": "19.2.1",
"@types/seedrandom": "^3.0.8", "@types/seedrandom": "^3.0.8",
"@types/sjcl": "^1.0.34", "@types/sjcl": "^1.0.34",
"eslint": "^9.35.0", "eslint": "^9.35.0",
"eslint-config-next": "15.5.3", "eslint-config-next": "16.0.0-beta.0",
"prisma": "^6.16.1", "prisma": "^6.16.1",
"schema-dts": "^1.1.5", "schema-dts": "^1.1.5",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} },
"pnpm": {
"overrides": {
"@types/react": "19.2.2",
"@types/react-dom": "19.2.1"
}
}
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."miis" ADD COLUMN "accessKey" VARCHAR(7);

View file

@ -76,6 +76,7 @@ model Mii {
tags String[] tags String[]
description String? @db.VarChar(256) description String? @db.VarChar(256)
platform MiiPlatform @default(THREE_DS) platform MiiPlatform @default(THREE_DS)
accessKey String? @db.VarChar(7)
firstName String? firstName String?
lastName String? lastName String?

View file

@ -29,11 +29,18 @@ const submitSchema = z
description: z.string().trim().max(256).optional(), description: z.string().trim().max(256).optional(),
// Switch // Switch
accessKey: z
.string()
.length(7, { error: "Access key must be 7 characters in length" })
.regex(/^[a-zA-Z0-9]+$/, "Access key must be alphanumeric"),
gender: z.enum(MiiGender).default("MALE"), gender: z.enum(MiiGender).default("MALE"),
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(), miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
// QR code // 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" }), 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" })
.optional(),
// Custom images // Custom images
image1: z.union([z.instanceof(File), z.any()]).optional(), image1: z.union([z.instanceof(File), z.any()]).optional(),
@ -42,15 +49,15 @@ const submitSchema = z
}) })
.refine( .refine(
(data) => { (data) => {
// If platform is Switch, gender and miiPortraitImage must be present // If platform is Switch, accessKey, gender, and miiPortraitImage must be present
if (data.platform === "SWITCH") { if (data.platform === "SWITCH") {
return data.gender !== undefined && data.miiPortraitImage !== undefined; return data.accessKey !== undefined && data.gender !== undefined && data.miiPortraitImage !== undefined;
} }
return true; return true;
}, },
{ {
message: "Gender and Mii portrait image are required for Switch platform", message: "Access key, gender, and Mii portrait image is required for Switch",
path: ["gender", "miiPortraitImage"], path: ["accessKey", "gender", "miiPortraitImage"],
} }
); );
@ -70,7 +77,7 @@ export async function POST(request: NextRequest) {
const formData = await request.formData(); const formData = await request.formData();
let rawTags: string[]; let rawTags: string[];
let rawQrBytesRaw: string[]; // raw raw let rawQrBytesRaw: string[] | undefined = undefined; // good variable name - raw raw; is undefined for zod to ignore it if platform is Switch
try { try {
rawTags = JSON.parse(formData.get("tags") as string); rawTags = JSON.parse(formData.get("tags") as string);
rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string); rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string);
@ -85,10 +92,11 @@ export async function POST(request: NextRequest) {
tags: rawTags, tags: rawTags,
description: formData.get("description"), description: formData.get("description"),
gender: formData.get("gender") ?? undefined, // ZOD MOMENT accessKey: formData.get("accessKey"),
gender: formData.get("gender"),
miiPortraitImage: formData.get("miiPortraitImage"), miiPortraitImage: formData.get("miiPortraitImage"),
qrBytesRaw: rawQrBytesRaw, qrBytesRaw: rawQrBytesRaw ?? undefined,
image1: formData.get("image1"), image1: formData.get("image1"),
image2: formData.get("image2"), image2: formData.get("image2"),
@ -123,7 +131,7 @@ export async function POST(request: NextRequest) {
if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400); if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
} }
const qrBytes = new Uint8Array(data.qrBytesRaw); const qrBytes = new Uint8Array(data.qrBytesRaw ?? []);
// Convert QR code to JS (3DS) // Convert QR code to JS (3DS)
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | undefined; let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | undefined;
@ -145,6 +153,9 @@ export async function POST(request: NextRequest) {
description, description,
gender: data.gender ?? "MALE", gender: data.gender ?? "MALE",
// Access key only for Switch
accessKey: data.platform === "SWITCH" ? data.accessKey : null,
// Automatically detect certain information if on 3DS // Automatically detect certain information if on 3DS
...(data.platform === "THREE_DS" && ...(data.platform === "THREE_DS" &&
conversion && { conversion && {
@ -191,29 +202,31 @@ export async function POST(request: NextRequest) {
return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500); return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500);
} }
try { if (data.platform === "THREE_DS") {
// Generate a new QR code for aesthetic reasons try {
const byteString = String.fromCharCode(...qrBytes); // Generate a new QR code for aesthetic reasons
const generatedCode = qrcode(0, "L"); const byteString = String.fromCharCode(...qrBytes);
generatedCode.addData(byteString, "Byte"); const generatedCode = qrcode(0, "L");
generatedCode.make(); generatedCode.addData(byteString, "Byte");
generatedCode.make();
// Store QR code // Store QR code
const codeDataUrl = generatedCode.createDataURL(); const codeDataUrl = generatedCode.createDataURL();
const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, ""); const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, "");
const codeBuffer = Buffer.from(codeBase64, "base64"); const codeBuffer = Buffer.from(codeBase64, "base64");
// Compress and store // Compress and store
const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer(); const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer();
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp"); const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp");
await fs.writeFile(codeFileLocation, codeWebpBuffer); await fs.writeFile(codeFileLocation, codeWebpBuffer);
} catch (error) { } catch (error) {
// Clean up if something went wrong // Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } }); await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Error generating QR code:", error); console.error("Error generating QR code:", error);
return rateLimit.sendResponse({ error: "Failed to generate QR code" }, 500); return rateLimit.sendResponse({ error: "Failed to generate QR code" }, 500);
}
} }
try { try {

View file

@ -119,15 +119,19 @@ export default async function MiiPage({ params }: Props) {
className="drop-shadow-lg hover:scale-105 transition-transform duration-300 object-contain size-full" className="drop-shadow-lg hover:scale-105 transition-transform duration-300 object-contain size-full"
/> />
</div> </div>
{/* QR Code */} {/* QR Code/Access key */}
<div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2"> <div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2">
<ImageViewer {mii.platform === "THREE_DS" ? (
src={`/mii/${mii.id}/image?type=qr-code`} <ImageViewer
alt="mii qr code" src={`/mii/${mii.id}/image?type=qr-code`}
width={128} alt="mii qr code"
height={128} width={128}
className="border-2 border-amber-300 rounded-lg hover:brightness-90 transition-all" height={128}
/> className="border-2 border-amber-300 rounded-lg hover:brightness-90 transition-all"
/>
) : (
<h1 className="font-bold text-3xl">{mii.accessKey}</h1>
)}
</div> </div>
<hr className="w-full border-t-2 border-t-amber-400" /> <hr className="w-full border-t-2 border-t-amber-400" />

View file

@ -219,7 +219,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
<Carousel <Carousel
images={[ images={[
`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=mii`,
`/mii/${mii.id}/image?type=qr-code`, ...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : []),
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`), ...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
]} ]}
/> />

View file

@ -31,6 +31,7 @@ export default function SubmitForm() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [tags, setTags] = useState<string[]>([]); const [tags, setTags] = useState<string[]>([]);
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [accessKey, setAccessKey] = useState("");
const [gender, setGender] = useState<MiiGender>("MALE"); const [gender, setGender] = useState<MiiGender>("MALE");
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]); const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
@ -67,16 +68,18 @@ export default function SubmitForm() {
formData.append("name", name); formData.append("name", name);
formData.append("tags", JSON.stringify(tags)); formData.append("tags", JSON.stringify(tags));
formData.append("description", description); formData.append("description", description);
formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw));
files.forEach((file, index) => { files.forEach((file, index) => {
// image1, image2, etc. // image1, image2, etc.
formData.append(`image${index + 1}`, file); formData.append(`image${index + 1}`, file);
}); });
if (platform === "SWITCH") { if (platform === "THREE_DS") {
formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw));
} else if (platform === "SWITCH") {
const response = await fetch(miiPortraitUri!); const response = await fetch(miiPortraitUri!);
const blob = await response.blob(); const blob = await response.blob();
formData.append("accessKey", accessKey);
formData.append("gender", gender); formData.append("gender", gender);
formData.append("miiPortraitImage", blob); formData.append("miiPortraitImage", blob);
} }
@ -96,6 +99,7 @@ export default function SubmitForm() {
}; };
useEffect(() => { useEffect(() => {
if (platform !== "THREE_DS") return;
if (qrBytesRaw.length == 0) return; if (qrBytesRaw.length == 0) return;
const qrBytes = new Uint8Array(qrBytesRaw); const qrBytes = new Uint8Array(qrBytesRaw);
@ -108,16 +112,14 @@ export default function SubmitForm() {
return; return;
} }
// Convert QR code to JS (3DS) // Convert QR code to JS
if (platform === "THREE_DS") { let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii };
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii }; try {
try { conversion = convertQrCode(qrBytes);
conversion = convertQrCode(qrBytes); setMiiPortraitUri(conversion.mii.studioUrl({ width: 512 }));
setMiiPortraitUri(conversion.mii.studioUrl({ width: 512 })); } catch (error) {
} catch (error) { setError(error instanceof Error ? error.message : String(error));
setError(error instanceof Error ? error.message : String(error)); return;
return;
}
} }
// Generate a new QR code for aesthetic reasons // Generate a new QR code for aesthetic reasons
@ -256,41 +258,56 @@ export default function SubmitForm() {
/> />
</div> </div>
{/* Gender (switch only) */}
{platform === "SWITCH" && (
<div className="w-full grid grid-cols-3 items-start">
<label htmlFor="gender" className="font-semibold py-2">
Gender
</label>
<div className="col-span-2 flex gap-1">
<button
type="button"
onClick={() => setGender("MALE")}
aria-label="Filter for Male Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
gender === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<Icon icon="foundation:male" className="text-blue-400" />
</button>
<button
type="button"
onClick={() => setGender("FEMALE")}
aria-label="Filter for Female Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
gender === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<Icon icon="foundation:female" className="text-pink-400" />
</button>
</div>
</div>
)}
{platform === "SWITCH" && ( {platform === "SWITCH" && (
<> <>
{/* Separator */} {/* Access Key */}
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="accessKey" className="font-semibold">
Access Key <SwitchSubmitTutorialButton />
</label>
<input
name="accessKey"
type="text"
className="pill input w-full col-span-2"
minLength={7}
maxLength={7}
placeholder="Type your mii's access key here..."
value={accessKey}
onChange={(e) => setAccessKey(e.target.value)}
/>
</div>
{/* Gender */}
<div className="w-full grid grid-cols-3 items-start">
<label htmlFor="gender" className="font-semibold py-2">
Gender
</label>
<div className="col-span-2 flex gap-1">
<button
type="button"
onClick={() => setGender("MALE")}
aria-label="Filter for Male Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
gender === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<Icon icon="foundation:male" className="text-blue-400" />
</button>
<button
type="button"
onClick={() => setGender("FEMALE")}
aria-label="Filter for Female Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all ${
gender === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<Icon icon="foundation:female" className="text-pink-400" />
</button>
</div>
</div>
{/* Mii Portrait */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="flex-grow border-zinc-300" /> <hr className="flex-grow border-zinc-300" />
<span>Mii Portrait</span> <span>Mii Portrait</span>
@ -304,27 +321,24 @@ export default function SubmitForm() {
)} )}
{/* QR code selector */} {/* QR code selector */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2"> {platform === "THREE_DS" && (
<hr className="flex-grow border-zinc-300" /> <>
<span>QR Code</span> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="flex-grow border-zinc-300" /> <hr className="flex-grow border-zinc-300" />
</div> <span>QR Code</span>
<hr className="flex-grow border-zinc-300" />
</div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<QrUpload setQrBytesRaw={setQrBytesRaw} /> <QrUpload setQrBytesRaw={setQrBytesRaw} />
<span>or</span> <span>or</span>
<QrScanner setQrBytesRaw={setQrBytesRaw} /> <QrScanner setQrBytesRaw={setQrBytesRaw} />
{platform === "THREE_DS" ? (
<>
<ThreeDsSubmitTutorialButton /> <ThreeDsSubmitTutorialButton />
<span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span> <span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span>
</> </div>
) : ( </>
<SwitchSubmitTutorialButton /> )}
)}
</div>
{/* Custom images selector */} {/* Custom images selector */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2">

View file

@ -40,8 +40,8 @@ export default function ThreeDsSubmitTutorialButton() {
return ( return (
<> <>
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline"> <button type="button" onClick={() => setIsOpen(true)} className="text-orange-400 cursor-pointer underline-offset-2 hover:underline">
How to? (?)
</button> </button>
{isOpen && {isOpen &&

View file

@ -1,28 +1,28 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"],
"sjcl-with-all": ["./node_modules/@types/sjcl"] "sjcl-with-all": ["./node_modules/@types/sjcl"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }