From f633648feedfa5f7b83f9ed19dfdc13c9c08499a Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Fri, 2 May 2025 18:32:01 +0100 Subject: [PATCH] feat: reporting --- .../20250502172234_reports/migration.sql | 25 +++++ prisma/schema.prisma | 36 +++++++ src/app/api/report/route.ts | 93 +++++++++++++++++++ src/app/mii/[id]/image/route.ts | 2 +- src/app/mii/[id]/page.tsx | 3 + src/app/privacy/page.tsx | 2 +- src/app/profile/settings/page.tsx | 4 - src/app/report/mii/[id]/page.tsx | 47 ++++++++++ src/app/report/user/[id]/page.tsx | 40 ++++++++ src/app/terms-of-service/page.tsx | 11 ++- src/components/profile-information.tsx | 6 ++ src/components/report/mii-form.tsx | 83 +++++++++++++++++ src/components/report/reason-selector.tsx | 64 +++++++++++++ src/components/report/user-form.tsx | 87 +++++++++++++++++ src/lib/rate-limit.ts | 2 +- 15 files changed, 494 insertions(+), 11 deletions(-) create mode 100644 prisma/migrations/20250502172234_reports/migration.sql create mode 100644 src/app/api/report/route.ts create mode 100644 src/app/report/mii/[id]/page.tsx create mode 100644 src/app/report/user/[id]/page.tsx create mode 100644 src/components/report/mii-form.tsx create mode 100644 src/components/report/reason-selector.tsx create mode 100644 src/components/report/user-form.tsx diff --git a/prisma/migrations/20250502172234_reports/migration.sql b/prisma/migrations/20250502172234_reports/migration.sql new file mode 100644 index 0000000..622331a --- /dev/null +++ b/prisma/migrations/20250502172234_reports/migration.sql @@ -0,0 +1,25 @@ +-- CreateEnum +CREATE TYPE "ReportType" AS ENUM ('MII', 'USER'); + +-- CreateEnum +CREATE TYPE "ReportReason" AS ENUM ('INAPPROPRIATE', 'SPAM', 'COPYRIGHT', 'OTHER'); + +-- CreateEnum +CREATE TYPE "ReportStatus" AS ENUM ('OPEN', 'RESOLVED', 'DISMISSED'); + +-- CreateTable +CREATE TABLE "reports" ( + "id" SERIAL NOT NULL, + "reportType" "ReportType" NOT NULL, + "status" "ReportStatus" NOT NULL DEFAULT 'OPEN', + "targetId" INTEGER NOT NULL, + "reason" "ReportReason" NOT NULL, + "reasonNotes" TEXT, + "authorId" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "reports_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "reports" ADD CONSTRAINT "reports_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 330641a..bc8f8d3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,7 @@ model User { sessions Session[] miis Mii[] likes Like[] + Report Report[] @@map("users") } @@ -92,3 +93,38 @@ model Like { @@id([userId, miiId]) @@map("likes") } + +model Report { + id Int @id @default(autoincrement()) + reportType ReportType + status ReportStatus @default(OPEN) + targetId Int + + reason ReportReason + reasonNotes String? + + authorId Int? + createdAt DateTime @default(now()) + + user User? @relation(fields: [authorId], references: [id]) + + @@map("reports") +} + +enum ReportType { + MII + USER +} + +enum ReportReason { + INAPPROPRIATE + SPAM + COPYRIGHT + OTHER +} + +enum ReportStatus { + OPEN + RESOLVED + DISMISSED +} diff --git a/src/app/api/report/route.ts b/src/app/api/report/route.ts new file mode 100644 index 0000000..472cd12 --- /dev/null +++ b/src/app/api/report/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { ReportReason, ReportStatus, ReportType } from "@prisma/client"; + +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { RateLimit } from "@/lib/rate-limit"; + +const reportSchema = z.object({ + id: z.coerce.number({ message: "ID must be a number" }).int({ message: "ID must be an integer" }).positive({ message: "ID must be valid" }), + type: z.enum(["mii", "user"], { message: "Type must be either 'mii' or 'user'" }), + reason: z.enum(["inappropriate", "spam", "copyright", "other"], { + message: "Reason must be either 'inappropriate', 'spam', 'copyright', or 'other'", + }), + notes: z.string().trim().max(256).optional(), +}); + +const getReportSchema = z.object({ + status: z.enum(["open", "resolved", "dismissed"], { message: "Status must be either 'open', 'resolved', or 'dismissed'" }).default("open"), +}); + +export async function POST(request: NextRequest) { + const session = await auth(); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const rateLimit = new RateLimit(request, 2); + const check = await rateLimit.handle(); + if (check) return check; + + const body = await request.json(); + const parsed = reportSchema.safeParse(body); + + if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.errors[0].message }, 400); + const { id, type, reason, notes } = parsed.data; + + // Check if the Mii or User exists + if (type === "mii") { + const mii = await prisma.mii.findUnique({ where: { id } }); + if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404); + } else { + const user = await prisma.user.findUnique({ where: { id } }); + if (!user) return rateLimit.sendResponse({ error: "User not found" }, 404); + } + + // Check if user creating the report has already reported the same target before + const existing = await prisma.report.findFirst({ + where: { + targetId: id, + reportType: type.toUpperCase() as ReportType, + authorId: Number(session.user.id), + }, + }); + + if (existing) return rateLimit.sendResponse({ error: "You have already reported this" }, 400); + + try { + await prisma.report.create({ + data: { + reportType: type.toUpperCase() as ReportType, + targetId: id, + reason: reason.toUpperCase() as ReportReason, + reasonNotes: notes, + authorId: Number(session.user.id), + }, + }); + } catch (error) { + console.error("Report creation failed", error); + return rateLimit.sendResponse({ error: "Failed to create report" }, 500); + } + + return rateLimit.sendResponse({ success: true }); +} + +export async function GET(request: NextRequest) { + const session = await auth(); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + // Check if user is an admin + if (Number(session.user.id) != Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) + return NextResponse.json({ error: "You're not an admin" }, { status: 403 }); + + const parsed = getReportSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams)); + if (!parsed.success) return NextResponse.json({ error: parsed.error.errors[0].message }, { status: 400 }); + const { status } = parsed.data; + + const reports = await prisma.report.findMany({ + where: { + status: (status.toUpperCase() as ReportStatus) ?? "OPEN", + }, + }); + + return NextResponse.json({ success: true, reports }); +} diff --git a/src/app/mii/[id]/image/route.ts b/src/app/mii/[id]/image/route.ts index 1baaa55..d72eedb 100644 --- a/src/app/mii/[id]/image/route.ts +++ b/src/app/mii/[id]/image/route.ts @@ -35,6 +35,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const buffer = await fs.readFile(filePath); return new NextResponse(buffer); } catch { - return rateLimit.sendResponse({ success: false, error: "Image not found" }, 404); + return rateLimit.sendResponse({ error: "Image not found" }, 404); } } diff --git a/src/app/mii/[id]/page.tsx b/src/app/mii/[id]/page.tsx index 7cd9704..04934cb 100644 --- a/src/app/mii/[id]/page.tsx +++ b/src/app/mii/[id]/page.tsx @@ -176,6 +176,9 @@ export default async function MiiPage({ params }: Props) { )} + + + diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 247544b..8f90254 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -83,7 +83,7 @@ export default function PrivacyPage() {

