From 903f36f1ee24c2da3cfb65366bd5707002daa54e Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Thu, 9 Apr 2026 12:44:13 +0100 Subject: [PATCH 1/6] fix: edit form youtubeId errors --- src/app/api/mii/[id]/edit/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/mii/[id]/edit/route.ts b/src/app/api/mii/[id]/edit/route.ts index 822b18d..17bcb38 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"), From ed705966196c942738675f0679f765dbd4011757 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Thu, 9 Apr 2026 12:51:33 +0100 Subject: [PATCH 2/6] fix: youtubeId should be null not empty on submit form --- src/app/api/submit/route.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts index e34bc66..b48ddeb 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, From 41876cbe730441d298133a714a7d8d7d3de9a367 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Thu, 9 Apr 2026 12:56:13 +0100 Subject: [PATCH 3/6] fix: tone not working --- src/components/mii/voice-viewer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/mii/voice-viewer.tsx b/src/components/mii/voice-viewer.tsx index 7a1d965..159bb4a 100644 --- a/src/components/mii/voice-viewer.tsx +++ b/src/components/mii/voice-viewer.tsx @@ -28,9 +28,9 @@ export default function VoiceViewer({ data, onChange, onClickTone }: Props) { type="button" key={i} onClick={() => { - if (onClickTone) onClickTone(i); + if (onClickTone) onClickTone(i + 1); }} - className={`transition-colors duration-100 rounded-xl hover:bg-orange-300 cursor-pointer ${data.tone === i ? "bg-orange-400!" : ""}`} + className={`transition-colors duration-100 rounded-xl hover:bg-orange-300 cursor-pointer ${data.tone === i + 1 ? "bg-orange-400!" : ""}`} > {i + 1} From a5080f1b2e65bd92797deec61fa1b74bbe427105 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Thu, 9 Apr 2026 12:58:38 +0100 Subject: [PATCH 4/6] feat: show positive/negative sign on most instructions --- src/components/mii/instructions.tsx | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/components/mii/instructions.tsx b/src/components/mii/instructions.tsx index 7a7b18b..0782335 100644 --- a/src/components/mii/instructions.tsx +++ b/src/components/mii/instructions.tsx @@ -30,6 +30,10 @@ function not(value: any) { return value !== undefined && value !== null; } +function numberValue(value: number, cutoff: number = 25) { + return value === cutoff ? "0" : value > cutoff ? `+${value - cutoff}` : `${value - cutoff}`; +} + function GridPosition({ index, cols = 5 }: { index: number; cols?: number }) { const row = Math.floor(index / cols) + 1; const col = (index % cols) + 1; @@ -101,11 +105,11 @@ function Section({ name, instructions, children, isSubSection }: SectionProps) { )} - {not(height) && {height}} - {not(distance) && {distance}} - {not(rotation) && {rotation}} - {not(size) && {size}} - {not(stretch) && {stretch}} + {not(height) && {numberValue(height!, 0)}} + {not(distance) && {numberValue(distance!, 0)}} + {not(rotation) && {numberValue(rotation!, 0)}} + {not(size) && {numberValue(size!, 0)}} + {not(stretch) && {numberValue(stretch!, 0)}} {children} @@ -194,13 +198,13 @@ export default function MiiInstructions({ instructions }: Props) { )} {(height || weight || datingPreferences || voice || personality) && ( -
+

Misc

