diff --git a/API.md b/API.md new file mode 100644 index 0000000..9913508 --- /dev/null +++ b/API.md @@ -0,0 +1,125 @@ +# TomodachiShare API Reference + +Welcome to the TomodachiShare API Reference! +Some routes may require authentication (see [Protected](#protected-endpoints) section - _TODO_). + +Schema properties marked with a **\*** are required. + +## Public Endpoints + +### **Search Miis** + +`GET /api/search?q={query}` + +Searches Miis by name, tags, and description. + +#### **Query Parameters** + +| Name | Type | Required | Description | +| ------ | ------ | -------- | ----------------------------------------------------------------- | +| **q** | string | **Yes** | The text to search for. Matches names, tags, and descriptions. | +| sort | string | No | Sorting mode: `likes`, `newest`, `oldest`, or `random`. | +| tags | string | No | Comma-separated list of tags. Example: `anime,frieren`. | +| gender | string | No | Gender filter: `MALE` or `FEMALE`. | +| limit | number | No | Number of results per page (1-100). | +| page | number | No | Page number. Defaults to `1`. | +| seed | number | No | Seed used for `random` sorting to ensure unique results per page. | + +#### **Examples** + +``` +https://tomodachishare.com/api/search?q={query} +``` + +``` +https://tomodachishare.com/api/search?q=frieren&sort=random&tags=anime,frieren&gender=MALE&limit=20&page=1&seed=1204 +``` + +#### **Response** + +Returns an array of Mii IDs: + +```json +[1, 204, 295, 1024] +``` + +--- + +### **Get Mii Image / QR Code / Metadata Image** + +`GET /mii/{id}/image?type={type}` + +Retrieves the Mii image, QR code, or metadata graphic. + +#### **Path & Query Parameters** + +| Name | Type | Required | Description | +| -------- | ------ | -------- | ------------------------------------- | +| **id** | number | **Yes** | The Mii’s ID. | +| **type** | string | **Yes** | One of: `mii`, `qr-code`, `metadata`. | + +#### **Examples** + +``` +https://tomodachishare.com/mii/1/image?type=mii +``` + +``` +https://tomodachishare.com/mii/2/image?type=qr-code +``` + +``` +https://tomodachishare.com/mii/3/image?type=metadata +``` + +#### **Response** + +Returns the image file. + +--- + +### **Get Mii Data** + +`GET /mii/{id}/data` + +Fetches metadata for a specific Mii. + +#### **Path Parameters** + +| Name | Type | Required | Description | +| ------ | ------ | -------- | ------------- | +| **id** | number | **Yes** | The Mii’s ID. | + +#### **Example** + +``` +https://tomodachishare.com/mii/1/data +``` + +#### **Response** + +```json +{ + "id": 1, + "name": "Frieren", + "imageCount": 3, + "tags": ["anime", "frieren"], + "description": "Frieren from 'Frieren: Beyond Journey's End'\r\nThe first Mii on the site!", + "firstName": "Frieren", + "lastName": "the Slayer", + "gender": "FEMALE", + "islandName": "Wuhu", + "allowedCopying": false, + "createdAt": "2025-05-04T12:29:41Z", + "user": { + "id": 1, + "username": "trafficlunar", + "name": "trafficlunar" + }, + "likes": 29 +} +``` + +## Protected Endpoints + +_TODO_ diff --git a/DEVELOPMENT.MD b/DEVELOPMENT.md similarity index 100% rename from DEVELOPMENT.MD rename to DEVELOPMENT.md diff --git a/README.md b/README.md index a068f35..54cd8bf 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,9 @@ - 🌎 Browse and add Miis from other players - 🏝️ Build your perfect island by finding the perfect residents -### Development Instructions +### Development Instructions + +### API Reference --- diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 0000000..57c7e35 --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,92 @@ +import { NextRequest } from "next/server"; + +import crypto from "crypto"; +import seedrandom from "seedrandom"; + +import { searchSchema } from "@/lib/schemas"; +import { RateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/prisma"; +import { Prisma } from "@prisma/client"; + +export async function GET(request: NextRequest) { + const rateLimit = new RateLimit(request, 24, "/api/search"); + const check = await rateLimit.handle(); + if (check) return check; + + const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams)); + if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400); + + const { q: query, sort, tags, gender, page = 1, limit = 24, seed } = parsed.data; + + const where: Prisma.MiiWhereInput = { + // Searching + ...(query && { + OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }], + }), + // Tag filtering + ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }), + // Gender + ...(gender && { gender: { equals: gender } }), + }; + + const select: Prisma.MiiSelect = { + id: true, + name: true, + imageCount: true, + tags: true, + createdAt: true, + gender: true, + // Like count + _count: { + select: { likedBy: true }, + }, + }; + + const skip = (page - 1) * limit; + + if (sort === "random") { + // Use seed for consistent random results + const randomSeed = seed || crypto.randomInt(0, 1_000_000_000); + + // Get all IDs that match the where conditions + const matchingIds = await prisma.mii.findMany({ + where, + select: { id: true }, + }); + + if (matchingIds.length === 0) return; + + const rng = seedrandom(randomSeed.toString()); + + // Randomize all IDs using the Durstenfeld algorithm + for (let i = matchingIds.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]]; + } + + // Convert to number[] array and return paginated results + return rateLimit.sendResponse(matchingIds.slice(skip, skip + limit).map((i) => i.id)); + } else { + // Sorting by likes, newest, or oldest + let orderBy: Prisma.MiiOrderByWithRelationInput[]; + + if (sort === "likes") { + orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }]; + } else if (sort === "oldest") { + orderBy = [{ createdAt: "asc" }, { name: "asc" }]; + } else { + // default to newest + orderBy = [{ createdAt: "desc" }, { name: "asc" }]; + } + + const list = await prisma.mii.findMany({ + where, + orderBy, + select: { id: true }, + skip, + take: limit, + }); + + return rateLimit.sendResponse(list.map((mii) => mii.id)); + } +} diff --git a/src/app/mii/[id]/data/route.ts b/src/app/mii/[id]/data/route.ts new file mode 100644 index 0000000..6c20aa4 --- /dev/null +++ b/src/app/mii/[id]/data/route.ts @@ -0,0 +1,49 @@ +import { NextRequest } from "next/server"; + +import { idSchema } from "@/lib/schemas"; +import { RateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const rateLimit = new RateLimit(request, 200, "/mii/data"); + const check = await rateLimit.handle(); + if (check) return check; + + const { id: slugId } = await params; + const parsed = idSchema.safeParse(slugId); + if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400); + const miiId = parsed.data; + + const data = await prisma.mii.findUnique({ + where: { id: miiId }, + select: { + id: true, + name: true, + _count: { + select: { + likedBy: true, + }, + }, + imageCount: true, + tags: true, + description: true, + firstName: true, + lastName: true, + gender: true, + islandName: true, + allowedCopying: true, + createdAt: true, + user: { select: { id: true, username: true, name: true } }, + }, + }); + + if (!data) { + return rateLimit.sendResponse({ error: "Mii not found" }, 404); + } + + const { _count, ...rest } = data; + return rateLimit.sendResponse({ + ...rest, + likes: _count.likedBy, + }); +} diff --git a/src/components/mii-list/index.tsx b/src/components/mii-list/index.tsx index 72486d6..5794cbb 100644 --- a/src/components/mii-list/index.tsx +++ b/src/components/mii-list/index.tsx @@ -1,13 +1,12 @@ import Link from "next/link"; -import { MiiGender, Prisma } from "@prisma/client"; +import { Prisma } from "@prisma/client"; import { Icon } from "@iconify/react"; -import { z } from "zod"; import crypto from "crypto"; import seedrandom from "seedrandom"; -import { querySchema } from "@/lib/schemas"; +import { searchSchema } from "@/lib/schemas"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; @@ -25,36 +24,6 @@ interface Props { inLikesPage?: boolean; // Self-explanatory } -const searchSchema = z.object({ - q: querySchema.optional(), - sort: z.enum(["likes", "newest", "oldest", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"), - tags: z - .string() - .optional() - .transform((value) => - value - ?.split(",") - .map((tag) => tag.trim()) - .filter((tag) => tag.length > 0) - ), - gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(), - // todo: incorporate tagsSchema - // Pages - limit: z.coerce - .number({ error: "Limit must be a number" }) - .int({ error: "Limit must be an integer" }) - .min(1, { error: "Limit must be at least 1" }) - .max(100, { error: "Limit cannot be more than 100" }) - .optional(), - page: z.coerce - .number({ error: "Page must be a number" }) - .int({ error: "Page must be an integer" }) - .min(1, { error: "Page must be at least 1" }) - .optional(), - // Random sort - seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(), -}); - export default async function MiiList({ searchParams, userId, inLikesPage }: Props) { const session = await auth(); diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 102ac10..4bc06bb 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -1,3 +1,4 @@ +import { MiiGender } from "@prisma/client"; import { z } from "zod"; // profanity censoring bypasses the regex in some of these but I think it's funny @@ -39,6 +40,36 @@ export const idSchema = z.coerce .int({ error: "ID must be an integer" }) .positive({ error: "ID must be valid" }); +export const searchSchema = z.object({ + q: querySchema.optional(), + sort: z.enum(["likes", "newest", "oldest", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"), + tags: z + .string() + .optional() + .transform((value) => + value + ?.split(",") + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0) + ), + gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(), + // todo: incorporate tagsSchema + // Pages + limit: z.coerce + .number({ error: "Limit must be a number" }) + .int({ error: "Limit must be an integer" }) + .min(1, { error: "Limit must be at least 1" }) + .max(100, { error: "Limit cannot be more than 100" }) + .optional(), + page: z.coerce + .number({ error: "Page must be a number" }) + .int({ error: "Page must be an integer" }) + .min(1, { error: "Page must be at least 1" }) + .optional(), + // Random sort + seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(), +}); + // Account Info export const usernameSchema = z .string()