As a user, you have the right to:

-
    +
    • Access the personal data we hold about you.
    • Request corrections to any inaccurate or incomplete information.
    • Request the deletion of your personal data.
    • diff --git a/src/app/profile/settings/page.tsx b/src/app/profile/settings/page.tsx index 6728f74..4d0b255 100644 --- a/src/app/profile/settings/page.tsx +++ b/src/app/profile/settings/page.tsx @@ -1,9 +1,5 @@ import { Metadata } from "next"; import { redirect } from "next/navigation"; -import Image from "next/image"; -import Link from "next/link"; - -import { Icon } from "@iconify/react"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; diff --git a/src/app/report/mii/[id]/page.tsx b/src/app/report/mii/[id]/page.tsx new file mode 100644 index 0000000..da40d1a --- /dev/null +++ b/src/app/report/mii/[id]/page.tsx @@ -0,0 +1,47 @@ +import { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +import ReportMiiForm from "@/components/report/mii-form"; + +interface Props { + params: Promise<{ id: string }>; +} + +export const metadata: Metadata = { + title: "Report Mii - TomodachiShare", + description: "Report a Mii on TomodachiShare", + robots: { + index: false, + follow: false, + }, +}; + +export default async function ReportMiiPage({ params }: Props) { + const session = await auth(); + const { id } = await params; + + const mii = await prisma.mii.findUnique({ + where: { + id: Number(id), + }, + include: { + _count: { + select: { + likedBy: true, + }, + }, + }, + }); + + if (!session) redirect("/login"); + if (!mii) redirect("/404"); + + return ( +
      + +
      + ); +} diff --git a/src/app/report/user/[id]/page.tsx b/src/app/report/user/[id]/page.tsx new file mode 100644 index 0000000..fae91bc --- /dev/null +++ b/src/app/report/user/[id]/page.tsx @@ -0,0 +1,40 @@ +import { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +import ReportUserForm from "@/components/report/user-form"; + +interface Props { + params: Promise<{ id: string }>; +} + +export const metadata: Metadata = { + title: "Report User - TomodachiShare", + description: "Report a user on TomodachiShare", + robots: { + index: false, + follow: false, + }, +}; + +export default async function ReportUserPage({ params }: Props) { + const session = await auth(); + const { id } = await params; + + const user = await prisma.user.findUnique({ + where: { + id: Number(id), + }, + }); + + if (!session) redirect("/login"); + if (!user) redirect("/404"); + + return ( +
      + +
      + ); +} diff --git a/src/app/terms-of-service/page.tsx b/src/app/terms-of-service/page.tsx index 79c691c..81daae9 100644 --- a/src/app/terms-of-service/page.tsx +++ b/src/app/terms-of-service/page.tsx @@ -10,7 +10,7 @@ export default function PrivacyPage() {

      Terms of Service

      - Effective Date: April 23, 2025 + Effective Date: May 02, 2025


      @@ -33,7 +33,7 @@ export default function PrivacyPage() {

      As a user of this site, you must abide by these guidelines:

      -
        +
        • Nothing that would interfere with or gain unauthorized access to the website or its systems.
        • Nothing that is against the law in the United Kingdom.
        • No NSFW, violent, gory, or inappropriate Miis or images.
        • @@ -44,6 +44,9 @@ export default function PrivacyPage() {
        • Avoid using inappropriate language. Profanity may be automatically censored.
        • No use of automated scripts, bots, or scrapers to access or interact with the site.
        +

        + If you find anybody or a Mii breaking these rules, please report it by going to their page and clicking the "Report" button. +

    • @@ -96,10 +99,10 @@ export default function PrivacyPage() { hello@trafficlunar.net - . + or by reporting the Mii on its page.

      Please include:

      -
        +
        • Your name and contact information
        • A description of the copyrighted work
        • A link to the allegedly infringing material
        • diff --git a/src/components/profile-information.tsx b/src/components/profile-information.tsx index 4eab2f9..b8028a5 100644 --- a/src/components/profile-information.tsx +++ b/src/components/profile-information.tsx @@ -54,6 +54,12 @@ export default async function ProfileInformation({ user: userData, createdAt, in {/* Buttons */}
          + {Number(session?.user.id) != id && ( + + + Report + + )} {Number(session?.user.id) == id && Number(session?.user.id) === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID) && ( diff --git a/src/components/report/mii-form.tsx b/src/components/report/mii-form.tsx new file mode 100644 index 0000000..0379050 --- /dev/null +++ b/src/components/report/mii-form.tsx @@ -0,0 +1,83 @@ +"use client"; + +import Image from "next/image"; +import { redirect } from "next/navigation"; + +import { useState } from "react"; +import { Mii, ReportReason } from "@prisma/client"; + +import ReasonSelector from "./reason-selector"; +import SubmitButton from "../submit-button"; +import LikeButton from "../like-button"; + +interface Props { + mii: Mii; + likes: number; +} + +export default function ReportMiiForm({ mii, likes }: Props) { + const [reason, setReason] = useState(); + const [notes, setNotes] = useState(); + const [error, setError] = useState(undefined); + + const handleSubmit = async () => { + const response = await fetch(`/api/report`, { + method: "POST", + body: JSON.stringify({ id: mii.id, type: "mii", reason: reason?.toLowerCase(), notes }), + }); + const { error } = await response.json(); + + if (!response.ok) { + setError(error); + return; + } + + redirect(`/`); + }; + + return ( +
          +
          +

          Report a Mii

          +

          If you encounter a rule-breaking Mii, please report it here

          +
          + +
          + +
          + mii image +
          +

          {mii.name}

          + +
          +
          + +
          + + +
          + +
          + +