- {not(height) && {height === 64 ? "0" : height! > 64 ? `+${height! - 64}` : `${height! - 64}`}} - {not(weight) && {weight === 64 ? "0" : weight! > 64 ? `+${weight! - 64}` : `${weight! - 64}`}} + {not(height) && {numberValue(height!, 64)}} + {not(weight) && {numberValue(weight!, 64)}}
{birthday && ( @@ -221,10 +225,10 @@ export default function MiiInstructions({ instructions }: Props) {

Voice

- {not(voice.speed) && {voice.speed}} - {not(voice.pitch) && {voice.pitch}} - {not(voice.depth) && {voice.depth}} - {not(voice.delivery) && {voice.delivery}} + {not(voice.speed) && {numberValue(voice.speed!, 25)}} + {not(voice.pitch) && {numberValue(voice.pitch!, 25)}} + {not(voice.depth) && {numberValue(voice.depth!, 25)}} + {not(voice.delivery) && {numberValue(voice.delivery!, 25)}} {not(voice.tone) && {voice.tone}}
From 913f0ef65a4f79429e745288ea5a3bf80ff64362 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Thu, 9 Apr 2026 15:01:25 +0100 Subject: [PATCH 5/6] fix: can't edit custom images properly also cleanup --- DEVELOPMENT.md | 2 ++ src/app/api/mii/[id]/edit/route.ts | 35 +++++++++--------------- src/app/api/submit/route.ts | 24 +++++++++------- src/components/submit-form/edit-form.tsx | 7 ++++- src/lib/images.tsx | 20 -------------- 5 files changed, 35 insertions(+), 53 deletions(-) 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 17bcb38..54bb48f 100644 --- a/src/app/api/mii/[id]/edit/route.ts +++ b/src/app/api/mii/[id]/edit/route.ts @@ -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 b48ddeb..4238698 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -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 }); } diff --git a/src/components/submit-form/edit-form.tsx b/src/components/submit-form/edit-form.tsx index ce14cc6..5634070 100644 --- a/src/components/submit-form/edit-form.tsx +++ b/src/components/submit-form/edit-form.tsx @@ -50,6 +50,11 @@ export default function EditForm({ mii, likes }: Props) { const session = useSession(); const [files, setFiles] = useState([]); + const handleFilesChange: React.Dispatch> = (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) {
- +
diff --git a/src/lib/images.tsx b/src/lib/images.tsx index eced945..01b4013 100644 --- a/src/lib/images.tsx +++ b/src/lib/images.tsx @@ -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); From 0e2df242d02818679f635f44627040902f96cde4 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Thu, 9 Apr 2026 15:50:46 +0100 Subject: [PATCH 6/6] feat: formatted links in descriptions --- src/app/out/page.tsx | 72 +++++++++++++++++++++++++++ src/components/description.tsx | 91 +++++++++++----------------------- 2 files changed, 100 insertions(+), 63 deletions(-) create mode 100644 src/app/out/page.tsx 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 ( +
+
+

+ + Warning +

+

You're attempting to leave TomodachiShare island! The destination website is potentially dangerous.

+ +
+ {url} +
+ +
+ + + Travel Back + + + + Continue + +
+
+
+ ); +} + +const SAFE_LINKS = new Set([ + "tomodachishare.com", + "trafficlunar.net", + "youtube.com", + "youtu.be", + "twitter.com", + "x.com", + "reddit.com", + "tiktok.com", + "tumblr.com", + "instagram.com", + "wikipedia.org", +]); diff --git a/src/components/description.tsx b/src/components/description.tsx index deacf3f..4f1349d 100644 --- a/src/components/description.tsx +++ b/src/components/description.tsx @@ -1,78 +1,43 @@ -import Image from "next/image"; +import { Icon } from "@iconify/react"; import Link from "next/link"; -import { prisma } from "@/lib/prisma"; - -import ProfilePicture from "./profile-picture"; - interface Props { text: string; className?: string; } +// Adds fancy formatting to links export default function Description({ text, className }: Props) { + const urlRegex = /(https?:\/\/[^\s]+)/g; + const parts = text.split(urlRegex); + return (

- {/* 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 ( - - mii - {linkedMii.name} - - ); - } - - if (profileMatch) { - const id = Number(profileMatch[1]); - const linkedProfile = await prisma.user.findUnique({ - where: { - id, - }, - }); - - if (!linkedProfile) return; - - return ( - - - {linkedProfile.name} - - ); - } - - // Regular text + return ( + + {url.hostname} + {url.pathname !== "/" ? url.pathname : ""} + {url.search} + + + ); + } catch { + // Normal text/Invalid URL fallback return {part}; - }); - })()} + } + })}

); }