feat: reporting

This commit is contained in:
trafficlunar 2025-05-02 18:32:01 +01:00
parent 334b6ec9b6
commit f633648fee
15 changed files with 494 additions and 11 deletions

View file

@ -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;

View file

@ -24,6 +24,7 @@ model User {
sessions Session[] sessions Session[]
miis Mii[] miis Mii[]
likes Like[] likes Like[]
Report Report[]
@@map("users") @@map("users")
} }
@ -92,3 +93,38 @@ model Like {
@@id([userId, miiId]) @@id([userId, miiId])
@@map("likes") @@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
}

View file

@ -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 });
}

View file

@ -35,6 +35,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const buffer = await fs.readFile(filePath); const buffer = await fs.readFile(filePath);
return new NextResponse(buffer); return new NextResponse(buffer);
} catch { } catch {
return rateLimit.sendResponse({ success: false, error: "Image not found" }, 404); return rateLimit.sendResponse({ error: "Image not found" }, 404);
} }
} }

View file

@ -176,6 +176,9 @@ export default async function MiiPage({ params }: Props) {
</> </>
)} )}
<Link href={`/report/mii/${mii.id}`} title="Report Mii" data-tooltip="Report" className="aspect-square">
<Icon icon="material-symbols:flag-rounded" />
</Link>
<ScanTutorialButton /> <ScanTutorialButton />
</div> </div>
</div> </div>

View file

@ -83,7 +83,7 @@ export default function PrivacyPage() {
<section> <section>
<p className="mb-2">As a user, you have the right to:</p> <p className="mb-2">As a user, you have the right to:</p>
<ul className="list-disc list-inside"> <ul className="list-disc list-inside indent-4">
<li>Access the personal data we hold about you.</li> <li>Access the personal data we hold about you.</li>
<li>Request corrections to any inaccurate or incomplete information.</li> <li>Request corrections to any inaccurate or incomplete information.</li>
<li>Request the deletion of your personal data.</li> <li>Request the deletion of your personal data.</li>

View file

@ -1,9 +1,5 @@
import { Metadata } from "next"; import { Metadata } from "next";
import { redirect } from "next/navigation"; 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 { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";

View file

@ -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 (
<div className="flex justify-center w-full">
<ReportMiiForm mii={mii} likes={mii._count.likedBy} />
</div>
);
}

View file

@ -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 (
<div className="flex justify-center w-full">
<ReportUserForm user={user} />
</div>
);
}

View file

