feat: .ltd files

no automatic instructions
This commit is contained in:
trafficlunar 2026-04-26 22:49:29 +01:00
parent a000447b0a
commit 817bda4993
36 changed files with 1733 additions and 1037 deletions

View file

@ -14,7 +14,9 @@
"@2toad/profanity": "^3.3.0",
"@auth/prisma-adapter": "2.11.1",
"@prisma/client": "^6.19.2",
"@tomodachi-share/shared": "workspace:*",
"bit-buffer": "^0.3.0",
"charinfo-ex": "^0.0.5",
"dayjs": "^1.11.20",
"downshift": "^9.3.2",
"file-type": "^22.0.1",
@ -27,8 +29,7 @@
"satori": "^0.26.0",
"sharp": "^0.34.5",
"sjcl-with-all": "1.0.8",
"zod": "^4.3.6",
"@tomodachi-share/shared": "workspace:*"
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "miis" ADD COLUMN "isFromSaveFile" BOOLEAN NOT NULL DEFAULT false;

View file

@ -79,10 +79,11 @@ model Mii {
in_queue Boolean @default(false)
needsFixing String?
instructions Json?
youtubeId String?
gender MiiGender?
makeup MiiMakeup?
isFromSaveFile Boolean @default(false)
instructions Json?
youtubeId String?
gender MiiGender?
makeup MiiMakeup?
firstName String?
lastName String?

View file

@ -19,6 +19,7 @@ export async function GET(request: NextRequest) {
makeup,
allowCopying,
quarantined,
isFromSaveFile,
page = 1,
limit = 24,
parentPage,
@ -59,17 +60,13 @@ export async function GET(request: NextRequest) {
// Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
// Platform
// Other
...(platform && { platform: { equals: platform } }),
// Gender
...(gender && { gender: { equals: gender } }),
// Allow Copying
...(allowCopying && { allowedCopying: true }),
// Makeup
...(makeup && { makeup: { equals: makeup } }),
// Quarantined
...(!quarantined && !userId && { quarantined: false }),
// Time range
...(isFromSaveFile && { isFromSaveFile: true }),
...(timeRange && {
reviewedAt: {
gte: new Date(Date.now() - { day: 86400000, week: 604800000, month: 2592000000, year: 31536000000 }[timeRange]),

View file

@ -15,60 +15,56 @@ import { nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@tomodachi-
import { RateLimit } from "@/lib/rate-limit";
import { generateMetadataImage, validateImage } from "@/lib/images";
import Mii from "../../../../../shared/src/mii.js/mii";
import { convertQrCode, minifyInstructions, ThreeDsTomodachiLifeMii } from "@tomodachi-share/shared";
import { convertQrCode, minifyInstructions, SwitchTomodachiLifeMii, ThreeDsTomodachiLifeMii } from "@tomodachi-share/shared";
import { SwitchMiiInstructions } from "@tomodachi-share/shared";
import { settings } from "../../../lib/settings";
import { CharInfoEx } from "charinfo-ex";
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
const submitSchema = z
.object({
platform: z.enum(MiiPlatform).default("THREE_DS"),
name: nameSchema,
tags: tagsSchema,
description: z.string().trim().max(512).optional(),
const submitSchema = z.object({
platform: z.enum(MiiPlatform).default("THREE_DS"),
name: nameSchema,
tags: tagsSchema,
description: z.string().trim().max(512).optional(),
// Switch
gender: z.enum(MiiGender).default("MALE"),
makeup: z.enum(MiiMakeup).default("PARTIAL"),
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(),
youtubeId: z
.string()
.trim()
.transform((val) => (val === "" ? null : val))
.refine((val) => val === null || /^[a-zA-Z0-9_-]{11}$/.test(val), "Invalid YouTube video ID")
.optional(),
instructions: switchMiiInstructionsSchema,
// Switch
gender: z.enum(MiiGender).default("MALE"),
makeup: z.enum(MiiMakeup).default("PARTIAL"),
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
youtubeId: z
.string()
.trim()
.transform((val) => (val === "" ? null : val))
.refine((val) => val === null || /^[a-zA-Z0-9_-]{11}$/.test(val), "Invalid YouTube video ID")
.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",
})
.nullish(),
way: z.enum(["savedata", "manual"]).optional(),
// 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(),
})
// This refine function is probably useless
.refine(
(data) => {
// If platform is Switch, gender, miiPortraitImage, and miiFeaturesImage must be present
if (data.platform === "SWITCH") {
return data.gender !== undefined && data.miiPortraitImage !== undefined && data.miiFeaturesImage !== undefined;
}
return true;
},
{
message: "Gender, Mii portrait & features image are required for Switch platform",
path: ["gender", "miiPortraitImage", "miiFeaturesImage"],
},
);
// Save data way
miiDataFile: z
.instanceof(File)
.refine((blob) => blob.size < 1024 * 1024 * 0.1, "File too large") // TODO: actual size
.optional(),
// Manual way
miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(),
instructions: switchMiiInstructionsSchema,
// 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",
})
.nullish(),
// 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(),
});
export async function POST(request: NextRequest) {
const session = await auth();
@ -106,8 +102,12 @@ export async function POST(request: NextRequest) {
gender: formData.get("gender") ?? undefined, // ZOD MOMENT
makeup: formData.get("makeup") ?? undefined,
miiPortraitImage: formData.get("miiPortraitImage"),
miiFeaturesImage: formData.get("miiFeaturesImage"),
youtubeId: formData.get("youtubeId"),
way: formData.get("way"),
miiDataFile: formData.get("miiDataFile") ?? undefined,
miiFeaturesImage: formData.get("miiFeaturesImage"),
instructions: minifiedInstructions,
qrBytesRaw: rawQrBytesRaw,
@ -131,6 +131,8 @@ export async function POST(request: NextRequest) {
qrBytesRaw,
gender,
makeup,
way,
miiDataFile,
miiPortraitImage,
miiFeaturesImage,
youtubeId,
@ -161,9 +163,10 @@ export async function POST(request: NextRequest) {
// Check Mii portrait & features image (Switch)
if (platform === "SWITCH") {
const portraitValidation = await validateImage(miiPortraitImage);
const featuresValidation = await validateImage(miiFeaturesImage);
if (!portraitValidation.valid)
return rateLimit.sendResponse({ error: `Failed to verify portrait: ${portraitValidation.error}` }, portraitValidation.status ?? 400);
const featuresValidation = await validateImage(miiFeaturesImage);
if (!featuresValidation.valid)
return rateLimit.sendResponse({ error: `Failed to verify features: ${featuresValidation.error}` }, featuresValidation.status ?? 400);
}
@ -180,6 +183,21 @@ export async function POST(request: NextRequest) {
}
}
const miiDataFileBuffer = miiDataFile ? await miiDataFile.arrayBuffer() : undefined;
const miiData = miiDataFileBuffer ? CharInfoEx.FromShareMiiFileArrayBuffer(miiDataFileBuffer) : undefined;
let parsedSwitchMii: SwitchTomodachiLifeMii | undefined = undefined;
if (way === "savedata") {
if (!miiData || !miiDataFileBuffer) return rateLimit.sendResponse({ error: "No valid Mii data provided" }, 400);
try {
parsedSwitchMii = new SwitchTomodachiLifeMii(miiDataFileBuffer, miiData);
} catch (error) {
console.warn("Failed to verify Switch Mii data", error);
return rateLimit.sendResponse({ error: "Failed to verify Mii data: is your ShareMii file up to date?" }, 400);
}
}
// Create Mii in database
const miiRecord = await prisma.mii.create({
data: {
@ -204,6 +222,7 @@ export async function POST(request: NextRequest) {
youtubeId,
instructions: minifiedInstructions,
makeup: makeup ?? "PARTIAL",
...(way === "savedata" && { isFromSaveFile: true }),
}),
},
});
@ -228,18 +247,20 @@ export async function POST(request: NextRequest) {
} else if (platform === "SWITCH") {
portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer());
// Save features image
const featuresBuffer = Buffer.from(await miiFeaturesImage.arrayBuffer());
const pngBuffer = await sharp(featuresBuffer)
.resize({
height: 800,
fit: "inside",
withoutEnlargement: true,
})
.png({ quality: 85 })
.toBuffer();
const fileLocation = path.join(miiUploadsDirectory, "features.png");
await fs.writeFile(fileLocation, pngBuffer);
const pngBuffer = await sharp(featuresBuffer).resize({ height: 800, fit: "inside", withoutEnlargement: true }).png({ quality: 85 }).toBuffer();
await fs.writeFile(path.join(miiUploadsDirectory, "features.png"), pngBuffer);
if (way === "savedata" && miiDataFileBuffer) {
await fs.writeFile(path.join(miiUploadsDirectory, "data.ltd"), Buffer.from(miiDataFileBuffer));
if (parsedSwitchMii) {
const pngBuffer = await parsedSwitchMii.extractFacePaintImage();
if (pngBuffer) await fs.writeFile(path.join(miiUploadsDirectory, "facepaint.png"), pngBuffer);
} else {
return rateLimit.sendResponse({ error: "Failed to extract Switch Mii data" }, 500);
}
}
}
// Save portrait image

View file

@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit";
import { idSchema } from "@tomodachi-share/shared/schemas";
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const rateLimit = new RateLimit(request, 4, "/mii/download");
const check = await rateLimit.handle();
if (check) return check;
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const miiId = parsed.data;
const mii = await prisma.mii.findUnique({
where: { id: miiId },
});
if (!mii) return new NextResponse("Not found", { status: 404 });
try {
const buffer = await fs.readFile(path.join(process.cwd(), "uploads", "mii", miiId.toString(), "data.ltd"));
return new NextResponse(buffer, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${mii.name}.ltd"`,
},
});
} catch {
return rateLimit.sendResponse({ error: "File not found" }, 404);
}
}

View file

@ -12,8 +12,8 @@ import { prisma } from "@/lib/prisma";
const searchParamsSchema = z.object({
type: z
.enum(["mii", "qr-code", "features", "image0", "image1", "image2", "metadata"], {
message: "Image type must be either 'mii', 'qr-code', 'features', 'image[number from 0 to 2]' or 'metadata'",
.enum(["mii", "qr-code", "features", "facepaint", "image0", "image1", "image2", "metadata"], {
message: "Image type must be either 'mii', 'qr-code', 'features', 'facepaint', 'image[number from 0 to 2]' or 'metadata'",
})
.default("mii"),
});