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

@ -25,11 +25,11 @@
"file-type": "^21.0.0",
"ioredis": "^5.7.0",
"jsqr": "^1.4.0",
"next": "15.5.3",
"next": "16.0.0-beta.0",
"next-auth": "5.0.0-beta.25",
"qrcode-generator": "^2.0.4",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-dropzone": "^14.3.8",
"react-webcam": "^7.2.0",
"satori": "^0.18.2",
@ -45,16 +45,22 @@
"@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/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": "15.5.3",
"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,6 +202,7 @@ export async function POST(request: NextRequest) {
return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500);
}
if (data.platform === "THREE_DS") {
try {
// Generate a new QR code for aesthetic reasons
const byteString = String.fromCharCode(...qrBytes);
@ -215,6 +227,7 @@ export async function POST(request: NextRequest) {
console.error("Error generating QR code:", error);
return rateLimit.sendResponse({ error: "Failed to generate QR code" }, 500);
}
}
try {
await generateMetadataImage(miiRecord, session.user.username!);

View file

@ -119,8 +119,9 @@ 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">
{mii.platform === "THREE_DS" ? (
<ImageViewer
src={`/mii/${mii.id}/image?type=qr-code`}
alt="mii qr code"
@ -128,6 +129,9 @@ export default async function MiiPage({ params }: Props) {
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,8 +112,7 @@ export default function SubmitForm() {
return;
}
// Convert QR code to JS (3DS)
if (platform === "THREE_DS") {
// Convert QR code to JS
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii };
try {
conversion = convertQrCode(qrBytes);
@ -118,7 +121,6 @@ export default function SubmitForm() {
setError(error instanceof Error ? error.message : String(error));
return;
}
}
// Generate a new QR code for aesthetic reasons
try {
@ -256,8 +258,26 @@ export default function SubmitForm() {
/>
</div>
{/* Gender (switch only) */}
{platform === "SWITCH" && (
<>
{/* 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
@ -286,11 +306,8 @@ export default function SubmitForm() {
</button>
</div>
</div>
)}
{platform === "SWITCH" && (
<>
{/* Separator */}
{/* 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,6 +321,8 @@ export default function SubmitForm() {
)}
{/* QR code selector */}
{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>
@ -315,16 +334,11 @@ export default function SubmitForm() {
<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>
</>
)}
{/* 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

@ -11,7 +11,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@ -23,6 +23,6 @@
"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"]
}