mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-06-27 22:24:14 +00:00
feat: .ltd files
no automatic instructions
This commit is contained in:
parent
a000447b0a
commit
817bda4993
36 changed files with 1733 additions and 1037 deletions
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
36
backend/src/app/mii/[id]/download/route.ts
Normal file
36
backend/src/app/mii/[id]/download/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue