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

View file

@ -29,11 +29,18 @@ const submitSchema = z
description: z.string().trim().max(256).optional(),
// 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"),
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" }),
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
image1: z.union([z.instanceof(File), z.any()]).optional(),
@ -42,15 +49,15 @@ const submitSchema = z
})
.refine(
(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") {
return data.gender !== undefined && data.miiPortraitImage !== undefined;
return data.accessKey !== undefined && data.gender !== undefined && data.miiPortraitImage !== undefined;
}
return true;
},
{
message: "Gender and Mii portrait image are required for Switch platform",
path: ["gender", "miiPortraitImage"],
message: "Access key, gender, and Mii portrait image is required for Switch",
path: ["accessKey", "gender", "miiPortraitImage"],
}
);
@ -70,7 +77,7 @@ export async function POST(request: NextRequest) {
const formData = await request.formData();
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 {
rawTags = JSON.parse(formData.get("tags") as string);
rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string);
@ -85,10 +92,11 @@ export async function POST(request: NextRequest) {
tags: rawTags,
description: formData.get("description"),
gender: formData.get("gender") ?? undefined, // ZOD MOMENT
accessKey: formData.get("accessKey"),
gender: formData.get("gender"),
miiPortraitImage: formData.get("miiPortraitImage"),
qrBytesRaw: rawQrBytesRaw,
qrBytesRaw: rawQrBytesRaw ?? undefined,
image1: formData.get("image1"),
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);
}
const qrBytes = new Uint8Array(data.qrBytesRaw);
const qrBytes = new Uint8Array(data.qrBytesRaw ?? []);
// Convert QR code to JS (3DS)
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | undefined;
@ -145,6 +153,9 @@ export async function POST(request: NextRequest) {
description,
gender: data.gender ?? "MALE",
// Access key only for Switch
accessKey: data.platform === "SWITCH" ? data.accessKey : null,
// Automatically detect certain information if on 3DS
...(data.platform === "THREE_DS" &&
conversion && {
@ -191,29 +202,31 @@ export async function POST(request: NextRequest) {
return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500);
}
try {
// Generate a new QR code for aesthetic reasons
const byteString = String.fromCharCode(...qrBytes);
const generatedCode = qrcode(0, "L");
generatedCode.addData(byteString, "Byte");
generatedCode.make();
if (data.platform === "THREE_DS") {
try {
// Generate a new QR code for aesthetic reasons
const byteString = String.fromCharCode(...qrBytes);
const generatedCode = qrcode(0, "L");
generatedCode.addData(byteString, "Byte");
generatedCode.make();
// Store QR code
const codeDataUrl = generatedCode.createDataURL();
const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, "");
const codeBuffer = Buffer.from(codeBase64, "base64");
// Store QR code
const codeDataUrl = generatedCode.createDataURL();
const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, "");
const codeBuffer = Buffer.from(codeBase64, "base64");
// Compress and store
const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer();
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp");
// Compress and store
const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer();
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp");
await fs.writeFile(codeFileLocation, codeWebpBuffer);
} catch (error) {
// Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } });
await fs.writeFile(codeFileLocation, codeWebpBuffer);
} catch (error) {
// Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } });
console.error("Error generating QR code:", error);
return rateLimit.sendResponse({ error: "Failed to generate QR code" }, 500);
console.error("Error generating QR code:", error);
return rateLimit.sendResponse({ error: "Failed to generate QR code" }, 500);
}
}
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"
/>
</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">
<ImageViewer
src={`/mii/${mii.id}/image?type=qr-code`}
alt="mii qr code"
width={128}
height={128}
className="border-2 border-amber-300 rounded-lg hover:brightness-90 transition-all"
/>
{mii.platform === "THREE_DS" ? (
<ImageViewer
src={`/mii/${mii.id}/image?type=qr-code`}
alt="mii qr code"
width={128}
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>
<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
images={[
`/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}`),
]}
/>

View file

@ -31,6 +31,7 @@ export default function SubmitForm() {
const [name, setName] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [description, setDescription] = useState("");
const [accessKey, setAccessKey] = useState("");
const [gender, setGender] = useState<MiiGender>("MALE");
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
@ -67,16 +68,18 @@ export default function SubmitForm() {
formData.append("name", name);
formData.append("tags", JSON.stringify(tags));
formData.append("description", description);
formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw));
files.forEach((file, index) => {
// image1, image2, etc.
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 blob = await response.blob();
formData.append("accessKey", accessKey);
formData.append("gender", gender);
formData.append("miiPortraitImage", blob);
}
@ -96,6 +99,7 @@ export default function SubmitForm() {
};
useEffect(() => {
if (platform !== "THREE_DS") return;
if (qrBytesRaw.length == 0) return;
const qrBytes = new Uint8Array(qrBytesRaw);
@ -108,16 +112,14 @@ export default function SubmitForm() {
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;
}
// Convert QR code to JS
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
@ -256,41 +258,56 @@ export default function SubmitForm() {
/>
</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" && (
<>
{/* 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">
<hr className="flex-grow border-zinc-300" />
<span>Mii Portrait</span>
@ -304,27 +321,24 @@ export default function SubmitForm() {
)}
{/* QR code selector */}
<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" />
<span>QR Code</span>
<hr className="flex-grow border-zinc-300" />
</div>
{platform === "THREE_DS" && (
<>
<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" />
<span>QR Code</span>
<hr className="flex-grow border-zinc-300" />
</div>
<div className="flex flex-col items-center gap-2">
<QrUpload setQrBytesRaw={setQrBytesRaw} />
<span>or</span>
<QrScanner setQrBytesRaw={setQrBytesRaw} />
<div className="flex flex-col items-center gap-2">
<QrUpload setQrBytesRaw={setQrBytesRaw} />
<span>or</span>
<QrScanner setQrBytesRaw={setQrBytesRaw} />
{platform === "THREE_DS" ? (
<>
<ThreeDsSubmitTutorialButton />
<span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span>
</>
) : (
<SwitchSubmitTutorialButton />
)}
</div>
</div>
</>
)}
{/* Custom images selector */}
<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 (
<>
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
How to?
<button type="button" onClick={() => setIsOpen(true)} className="text-orange-400 cursor-pointer underline-offset-2 hover:underline">
(?)
</button>
{isOpen &&

View file

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