From 2e4611520df7e84294e1da48e977d8093b426269 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Sat, 12 Apr 2025 17:01:16 +0100 Subject: [PATCH] refactor: cleanup - schema edition whole bunch of refactors (especially with schemas) --- src/app/api/auth/username/route.ts | 4 +-- src/app/api/like/route.ts | 16 ++++++++--- src/app/api/submit/route.ts | 41 ++++++++++++++------------- src/app/components/mii-list/index.tsx | 1 - src/app/components/search-bar.tsx | 11 ++----- src/app/components/tag-selector.tsx | 2 +- src/lib/schemas.ts | 1 + 7 files changed, 40 insertions(+), 36 deletions(-) diff --git a/src/app/api/auth/username/route.ts b/src/app/api/auth/username/route.ts index 9dd62e1..24ca704 100644 --- a/src/app/api/auth/username/route.ts +++ b/src/app/api/auth/username/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/lib/auth"; @@ -10,7 +10,7 @@ const usernameSchema = z .max(20, "Username cannot be more than 20 characters long") .regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"); -export async function PATCH(request: Request) { +export async function PATCH(request: NextRequest) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/src/app/api/like/route.ts b/src/app/api/like/route.ts index 7b9974a..28ffa5c 100644 --- a/src/app/api/like/route.ts +++ b/src/app/api/like/route.ts @@ -1,16 +1,24 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; -export async function PATCH(request: Request) { +const likeSchema = z.object({ + miiId: z.coerce.number().int({ message: "Mii ID must be an integer" }).positive({ message: "Mii ID must be valid" }), +}); + +export async function PATCH(request: NextRequest) { // todo: rate limit const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - const { miiId } = await request.json(); - if (!miiId) return NextResponse.json({ error: "Mii ID is required" }, { status: 400 }); + const body = await request.json(); + const parsed = likeSchema.safeParse(body); + + if (!parsed.success) return NextResponse.json({ error: parsed.error.errors[0].message }, { status: 400 }); + const { miiId } = parsed.data; const result = await prisma.$transaction(async (tx) => { const existingLike = await tx.like.findUnique({ diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts index a8aed1d..b689515 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; import fs from "fs/promises"; import path from "path"; @@ -17,31 +18,33 @@ import TomodachiLifeMii from "@/lib/tomodachi-life-mii"; const uploadsDirectory = path.join(process.cwd(), "public", "mii"); +const submitSchema = z.object({ + name: nameSchema, + tags: tagsSchema, + qrBytesRaw: z + .array(z.number(), { required_error: "A QR code is required" }) + .length(372, { message: "QR code size is not a valid Tomodachi Life QR code" }), + image1: z.instanceof(File).optional(), + image2: z.instanceof(File).optional(), + image3: z.instanceof(File).optional(), +}); + export async function POST(request: Request) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const formData = await request.formData(); + const parsed = submitSchema.safeParse({ + name: formData.get("name"), + tags: JSON.parse(formData.get("tags") as string), + qrBytesRaw: JSON.parse(formData.get("qrBytesRaw") as string), + image1: formData.get("image1"), + image2: formData.get("image2"), + image3: formData.get("image3"), + }); - const name = formData.get("name") as string; - const tags: string[] = JSON.parse(formData.get("tags") as string); - const qrBytesRaw: number[] = JSON.parse(formData.get("qrBytesRaw") as string); - - const image1 = formData.get("image1") as File; - const image2 = formData.get("image2") as File; - const image3 = formData.get("image3") as File; - - if (!name) return NextResponse.json({ error: "Name is required" }, { status: 400 }); - if (!tags || tags.length == 0) return NextResponse.json({ error: "At least one tag is required" }, { status: 400 }); - if (!qrBytesRaw || qrBytesRaw.length == 0) return NextResponse.json({ error: "A QR code is required" }, { status: 400 }); - - const nameValidation = nameSchema.safeParse(name); - if (!nameValidation.success) return NextResponse.json({ error: nameValidation.error.errors[0].message }, { status: 400 }); - - const tagsValidation = tagsSchema.safeParse(tags); - if (!tagsValidation.success) return NextResponse.json({ error: tagsValidation.error.errors[0].message }, { status: 400 }); - - if (qrBytesRaw.length !== 372) return NextResponse.json({ error: "QR code size is not a valid Tomodachi Life QR code" }, { status: 400 }); + if (!parsed.success) return NextResponse.json({ error: parsed.error.errors[0].message }, { status: 400 }); + const { name, tags, qrBytesRaw, image1, image2, image3 } = parsed.data; // Validate image files const images: File[] = []; diff --git a/src/app/components/mii-list/index.tsx b/src/app/components/mii-list/index.tsx index 245840d..e0715c2 100644 --- a/src/app/components/mii-list/index.tsx +++ b/src/app/components/mii-list/index.tsx @@ -40,7 +40,6 @@ export default async function MiiList({ searchParams, userId, where }: Props) { : []; const whereTags = tagFilter.length > 0 ? { tags: { hasEvery: tagFilter } } : undefined; - // If the mii list is on a user's profile, don't query for the username const userInclude = userId == null ? { diff --git a/src/app/components/search-bar.tsx b/src/app/components/search-bar.tsx index 56456f0..995551a 100644 --- a/src/app/components/search-bar.tsx +++ b/src/app/components/search-bar.tsx @@ -3,20 +3,13 @@ import { useState } from "react"; import { Icon } from "@iconify/react"; import { redirect } from "next/navigation"; -import { z } from "zod"; - -const searchSchema = z - .string() - .trim() - .min(2) - .max(64) - .regex(/^[a-zA-Z0-9_]+$/); +import { nameSchema } from "@/lib/schemas"; export default function SearchBar() { const [query, setQuery] = useState(""); const handleSearch = () => { - const result = searchSchema.safeParse(query); + const result = nameSchema.safeParse(query); if (!result.success) redirect("/"); redirect(`/search?q=${query}`); diff --git a/src/app/components/tag-selector.tsx b/src/app/components/tag-selector.tsx index dd975f8..1072cf6 100644 --- a/src/app/components/tag-selector.tsx +++ b/src/app/components/tag-selector.tsx @@ -9,7 +9,7 @@ interface Props { setTags: React.Dispatch>; } -const tagRegex = /^[a-z-]*$/; +const tagRegex = /^[a-z0-9-_]*$/; const predefinedTags = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"]; export default function TagSelector({ tags, setTags }: Props) { diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 749e88f..57d8c03 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -2,6 +2,7 @@ import { z } from "zod"; export const nameSchema = z .string() + .trim() .min(2, { message: "Name must be at least 2 characters long" }) .max(64, { message: "Name cannot be more than 64 characters long" }) .regex(/^[a-zA-Z0-9-_. ']+$/, {