diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8cdcf26..d92ce0c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 diff --git a/src/app/api/mii/[id]/edit/route.ts b/src/app/api/mii/[id]/edit/route.ts index 822b18d..54bb48f 100644 --- a/src/app/api/mii/[id]/edit/route.ts +++ b/src/app/api/mii/[id]/edit/route.ts @@ -93,7 +93,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< makeup: formData.get("makeup") ?? undefined, miiPortraitImage: formData.get("miiPortraitImage"), miiFeaturesImage: formData.get("miiFeaturesImage"), - youtubeId: formData.get("youtubeId"), + youtubeId: formData.get("youtubeId") ?? undefined, instructions: minifiedInstructions, image1: formData.get("image1"), image2: formData.get("image2"), @@ -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 && diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts index e34bc66..4238698 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -39,8 +39,9 @@ const submitSchema = z miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(), youtubeId: z .string() - .regex(/^[a-zA-Z0-9_-]{11}$/, "Invalid YouTube video ID") - .or(z.literal("")) + .trim() + .transform((val) => (val === "" ? null : val)) + .refine((val) => val === null || /^[a-zA-Z0-9_-]{11}$/.test(val), "Invalid YouTube video ID") .optional(), instructions: switchMiiInstructionsSchema, @@ -62,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"], }, ); @@ -160,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 ?? []); @@ -201,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" @@ -339,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 }); } diff --git a/src/app/out/page.tsx b/src/app/out/page.tsx new file mode 100644 index 0000000..d72c91b --- /dev/null +++ b/src/app/out/page.tsx @@ -0,0 +1,72 @@ +import { Metadata } from "next"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { Icon } from "@iconify/react"; + +export const metadata: Metadata = { + title: "Leaving TomodachiShare", + description: "Warning: You are leaving TomodachiShare, proceed with caution", +}; + +interface Props { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +} + +export default async function LinkOutPage({ searchParams }: Props) { + const url = (await searchParams).url; + if (!url || Array.isArray(url)) redirect("/"); + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + redirect("/"); // redirect if URL is invalid + } + + // Next.js doesn't allow attacks like these but you can never be too safe + if (!["http:", "https:"].includes(parsed.protocol)) redirect("/"); + + const isSafe = Array.from(SAFE_LINKS).some((domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`)); + if (isSafe) redirect(url); + + return ( +
You're attempting to leave TomodachiShare island! The destination website is potentially dangerous.
+ +{url}
+
- {/* Adds fancy formatting when linking to other pages on the site */}
- {(() => {
- const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://tomodachishare.com";
+ {parts.map(async (part, index) => {
+ try {
+ // Check if it's a URL
+ if (!urlRegex.test(part)) throw new Error("Not a URL");
+ const url = new URL(part);
- // Match both mii and profile links
- const regex = new RegExp(`(${baseUrl.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}/(?:mii|profile)/\\d+)`, "g");
- const parts = text.split(regex);
-
- return parts.map(async (part, index) => {
- const miiMatch = part.match(new RegExp(`^${baseUrl}/mii/(\\d+)$`));
- const profileMatch = part.match(new RegExp(`^${baseUrl}/profile/(\\d+)$`));
-
- if (miiMatch) {
- const id = Number(miiMatch[1]);
- const linkedMii = await prisma.mii.findUnique({
- where: {
- id,
- },
- });
-
- if (!linkedMii) return;
-
- return (
-
-