import { NextRequest, NextResponse } from "next/server"; import * as Sentry from "@sentry/nextjs"; import { z } from "zod"; import { Mii, Prisma } from "@prisma/client"; import fs from "fs/promises"; import path from "path"; import sharp from "sharp"; import { profanity } from "@2toad/profanity"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { idSchema, nameSchema, tagsSchema } from "@/lib/schemas"; import { generateMetadataImage, validateImage } from "@/lib/images"; import { RateLimit } from "@/lib/rate-limit"; const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); const editSchema = z.object({ name: nameSchema.optional(), tags: tagsSchema.optional(), description: z.string().trim().max(256).optional(), 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 PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); Sentry.setUser({ id: session.user?.id, name: session.user?.name }); const rateLimit = new RateLimit(request, 1); // no grouped pathname; edit each mii 1 time a minute const check = await rateLimit.handle(); if (check) return check; // Get Mii ID const { id: slugId } = await params; const parsedId = idSchema.safeParse(slugId); if (!parsedId.success) return rateLimit.sendResponse({ error: parsedId.error.issues[0].message }, 400); const miiId = parsedId.data; // Check ownership of Mii const mii = await prisma.mii.findUnique({ where: { id: miiId, }, }); if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404); if (!(Number(session.user?.id) === mii.userId || Number(session.user?.id) === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID))) return rateLimit.sendResponse({ error: "You don't have ownership of that Mii" }, 403); // Parse form data const formData = await request.formData(); let rawTags: string[] | undefined = undefined; try { const value = formData.get("tags"); if (value) rawTags = JSON.parse(value as string); } catch { return rateLimit.sendResponse({ error: "Invalid JSON in tags" }, 400); } const parsed = editSchema.safeParse({ name: formData.get("name") ?? undefined, tags: rawTags, description: formData.get("description") ?? undefined, image1: formData.get("image1"), image2: formData.get("image2"), image3: formData.get("image3"), }); if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400); const { name, tags, description, image1, image2, image3 } = parsed.data; // Validate image files const images: File[] = []; for (const img of [image1, image2, image3]) { if (!img) continue; const imageValidation = await validateImage(img); if (imageValidation.valid) { images.push(img); } else { return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400); } } // Edit Mii in database const updateData: Prisma.MiiUpdateInput = {}; if (name !== undefined) updateData.name = profanity.censor(name); // Censor potential inappropriate words if (tags !== undefined) updateData.tags = tags.map((t) => profanity.censor(t)); // Same here if (description !== undefined) updateData.description = profanity.censor(description); if (images.length > 0) updateData.imageCount = images.length; if (Object.keys(updateData).length == 0) return rateLimit.sendResponse({ error: "Nothing was changed" }, 400); const updatedMii = await prisma.mii.update({ where: { id: miiId, }, data: updateData, include: { user: { select: { name: true, }, }, }, }); // Only touch files if new images were uploaded if (images.length > 0) { // Ensure directories exist const miiUploadsDirectory = path.join(uploadsDirectory, miiId.toString()); await fs.mkdir(miiUploadsDirectory, { recursive: true }); // 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)))); // Compress and upload new images try { await Promise.all( images.map(async (image, index) => { const buffer = Buffer.from(await image.arrayBuffer()); const pngBuffer = await sharp(buffer).png({ quality: 85 }).toBuffer(); const fileLocation = path.join(miiUploadsDirectory, `image${index}.png`); await fs.writeFile(fileLocation, pngBuffer); }), ); } catch (error) { console.error("Error uploading user images:", error); Sentry.captureException(error, { extra: { stage: "edit-custom-images" } }); return rateLimit.sendResponse({ error: "Failed to store user images" }, 500); } } else if (description === undefined) { // If images or description were not changed, regenerate the metadata image try { await generateMetadataImage(updatedMii, updatedMii.user.name!); } catch (error) { console.error(error); return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500); } } return rateLimit.sendResponse({ success: true }); }