fix: can't edit custom images properly

also cleanup
This commit is contained in:
trafficlunar 2026-04-09 15:01:25 +01:00
parent a5080f1b2e
commit 913f0ef65a
5 changed files with 35 additions and 53 deletions

View file

@ -80,6 +80,8 @@ For Discord, create an application in the developer portal, go to 'OAuth2', copy
For GitHub, navigate to your profile settings, then 'Developer Settings', and create a new application. Set the homepage URL to `http://localhost:3000` and copy the Client ID and generate a new client secret. Finally, add in a callback URL with the value `http://localhost:3000/api/auth/callback/github`.
Google is annoying so I'm not explaining it.
After configuring the environment variables, you can run a development server.
```bash

View file

@ -110,26 +110,28 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
parsed.data;
// Validate image files
let wasImagesModerated = false;
const images: File[] = [];
const customImages: File[] = [];
for (const img of [image1, image2, image3]) {
if (!img) continue;
const validation = await validateImage(img);
if (!validation.valid) wasImagesModerated = true;
images.push(img);
if (validation.valid) {
customImages.push(img);
} else {
return rateLimit.sendResponse({ error: `Failed to verify custom image: ${validation.error}` }, validation.status ?? 400);
}
}
// Check Mii portrait & features image (Switch)
if (mii.platform === "SWITCH") {
if (miiPortraitImage) {
const validation = await validateImage(miiPortraitImage);
if (!validation.valid) wasImagesModerated = true;
if (!validation.valid) return rateLimit.sendResponse({ error: `Failed to verify portrait: ${validation.error}` }, validation.status ?? 400);
}
if (miiFeaturesImage) {
const validation = await validateImage(miiFeaturesImage);
if (!validation.valid) wasImagesModerated = true;
if (!validation.valid) return rateLimit.sendResponse({ error: `Failed to verify features: ${validation.error}` }, validation.status ?? 400);
}
}
@ -147,10 +149,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
if (makeup !== undefined) updateData.makeup = makeup;
if (youtubeId !== undefined) updateData.youtubeId = youtubeId;
if (instructions !== undefined) updateData.instructions = instructions;
if (images.length > 0) updateData.imageCount = images.length;
if (customImages.length > 0) updateData.imageCount = customImages.length;
const imagesChanged = images.length > 0 || miiPortraitImage || miiFeaturesImage;
if ((settings.queueEnabled && imagesChanged) || wasImagesModerated) updateData.in_queue = true;
const imagesChanged = customImages.length > 0 || miiPortraitImage || miiFeaturesImage;
if (settings.queueEnabled && imagesChanged) updateData.in_queue = true;
if (Object.keys(updateData).length === 0) return rateLimit.sendResponse({ error: "Nothing was changed" }, 400);
const updatedMii = await prisma.mii.update({
@ -172,7 +174,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
await fs.mkdir(miiUploadsDirectory, { recursive: true });
// Only touch files if new images were uploaded
if (images.length > 0) {
if (customImages.length > 0) {
// Delete all custom images
const files = await fs.readdir(miiUploadsDirectory);
await Promise.all(files.filter((file) => file.startsWith("image")).map((file) => fs.unlink(path.join(miiUploadsDirectory, file))));
@ -180,7 +182,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
// Compress and upload new images
try {
await Promise.all(
images.map(async (image, index) => {
customImages.map(async (image, index) => {
const buffer = Buffer.from(await image.arrayBuffer());
const pngBuffer = await sharp(buffer).resize({ height: 800, fit: "inside", withoutEnlargement: true }).png({ quality: 85 }).toBuffer();
const fileLocation = path.join(miiUploadsDirectory, `image${index}.png`);
@ -198,17 +200,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
// Only save portrait & features for Switch Miis when they are provided
if (mii.platform === "SWITCH" && (miiPortraitImage || miiFeaturesImage)) {
try {
// Delete existing portrait/features if they're being replaced
await Promise.all(
["mii.png", "features.png"]
.filter((file) => {
if (file === "mii.png") return miiPortraitImage;
if (file === "features.png") return miiFeaturesImage;
return false;
})
.map((file) => fs.unlink(path.join(miiUploadsDirectory, file))),
);
await Promise.all(
[
miiPortraitImage &&

View file

@ -63,13 +63,13 @@ const submitSchema = z
(data) => {
// If platform is Switch, gender, miiPortraitImage, and miiFeaturesImage must be present
if (data.platform === "SWITCH") {
return data.gender !== undefined && data.miiPortraitImage !== undefined;
return data.gender !== undefined && data.miiPortraitImage !== undefined && data.miiFeaturesImage !== undefined;
}
return true;
},
{
message: "Gender, Mii portrait & features image, and instructions are required for Switch platform",
path: ["gender", "miiPortraitImage", "miiFeaturesImage", "instructions"],
message: "Gender, Mii portrait & features image are required for Switch platform",
path: ["gender", "miiPortraitImage", "miiFeaturesImage"],
},
);
@ -161,23 +161,27 @@ export async function POST(request: NextRequest) {
const description = uncensoredDescription && profanity.censor(uncensoredDescription);
// Validate image files
let wasImagesModerated = false;
const customImages: File[] = [];
for (const img of [image1, image2, image3]) {
if (!img) continue;
const validation = await validateImage(img);
if (!validation.valid) wasImagesModerated = true;
customImages.push(img);
if (validation.valid) {
customImages.push(img);
} else {
return rateLimit.sendResponse({ error: `Failed to verify custom image: ${validation.error}` }, validation.status ?? 400);
}
}
// Check Mii portrait & features image (Switch)
if (platform === "SWITCH") {
const portraitValidation = await validateImage(miiPortraitImage);
const featuresValidation = await validateImage(miiFeaturesImage);
if (!portraitValidation.valid) wasImagesModerated = true;
if (!featuresValidation.valid) wasImagesModerated = true;
if (!portraitValidation.valid)
return rateLimit.sendResponse({ error: `Failed to verify portrait: ${portraitValidation.error}` }, portraitValidation.status ?? 400);
if (!featuresValidation.valid)
return rateLimit.sendResponse({ error: `Failed to verify features: ${featuresValidation.error}` }, featuresValidation.status ?? 400);
}
const qrBytes = new Uint8Array(qrBytesRaw ?? []);
@ -202,7 +206,7 @@ export async function POST(request: NextRequest) {
tags,
description,
gender: gender ?? "MALE",
in_queue: wasImagesModerated || settings.queueEnabled,
in_queue: settings.queueEnabled,
// Automatically detect certain information if on 3DS
...(platform === "THREE_DS"
@ -340,5 +344,5 @@ export async function POST(request: NextRequest) {
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
}
return rateLimit.sendResponse({ success: true, id: miiRecord.id, inQueue: wasImagesModerated });
return rateLimit.sendResponse({ success: true, id: miiRecord.id });
}

View file

@ -50,6 +50,11 @@ export default function EditForm({ mii, likes }: Props) {
const session = useSession();
const [files, setFiles] = useState<FileWithPath[]>([]);
const handleFilesChange: React.Dispatch<React.SetStateAction<FileWithPath[]>> = (updater) => {
hasCustomImagesChanged.current = true;
setFiles(updater);
};
const handleDrop = useCallback(
(acceptedFiles: FileWithPath[]) => {
if (files.length >= 3) return;
@ -439,7 +444,7 @@ export default function EditForm({ mii, likes }: Props) {
</Dropzone>
</div>
<ImageList files={files} setFiles={setFiles} />
<ImageList files={files} setFiles={handleFilesChange} />
<hr className="border-zinc-300 my-2" />
<div className="flex justify-between items-center">

View file

@ -52,26 +52,6 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
return { valid: false, error: "Image dimensions are invalid. Resolution must be between 128x128 and 8000x8000" };
}
// Check for inappropriate content
// https://github.com/trafficlunar/api-moderation
try {
const blob = new Blob([buffer]);
const formData = new FormData();
formData.append("image", blob);
const headers = new Headers();
headers.append("token", process.env.TOKEN ?? "");
const moderationResponse = await fetch("https://api.trafficlunar.net/moderate/image", { method: "POST", body: formData, headers });
const result = await moderationResponse.json();
if (result.error) {
return { valid: false, error: result.error };
}
} catch (moderationError) {
console.error("Error fetching moderation API:", moderationError);
Sentry.captureException(moderationError, { extra: { stage: "moderation-api-fetch" } });
return { valid: false, error: "Moderation API is down", status: 503 };
}
return { valid: true };
} catch (error) {
console.error("Error validating image:", error);