@ -10,7 +10,7 @@ export default function PrivacyPage() {
<div> <div>
<h1 className="text-2xl font-bold">Terms of Service</h1> <h1 className="text-2xl font-bold">Terms of Service</h1>
<h2 className="font-light"> <h2 className="font-light">
<strong className="font-medium">Effective Date:</strong> April 23, 2025 <strong className="font-medium">Effective Date:</strong> May 02, 2025
</h2> </h2>
<hr className="border-black/20 mt-1 mb-4" /> <hr className="border-black/20 mt-1 mb-4" />
@ -33,7 +33,7 @@ export default function PrivacyPage() {
<section> <section>
<p className="mb-2">As a user of this site, you must abide by these guidelines:</p> <p className="mb-2">As a user of this site, you must abide by these guidelines:</p>
<ul className="list-disc list-inside"> <ul className="list-disc list-inside indent-4">
<li>Nothing that would interfere with or gain unauthorized access to the website or its systems.</li> <li>Nothing that would interfere with or gain unauthorized access to the website or its systems.</li>
<li>Nothing that is against the law in the United Kingdom.</li> <li>Nothing that is against the law in the United Kingdom.</li>
<li>No NSFW, violent, gory, or inappropriate Miis or images.</li> <li>No NSFW, violent, gory, or inappropriate Miis or images.</li>
@ -44,6 +44,9 @@ export default function PrivacyPage() {
<li>Avoid using inappropriate language. Profanity may be automatically censored.</li> <li>Avoid using inappropriate language. Profanity may be automatically censored.</li>
<li>No use of automated scripts, bots, or scrapers to access or interact with the site.</li> <li>No use of automated scripts, bots, or scrapers to access or interact with the site.</li>
</ul> </ul>
<p className="mt-2">
If you find anybody or a Mii breaking these rules, please report it by going to their page and clicking the &quot;Report&quot; button.
</p>
</section> </section>
</li> </li>
<li> <li>
@ -96,10 +99,10 @@ export default function PrivacyPage() {
<a href="mailto:hello@trafficlunar.net" className="text-blue-700"> <a href="mailto:hello@trafficlunar.net" className="text-blue-700">
hello@trafficlunar.net hello@trafficlunar.net
</a> </a>
. or by reporting the Mii on its page.
</p> </p>
<p className="mb-2">Please include:</p> <p className="mb-2">Please include:</p>
<ul className="list-disc list-inside"> <ul className="list-disc list-inside indent-4">
<li>Your name and contact information</li> <li>Your name and contact information</li>
<li>A description of the copyrighted work</li> <li>A description of the copyrighted work</li>
<li>A link to the allegedly infringing material</li> <li>A link to the allegedly infringing material</li>

View file

@ -54,6 +54,12 @@ export default async function ProfileInformation({ user: userData, createdAt, in
{/* Buttons */} {/* Buttons */}
<div className="flex flex-col items-end justify-end gap-1 max-md:flex-row"> <div className="flex flex-col items-end justify-end gap-1 max-md:flex-row">
{Number(session?.user.id) != id && (
<Link href={`/report/user/${id}`} className="pill button !px-4">
<Icon icon="material-symbols:flag-rounded" className="text-2xl mr-2" />
<span>Report</span>
</Link>
)}
{Number(session?.user.id) == id && Number(session?.user.id) === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID) && ( {Number(session?.user.id) == id && Number(session?.user.id) === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID) && (
<Link href="/admin" className="pill button !px-4"> <Link href="/admin" className="pill button !px-4">
<Icon icon="mdi:shield-moon" className="text-2xl mr-2" /> <Icon icon="mdi:shield-moon" className="text-2xl mr-2" />

View file

@ -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<ReportReason>();
const [notes, setNotes] = useState<string>();
const [error, setError] = useState<string | undefined>(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 (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4 w-full max-w-2xl">
<div>
<h2 className="text-2xl font-bold">Report a Mii</h2>
<p className="text-sm text-zinc-500">If you encounter a rule-breaking Mii, please report it here</p>
</div>
<hr className="border-zinc-300" />
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
<Image src={`/mii/${mii.id}/image?type=mii`} alt="mii image" width={128} height={128} />
<div className="p-4">
<p className="text-xl font-bold line-clamp-1">{mii.name}</p>
<LikeButton likes={likes} isLiked={true} disabled />
</div>
</div>
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="reason" className="font-semibold">
Reason
</label>
<ReasonSelector reason={reason} setReason={setReason} />
</div>
<div className="w-full grid grid-cols-3">
<label htmlFor="reason-note" className="font-semibold">
Reason notes
</label>
<textarea
rows={3}
placeholder="Type notes here for the report..."
className="pill input !rounded-xl resize-none col-span-2"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<hr className="border-zinc-300" />
<div className="flex justify-between items-center">
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
<SubmitButton onClick={handleSubmit} className="ml-auto" />
</div>
</div>
);
}

View file

@ -0,0 +1,64 @@
"use client";
import { Icon } from "@iconify/react";
import { ReportReason } from "@prisma/client";
import { useSelect } from "downshift";
interface Props {
reason: ReportReason | undefined;
setReason: React.Dispatch<React.SetStateAction<ReportReason | undefined>>;
}
const reasonMap: Record<ReportReason, string> = {
INAPPROPRIATE: "Inappropriate content",
SPAM: "Spam",
COPYRIGHT: "Copyrighted content",
OTHER: "Other...",
};
const reasonOptions = Object.entries(reasonMap).map(([value, label]) => ({
value: value as ReportReason,
label,
}));
export default function ReasonSelector({ reason, setReason }: Props) {
const { isOpen, getToggleButtonProps, getMenuProps, getItemProps, highlightedIndex, selectedItem } = useSelect({
items: reasonOptions,
selectedItem: reason ? reasonOptions.find((option) => option.value === reason) : null,
itemToString: (item) => (item ? item.label : ""),
onSelectedItemChange: ({ selectedItem }) => {
if (selectedItem) {
setReason(selectedItem.value);
}
},
});
return (
<div className="relative w-full col-span-2">
{/* Toggle button to open the dropdown */}
<button type="button" {...getToggleButtonProps()} className="pill input w-full gap-1 !justify-between text-nowrap">
{selectedItem?.label || <span className="text-black/40">Select a reason for the report...</span>}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
</button>
{/* Dropdown menu */}
<ul
{...getMenuProps()}
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
isOpen ? "block" : "hidden"
}`}
>
{isOpen &&
reasonOptions.map((item, index) => (
<li
key={item.value}
{...getItemProps({ item, index })}
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
>
{item.label}
</li>
))}
</ul>
</div>
);
}

View file

@ -0,0 +1,87 @@
"use client";
import Image from "next/image";
import { redirect } from "next/navigation";
import { useState } from "react";
import { ReportReason, User } from "@prisma/client";
import ReasonSelector from "./reason-selector";
import SubmitButton from "../submit-button";
interface Props {
user: User;
}
export default function ReportUserForm({ user }: Props) {
const [reason, setReason] = useState<ReportReason>();
const [notes, setNotes] = useState<string>();
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => {
const response = await fetch(`/api/report`, {
method: "POST",
body: JSON.stringify({ id: user.id, type: "user", reason: reason?.toLowerCase(), notes }),
});
const { error } = await response.json();
if (!response.ok) {
setError(error);
return;
}
redirect(`/`);
};
return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4 w-full max-w-2xl">
<div>
<h2 className="text-2xl font-bold">Report a User</h2>
<p className="text-sm text-zinc-500">If you encounter a user causing issues, please report them here</p>
</div>
<hr className="border-zinc-300" />
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex p-4 gap-4">
<Image
src={user.image ?? "/missing.svg"}
alt="profile picture"
width={96}
height={96}
className="aspect-square rounded-full border-2 border-orange-400"
/>
<div className="flex flex-col justify-center">
<p className="text-xl font-bold overflow-hidden text-ellipsis">{user.name}</p>
<p className="text-sm font-bold overflow-hidden text-ellipsis">@{user.username}</p>
</div>
</div>
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="reason" className="font-semibold">
Reason
</label>
<ReasonSelector reason={reason} setReason={setReason} />
</div>
<div className="w-full grid grid-cols-3">
<label htmlFor="reason-note" className="font-semibold">
Reason notes
</label>
<textarea
rows={3}
placeholder="Type notes here for the report..."
className="pill input !rounded-xl resize-none col-span-2"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<hr className="border-zinc-300" />
<div className="flex justify-between items-center">
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
<SubmitButton onClick={handleSubmit} className="ml-auto" />
</div>
</div>
);
}

View file

@ -80,7 +80,7 @@ export class RateLimit {
this.data = await this.check(identifier); this.data = await this.check(identifier);
if (!this.data.success) return this.sendResponse({ success: false, error: "Rate limit exceeded. Please try again later." }, 429); if (!this.data.success) return this.sendResponse({ error: "Rate limit exceeded. Please try again later." }, 429);
return; return;
} }
} }