1
0
Fork 0

Compare commits

...

25 Commits

Author SHA1 Message Date
Paul Makles 73ca6ec732 chore: add tom 2024-08-16 13:24:39 +00:00
Paul Makles e498e9d261 chore: mod perms 2024-07-24 10:15:00 +00:00
Paul Makles 78ce9f96d7 chore: update emails 2024-03-30 20:36:04 +00:00
Lea 96d5884461
feat: highlight suspended/banned users 2023-12-05 18:10:02 +01:00
Paul Makles b79a14f00c
chore: edit template responses to ask user to email 2023-11-30 13:28:55 +00:00
Paul Makles 7f1619d33a
chore: make cased reports go away 2023-11-26 13:23:00 +00:00
Paul Makles 5cdb02b5bf
refactor: rows not cols 2023-11-26 13:19:08 +00:00
Paul Makles 500f8b3e1c
htrdhytdjfytuuytjf 2023-11-26 13:17:18 +00:00
Paul Makles c30d75c0ed
chore: trolling 2023-11-26 13:16:30 +00:00
Paul Makles 8113a86db9
feat: wipe user messages 2023-11-26 13:14:44 +00:00
Paul Makles eb0bd7a7c9
feat: case system 2023-11-26 13:11:44 +00:00
Paul Makles e9b623f60d
feat: show username of grouped reports rather than id 2023-11-25 20:59:28 +00:00
Paul Makles cdb05a5af7
feat: overdue badge 2023-11-01 16:23:41 +00:00
Lea 221ce0e75d fix: remove recent messages panel 2023-11-01 17:18:28 +01:00
Lea 5b0bc0d36a feat: enable and improve relations/server list 2023-11-01 17:18:28 +01:00
Lea 20579c9ebb fix: fix size of images on landing page 2023-11-01 17:18:28 +01:00
Lea 386f124fe9 fix: reorder action buttons on small screens 2023-11-01 17:18:28 +01:00
Lea a3e5db0886 feat: search users by username or tag 2023-11-01 17:18:28 +01:00
Lea 9c531f3d99 Revert "feat: user stream UI"
i messed up and worked on the wrong branch :trollface:
2023-11-01 17:18:28 +01:00
Lea 543baee97f feat: warning banner on restricted users 2023-11-01 17:18:28 +01:00
Lea 0210d385aa fix: allow sending multiline alerts 2023-11-01 17:18:28 +01:00
Lea bc48204410 feat: transfer bots, reset bot token
also slightly adjusted ACLs
2023-11-01 17:18:28 +01:00
Lea 77075dad23 feat: user stream UI 2023-11-01 17:18:28 +01:00
Lea 54aea181fa chore: add vscode configuration 2023-11-01 17:18:28 +01:00
Paul Makles bbaff35812 feat: group reports by author if not urgent 2023-10-26 00:20:19 +01:00
29 changed files with 1337 additions and 261 deletions

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"editor.tabSize": 2,
"editor.insertSpaces": true
}

View File

@ -0,0 +1,41 @@
import { ReportCard } from "@/components/cards/ReportCard";
import { CardLink } from "@/components/common/CardLink";
import { NavigationToolbar } from "@/components/common/NavigationToolbar";
import { CaseActions } from "@/components/pages/inspector/CaseActions";
import { fetchCaseById, fetchReportsByCase } from "@/lib/db";
import { PizzaIcon } from "lucide-react";
import { notFound } from "next/navigation";
export default async function Reports({ params }: { params: { id: string } }) {
const Case = await fetchCaseById(params.id);
if (!Case) return notFound();
const reports = await fetchReportsByCase(params.id);
return (
<div className="flex flex-col gap-2">
<NavigationToolbar>Viewing Case</NavigationToolbar>
<CaseActions Case={Case} />
<div className="flex flex-col gap-2">
<h1 className="text-2xl">Reports</h1>
{reports.length ? (
reports.map((report) => (
<CardLink key={report._id} href={`/panel/reports/${report._id}`}>
<ReportCard report={report} />
</CardLink>
))
) : (
<>
<h2 className="mt-8 flex justify-center">
<PizzaIcon className="text-gray-400" />
</h2>
<h3 className="text-xs text-center pb-2 text-gray-400">
No reports added yet.
</h3>
</>
)}
</div>
</div>
);
}

37
app/panel/cases/page.tsx Normal file
View File

@ -0,0 +1,37 @@
import { CaseCard } from "@/components/cards/CaseCard";
import { CardLink } from "@/components/common/CardLink";
import { CreateCase } from "@/components/common/CreateCase";
import { fetchOpenCases } from "@/lib/db";
import { PizzaIcon } from "lucide-react";
export default async function Reports() {
const cases = (await fetchOpenCases()).reverse();
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-2 items-center">
<CreateCase />
<h1 className="text-2xl">Open Cases</h1>
</div>
{cases.length ? (
cases.map((entry) => (
<CardLink key={entry._id} href={`/panel/cases/${entry._id}`}>
<CaseCard entry={entry} />
</CardLink>
))
) : (
<>
<h2 className="mt-8 flex justify-center">
<PizzaIcon className="text-gray-400" />
</h2>
<h3 className="text-xs text-center pb-2 text-gray-400">
No cases currently open.
</h3>
</>
)}
</div>
</div>
);
}

View File

@ -24,6 +24,7 @@ import { User } from "revolt-api";
import { decodeTime } from "ulid";
import relativeTime from "dayjs/plugin/relativeTime";
import SafetyNotesCard from "@/components/cards/SafetyNotesCard";
import { RestrictedUserCard } from "@/components/cards/RestrictedUserCard";
dayjs.extend(relativeTime);
@ -41,6 +42,8 @@ export default async function User({
return (
<div className="flex flex-col gap-2">
<NavigationToolbar>Inspecting Account</NavigationToolbar>
<RestrictedUserCard id={params.id} />
{user && <UserCard user={user} subtitle={`${account.email} · Created ${dayjs(decodeTime(account._id)).fromNow()}`} withLink />}
<AccountActions account={account} user={user as User} />
<EmailClassificationCard email={account.email} />

View File

@ -3,7 +3,7 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/use-toast";
import { lookupEmail } from "@/lib/actions";
import { lookupEmail, searchUserByTag } from "@/lib/actions";
import { API_URL } from "@/lib/constants";
import { useRouter } from "next/navigation";
import { useState } from "react";
@ -11,6 +11,8 @@ import { useState } from "react";
export default function Inspect() {
const [id, setId] = useState("");
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [discriminator, setDiscriminator] = useState("");
const router = useRouter();
const searchEmail = async () => {
@ -31,6 +33,29 @@ export default function Inspect() {
}
};
const searchUsername = async () => {
try {
if (!discriminator) {
// Display all users with this username
router.push(`/panel/inspect/search?username=${encodeURIComponent(username)}`);
} else {
// Show the specific user that matches username#discriminator
const result = await searchUserByTag(username, discriminator);
if (!result) toast({
title: "Couldn't find user",
variant: "destructive",
});
else router.push(`/panel/inspect/user/${result}`);
}
} catch(e) {
toast({
title: "Failed to search",
description: String(e),
variant: "destructive",
})
}
};
const createHandler = (type: string) => () =>
router.push(`/panel/inspect/${type}/${id}`);
@ -41,7 +66,7 @@ export default function Inspect() {
value={id}
onChange={(e) => setId(e.currentTarget.value)}
/>
<div className="flex gap-2">
<div className="flex flex-col md:flex-row gap-2">
<Button
className="flex-1"
variant="outline"
@ -92,7 +117,8 @@ export default function Inspect() {
</Button>
</div>
<hr />
<div className="flex gap-2 justify-between">
<div className="flex flex-col lg:flex-row gap-2 w-full">
<div className="flex gap-2 justify-between grow">
<Input
placeholder="Enter an email..."
value={email}
@ -108,6 +134,33 @@ export default function Inspect() {
Lookup
</Button>
</div>
<div className="flex gap-2 justify-between grow">
<div className="flex flex-row items-center w-full gap-1">
<Input
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
onKeyDown={(e) => e.key == "Enter" && username && searchUsername()}
/>
<span className="select-none text-gray-500">#</span>
<Input
placeholder="0000"
value={discriminator}
onChange={(e) => setDiscriminator(e.currentTarget.value)}
onKeyDown={(e) => e.key == "Enter" && username && searchUsername()}
className="flex-shrink-[2]"
/>
</div>
<Button
className="flex"
variant="outline"
disabled={!username}
onClick={searchUsername}
>
Search
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,34 @@
import { UserCard } from "@/components/cards/UserCard";
import { fetchUsersByUsername } from "@/lib/actions";
import { SearchX } from "lucide-react";
import { redirect } from "next/navigation";
export default async function Search({ searchParams }: { searchParams: any }) {
const username = searchParams.username;
if (!username) return redirect("/panel/inspect");
const users = await fetchUsersByUsername(username);
if (!users.length) return (
<>
<h2 className="mt-8 flex justify-center">
<SearchX className="text-gray-400" />
</h2>
<h3 className="text-xs text-center pb-2 text-gray-400">
No search results
</h3>
</>
);
return (
<div className="flex flex-col gap-2">
{
users.map((user) => (
<a key={user._id} href={`/panel/inspect/user/${user._id}`}>
<UserCard user={user} subtitle={user._id} />
</a>
))
}
</div>
);
}

View File

@ -30,6 +30,7 @@ import { notFound } from "next/navigation";
import { Bot } from "revolt-api";
import relativeTime from "dayjs/plugin/relativeTime";
import { decodeTime } from "ulid";
import { RestrictedUserCard } from "@/components/cards/RestrictedUserCard";
dayjs.extend(relativeTime);
@ -56,12 +57,10 @@ export default async function User({
const relevantUsers = await fetchUsersById([
...botIds,
...(
user.relations?.filter((relation) => relation.status === "Friend") ?? []
user.relations ?? []
).map((relation) => relation._id),
]);
relevantUsers.sort((a) => (a.bot ? -1 : 0));
// Fetch server memberships
const serverMemberships = await fetchMembershipsByUser(user._id).catch(
() => []
@ -80,6 +79,7 @@ export default async function User({
<div className="flex flex-col gap-2">
<NavigationToolbar>Inspecting User</NavigationToolbar>
<RestrictedUserCard id={user._id} />
<UserCard user={user} subtitle={`Joined ${dayjs(decodeTime(user._id)).fromNow()} · ${user.status?.text ?? "No status set"}`} />
<UserActions user={user} bot={bot as Bot} />
<SafetyNotesCard objectId={user._id} type="user" />
@ -115,9 +115,6 @@ export default async function User({
<Separator />
<RelevantReports byUser={reportsByUser} forUser={reportsAgainstUser} />
<Separator />
<RecentMessages userId={user._id} />
<Separator />
<JsonCard obj={user} />
</div>

View File

@ -1,32 +1,101 @@
import { ReportCard } from "@/components/cards/ReportCard";
import { CardLink } from "@/components/common/CardLink";
import { Input } from "@/components/ui/input";
import { fetchOpenReports } from "@/lib/db";
import { fetchOpenReports, fetchUsersById } from "@/lib/db";
import { PizzaIcon } from "lucide-react";
import { Report } from "revolt-api";
export default async function Reports() {
const reports = (await fetchOpenReports())
.reverse()
.sort((b, _) => (b.content.report_reason.includes("Illegal") ? -1 : 0));
const byCategory: Record<string, Report[]> = {
Urgent: [],
All: [],
AssignedToCase: [],
};
const keyOrder = ["Urgent", "All"];
const countsByAuthor: Record<string, number> = {};
for (const report of reports) {
if (report.case_id) {
byCategory.AssignedToCase.push(report);
} else if (report.content.report_reason.includes("Illegal")) {
byCategory.Urgent.push(report);
} else {
countsByAuthor[report.author_id] =
(countsByAuthor[report.author_id] || 0) + 1;
}
}
for (const report of reports) {
if (report.case_id) continue;
if (!report.content.report_reason.includes("Illegal")) {
if (countsByAuthor[report.author_id] > 1) {
if (!keyOrder.includes(report.author_id)) {
keyOrder.push(report.author_id);
byCategory[report.author_id] = [];
}
byCategory[report.author_id].push(report);
} else {
byCategory.All.push(report);
}
}
}
const authorNames: Record<string, string> = {};
for (const user of await fetchUsersById(Object.keys(countsByAuthor))) {
authorNames[user._id] = user.username + "#" + user.discriminator;
}
return (
<div className="flex flex-col gap-2">
<Input placeholder="Search for reports..." disabled />
{reports.length
? reports.map((report) => (
<CardLink key={report._id} href={`/panel/reports/${report._id}`}>
<div className="flex flex-col gap-8">
{/*<Input placeholder="Search for reports..." disabled />*/}
{reports.length ? (
keyOrder
.filter((key) => byCategory[key].length)
.map((key) => {
return (
<div key={key} className="flex flex-col gap-2">
<h1 className="text-2xl">{authorNames[key] ?? key}</h1>
{byCategory[key].map((report) => (
<CardLink
key={report._id}
href={`/panel/reports/${report._id}`}
>
<ReportCard report={report} />
</CardLink>
))
: (<>
))}{" "}
</div>
);
})
) : (
<>
<h2 className="mt-8 flex justify-center">
<PizzaIcon className="text-gray-400" />
</h2>
<h3 className="text-xs text-center pb-2 text-gray-400">
You&lsquo;ve caught up for now.
</h3>
</>)
}
</>
)}
{byCategory["AssignedToCase"].length && (
<details>
<summary>
<h1 className="text-xl inline">Reports assigned to cases</h1>
</summary>
<div className="flex flex-col gap-2">
{byCategory["AssignedToCase"].map((report) => (
<CardLink key={report._id} href={`/panel/reports/${report._id}`}>
<ReportCard report={report} />
</CardLink>
))}
</div>
</details>
)}
</div>
);
}

View File

@ -0,0 +1,32 @@
import { Report } from "revolt-api";
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Badge } from "../ui/badge";
import dayjs from "dayjs";
import { decodeTime } from "ulid";
import relativeTime from "dayjs/plugin/relativeTime";
import { CaseDocument } from "@/lib/db";
dayjs.extend(relativeTime);
export function CaseCard({ entry: entry }: { entry: CaseDocument }) {
return (
<Card>
<CardHeader>
<CardTitle
className={`overflow-ellipsis whitespace-nowrap overflow-x-clip ${
entry.status !== "Open" ? "text-gray-500" : ""
}`}
>
{entry.title || "No reason specified"}
</CardTitle>
<CardDescription>
{entry._id.toString().substring(20, 26)} &middot;{" "}
{dayjs(decodeTime(entry._id)).fromNow()} &middot; {entry.author}{" "}
{entry.status !== "Open" && entry.closed_at && (
<>&middot; Closed {dayjs(entry.closed_at).fromNow()}</>
)}
</CardDescription>
</CardHeader>
</Card>
);
}

View File

@ -1,13 +1,23 @@
import { Report } from "revolt-api";
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Badge } from "../ui/badge";
import dayjs from "dayjs";
import { decodeTime } from "ulid";
import relativeTime from "dayjs/plugin/relativeTime";
import { ReportDocument } from "@/lib/db";
dayjs.extend(relativeTime);
export function ReportCard({ report }: { report: Report }) {
const lastWeek = new Date();
lastWeek.setDate(lastWeek.getDate() - 7);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
export function ReportCard({ report }: { report: ReportDocument }) {
const dueDate = +(report.content.report_reason.includes("Illegal")
? yesterday
: lastWeek);
return (
<Card>
<CardHeader>
@ -37,6 +47,25 @@ export function ReportCard({ report }: { report: Report }) {
{dayjs(decodeTime(report._id)).fromNow()}{" "}
{report.status !== "Created" && report.closed_at && (
<>&middot; Closed {dayjs(report.closed_at).fromNow()}</>
)}{" "}
{report.case_id && (
<>
&middot;{" "}
<Badge className="align-middle" variant="secondary">
Assigned
</Badge>
</>
)}{" "}
{report.status === "Created" && decodeTime(report._id) < dueDate && (
<>
&middot;{" "}
<Badge className="align-middle" variant="relatively-destructive">
Due{" "}
{dayjs()
.add(dayjs(decodeTime(report._id)).diff(dueDate))
.fromNow()}
</Badge>
</>
)}
</CardDescription>
</CardHeader>

View File

@ -0,0 +1,17 @@
import { RESTRICT_ACCESS_LIST } from "@/lib/constants";
import { User } from "revolt-api";
import { Card } from "../ui/card";
import { AlertCircle } from "lucide-react";
export function RestrictedUserCard({ id }: { id: string | null | undefined }) {
if (!id || !RESTRICT_ACCESS_LIST.includes(id)) return null;
return (
<Card
className="p-2 bg-red-500 text-white flex flex-row gap-2"
>
<AlertCircle />
Destructive actions are disabled for this user
</Card>
);
}

View File

@ -1,22 +1,33 @@
"use client"
"use client";
import { useEffect, useState } from "react";
import { Textarea } from "../ui/textarea";
import { toast } from "../ui/use-toast";
import { SafetyNotes, fetchSafetyNote, updateSafetyNote } from "@/lib/db";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "../ui/card";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "../ui/card";
import { useSession } from "next-auth/react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
dayjs.extend(relativeTime);
export default function SafetyNotesCard({ objectId, type, title }: {
objectId: string,
type: SafetyNotes["_id"]["type"],
title?: string
export default function SafetyNotesCard({
objectId,
type,
title,
}: {
objectId: string;
type: SafetyNotes["_id"]["type"];
title?: string;
}) {
const session = useSession();
const [draft, setDraft] = useState("");
@ -40,12 +51,14 @@ export default function SafetyNotesCard({ objectId, type, title }: {
return (
<Card>
<CardHeader>
<CardTitle>{title ?? type.charAt(0).toUpperCase() + type.slice(1) + " notes"}</CardTitle>
<CardTitle>
{title ?? type.charAt(0).toUpperCase() + type.slice(1) + " notes"}
</CardTitle>
</CardHeader>
<CardContent>
{
editing
? <Textarea
{editing ? (
<Textarea
rows={8}
placeholder={
error
? error
@ -83,33 +96,37 @@ export default function SafetyNotesCard({ objectId, type, title }: {
}
}}
/>
: <div onClick={() => setEditing(true)}>
{
error
? <>{error}</>
: value?.text
? <ReactMarkdown
) : (
<div onClick={() => setEditing(true)}>
{error ? (
<>{error}</>
) : value?.text ? (
<ReactMarkdown
className="prose prose-a:text-[#fd6671] prose-img:max-h-96 max-w-none"
remarkPlugins={[remarkGfm]}
>
{value.text}
</ReactMarkdown>
: ready
? <i>Click to add a note</i>
: <i>Fetching notes...</i>
}
) : ready ? (
<i>Click to add a note</i>
) : (
<i>Fetching notes...</i>
)}
</div>
}
)}
</CardContent>
<CardFooter className="-my-2">
<CardDescription>
{
value
? <>Last edited {dayjs(value.edited_at).fromNow()} by {value.edited_by}</>
: <>No object note set</>
}
{value ? (
<>
Last edited {dayjs(value.edited_at).fromNow()} by{" "}
{value.edited_by}
</>
) : (
<>No object note set</>
)}
</CardDescription>
</CardFooter>
</Card>
)
);
}

View File

@ -7,13 +7,20 @@ import Link from "next/link";
import { ExternalLinkIcon } from "lucide-react";
export function UserCard({ user, subtitle, withLink }: { user: User; subtitle: string, withLink?: boolean }) {
const gradientColour = user.flags == 1
? 'rgba(251, 146, 60, 0.6)'
: user.flags == 4
? 'rgba(239, 68, 68, 0.6)'
: 'transparent';
const gradient = `linear-gradient(to right, white, rgba(255,0,0,0)), repeating-linear-gradient(225deg, transparent, transparent 32px, ${gradientColour} 32px, ${gradientColour} 64px)`;
return (
<Card
className="bg-no-repeat bg-right text-left"
style={{
backgroundImage: user.profile?.background
? `linear-gradient(to right, white, rgba(255,0,0,0)), url('${AUTUMN_URL}/backgrounds/${user.profile.background._id}')`
: "",
? `${gradient}, url('${AUTUMN_URL}/backgrounds/${user.profile.background._id}')`
: gradient,
backgroundSize: "75%",
}}
>
@ -30,6 +37,8 @@ export function UserCard({ user, subtitle, withLink }: { user: User; subtitle: s
</AvatarFallback>
</Avatar>
{user.bot && <Badge className="align-middle">Bot</Badge>}{" "}
{user.flags == 1 && <Badge className="align-middle bg-orange-400">Suspended</Badge>}{" "}
{user.flags == 4 && <Badge className="align-middle bg-red-700">Banned</Badge>}{" "}
<div className="flex gap-2">
{user.username}#{user.discriminator} {user.display_name}
{

View File

@ -0,0 +1,54 @@
"use client";
import { Plus } from "lucide-react";
import { Button } from "../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Label } from "../ui/label";
import { Input } from "../ui/input";
import { useState } from "react";
import { createCase } from "@/lib/db";
import { useRouter } from "next/navigation";
export function CreateCase() {
const [title, setTitle] = useState("");
const router = useRouter();
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="icon">
<Plus className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">Create Case</h4>
</div>
<div className="grid gap-2">
<div className="grid grid-cols-3 items-center gap-4">
<Label htmlFor="description">Title</Label>
<Input
id="description"
className="col-span-2 h-8"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
/>
</div>
<Button
variant="secondary"
onClick={() => {
if (!title) return;
createCase(title).then((id) =>
router.push(`/panel/cases/${id}`)
);
}}
>
Create
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -11,6 +11,7 @@ import {
Siren,
Sparkles,
TrendingUp,
BookCopy,
} from "lucide-react";
export function NavigationLinks() {
@ -34,6 +35,12 @@ export function NavigationLinks() {
>
<Siren className="h-4 w-4" />
</Link>
<Link
className={buttonVariants({ variant: "outline", size: "icon" })}
href="/panel/cases"
>
<BookCopy className="h-4 w-4" />
</Link>
<Link
className={buttonVariants({ variant: "outline", size: "icon" })}
href="/panel/inspect"

View File

@ -16,7 +16,7 @@ export function NavigationToolbar({ children }: { children: string }) {
<Button variant="outline" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<Popover>
{/* <Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="icon">
<Star
@ -49,7 +49,7 @@ export function NavigationToolbar({ children }: { children: string }) {
</div>
</div>
</PopoverContent>
</Popover>
</Popover> */}
<h2 className="text-2xl">{children}</h2>
</div>
);

View File

@ -23,7 +23,7 @@ export function LoginButton() {
</Button>
<img
src={`https://api.gifbox.me/file/posts/aYON6GqiqpwSpiZmAbJoOtw8tM2uYsEU.webp`}
height={320}
className="h-[320px]"
/>
</>
);
@ -43,7 +43,7 @@ export function LoginButton() {
</Button>
<img
src={`https://api.gifbox.me/file/posts/w7iUJfiyKA_zGkHN7Rr625WpaTHYgm4v.webp`}
height={320}
className="h-[320px]"
/>
</>
);

View File

@ -45,7 +45,7 @@ export function AccountActions({
const [emailDraft, setEmailDraft] = useState("");
return (
<div className="flex gap-2">
<div className="flex flex-col md:flex-row gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="flex-1">

View File

@ -0,0 +1,95 @@
"use client";
import { Textarea } from "../../ui/textarea";
import { Button } from "../../ui/button";
import { useState } from "react";
import { useToast } from "../../ui/use-toast";
import { CaseDocument } from "@/lib/db";
import { CaseCard } from "@/components/cards/CaseCard";
import { closeCase, reopenCase, updateCaseNotes } from "@/lib/actions";
export function CaseActions({ Case }: { Case: CaseDocument }) {
const { toast } = useToast();
const [caseDraft, setDraft] = useState(Case);
return (
<>
<CaseCard entry={Case} />
<Textarea
rows={8}
placeholder="Enter notes here... (save on unfocus)"
className="!min-h-0 !h-[76px]"
defaultValue={Case.notes}
onBlur={async (e) => {
const notes = e.currentTarget.value;
if (notes === caseDraft.notes ?? "") return;
try {
await updateCaseNotes(Case._id, notes);
setDraft((c) => ({ ...c, notes }));
toast({
title: "Updated report notes",
});
} catch (err) {
toast({
title: "Failed to update report notes",
description: String(err),
variant: "destructive",
});
}
}}
/>
<div className="flex gap-2">
{caseDraft.status === "Open" ? (
<>
<Button
className="flex-1 bg-green-400 hover:bg-green-300"
onClick={async () => {
try {
const $set = await closeCase(Case._id);
setDraft((c) => ({ ...c, ...$set }));
toast({
title: "Closed case",
});
} catch (err) {
toast({
title: "Failed to close case",
description: String(err),
variant: "destructive",
});
}
}}
>
Close Case
</Button>
</>
) : (
<>
<Button
className="flex-1"
onClick={async () => {
try {
const $set = await reopenCase(Case._id);
setDraft((c) => ({ ...c, ...$set }));
toast({
title: "Opened case again",
});
} catch (err) {
toast({
title: "Failed to re-open case",
description: String(err),
variant: "destructive",
});
}
}}
>
Re-open Case
</Button>
</>
)}
</div>
</>
);
}

View File

@ -18,12 +18,24 @@ export function RelevantObjects({
return (
<div className="flex gap-2">
<div className="flex-1 min-w-0 flex flex-col gap-2">
<h2 className="text-md text-center pb-2">Bots & Friends</h2>
<h2 className="text-md text-center pb-2">Bots & Relations</h2>
<ListCompactor
data={users}
data={[
// for whatever fucking reason nextjs threw a bunch of errors at me
// when i used .sort() here but i guess this works well enough..?
...users.filter((user) => user.bot?.owner == userId),
...users.filter((user) => user.bot?.owner != userId),
]}
Component={({ item }) => (
<Link href={`/panel/inspect/user/${item._id}`}>
<UserCard user={item} subtitle="" />
<UserCard
user={item}
subtitle={
item.bot?.owner == userId
? "Owned bot"
: item.relations?.find((relation) => relation._id == userId)?.status || ""
}
/>
</Link>
)}
/>
@ -31,12 +43,21 @@ export function RelevantObjects({
<div className="flex-1 min-w-0 flex flex-col gap-2">
<h2 className="text-md text-center pb-2">Servers</h2>
<ListCompactor
data={servers}
// same as above
data={[
...servers.filter((server) => userId == server.owner),
...servers.filter((server) => userId != server.owner),
]}
Component={({ item }) => (
<Link href={`/panel/inspect/server/${item._id}`}>
<ServerCard
server={item}
subtitle={userId === item.owner ? "Server Owner" : ""}
subtitle={
[
userId === item.owner ? "Server Owner" : null,
item.discoverable ? "Discoverable" : null,
].filter(i => i).join(" · ")
}
/>
</Link>
)}

View File

@ -14,6 +14,7 @@ import {
import { useState } from "react";
import { useToast } from "../../ui/use-toast";
import {
assignReportToCase,
rejectReport,
reopenReport,
resolveReport,
@ -32,6 +33,10 @@ import {
AlertDialogTrigger,
} from "../../ui/alert-dialog";
import { ReportCard } from "../../cards/ReportCard";
import { Popover } from "@radix-ui/react-popover";
import { PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { CaseDocument, ReportDocument, fetchOpenCases } from "@/lib/db";
import { CaseCard } from "@/components/cards/CaseCard";
const template: Record<string, (ref: string) => string> = {
resolved: (ref) =>
@ -43,7 +48,7 @@ const template: Record<string, (ref: string) => string> = {
"not enough evidence": (ref) =>
`Your report (${ref}) has not been actioned at this time due to a lack of supporting evidence, if you have additional information to support your report, please either report individual relevant messages or send an email to contact@revolt.chat.`,
clarify: (ref) =>
`Your report (${ref}) needs clarification, please provide additional information.`,
`Your report (${ref}) needs clarification, please provide additional information. You can report the messages again, report additional messages, or send an email to contact@revolt.chat.`,
acknowledged: (ref) =>
`Your report (${ref}) has been acknowledged, we will be monitoring the situation.`,
default: (ref) =>
@ -54,11 +59,12 @@ export function ReportActions({
report,
reference,
}: {
report: Report;
report: ReportDocument;
reference: string;
}) {
const { toast } = useToast();
const [reportDraft, setDraft] = useState(report);
const [availableCases, setAvailableCases] = useState<CaseDocument[]>([]);
function rejectHandler(reason: string) {
return async () => {
@ -83,6 +89,7 @@ export function ReportActions({
<ReportCard report={reportDraft} />
<Textarea
rows={8}
placeholder="Enter notes here... (save on unfocus)"
className="!min-h-0 !h-[76px]"
defaultValue={report.notes}
@ -106,6 +113,74 @@ export function ReportActions({
}}
/>
{reportDraft.case_id ? (
<Button
variant="destructive"
onClick={async () => {
try {
const $set = await assignReportToCase(report._id);
setDraft((report) => ({ ...report, ...$set }));
toast({
title: "Removed report from case",
});
} catch (err) {
toast({
title: "Failed to resolve report",
description: String(err),
variant: "destructive",
});
}
}}
>
Remove from case
</Button>
) : (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
onClick={() => {
fetchOpenCases().then(setAvailableCases);
}}
>
Add to case
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">Open Cases</h4>
{availableCases.map((entry) => (
<a
key={entry._id}
onClick={async () => {
try {
const $set = await assignReportToCase(
report._id,
entry._id
);
setDraft((report) => ({ ...report, ...$set }));
toast({
title: "Assigned report to case",
});
} catch (err) {
toast({
title: "Failed to resolve report",
description: String(err),
variant: "destructive",
});
}
}}
>
<CaseCard entry={entry} />
</a>
))}
</div>
</div>
</PopoverContent>
</Popover>
)}
<div className="flex gap-2">
{reportDraft.status === "Created" ? (
<>

View File

@ -35,7 +35,7 @@ export function ServerActions({ server }: { server: Server }) {
const { toast } = useToast();
return (
<div className="flex gap-2">
<div className="flex flex-col md:flex-row gap-2">
{serverDraft.discoverable ? (
<Button
className="flex-1"

View File

@ -23,11 +23,14 @@ import { Input } from "../../ui/input";
import {
banUser,
closeReportsByUser,
resetBotToken,
sendAlert,
suspendUser,
transferBot,
unsuspendUser,
updateBotDiscoverability,
updateUserBadges,
wipeUser,
wipeUserProfile,
} from "@/lib/actions";
import { useRef, useState } from "react";
@ -37,6 +40,8 @@ import { Card, CardHeader } from "../../ui/card";
import { cn } from "@/lib/utils";
import { decodeTime } from "ulid";
import { Checkbox } from "@/components/ui/checkbox";
import UserSelector from "@/components/ui/user-selector";
import { Textarea } from "@/components/ui/textarea";
const badges = [1, 2, 4, 8, 16, 32, 128, 0, 256, 512, 1024];
@ -53,6 +58,8 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
displayName: false,
status: false,
});
const [transferTarget, setTransferTarget] = useState<User | null>(null);
const [transferResetToken, setTransferResetToken] = useState(true);
const userInaccessible = userDraft.flags === 4 || userDraft.flags === 2;
@ -103,7 +110,7 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
</CardHeader>
</Card>
<div className="flex gap-2">
<div className="flex flex-col md:flex-row gap-2">
{bot ? (
botDraft!.discoverable ? (
<Button
@ -258,6 +265,51 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="flex-1 bg-pink-600" disabled={userInaccessible}>
Wipe Messages
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to wipe this user&apos;s messages?
</AlertDialogTitle>
<AlertDialogDescription>
All messages sent by this user will be deleted immediately.
<br className="text-base/8" />
<span className="text-red-700">
This action is irreversible and{" "}
<b className="font-bold">will not publish any events</b>!
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="hover:bg-red-700 transition-all"
onClick={() =>
wipeUser(user._id, 0, true)
.then(() => {
setUserDraft((user) => ({ ...user, flags: 4 }));
toast({ title: "Wiped user's messages" });
})
.catch((err) =>
toast({
title: "Failed to wipe user's messages!",
description: String(err),
variant: "destructive",
})
)
}
>
Ban
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="flex-1 bg-yellow-600">Bees</Button>
@ -297,7 +349,7 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
This will send a message from the Platform Moderation
account.
</span>
<Input
<Textarea
placeholder="Enter a message..."
name="message"
onChange={(e) =>
@ -416,6 +468,101 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" disabled={!user.bot?.owner}>
Reset bot token
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset token</AlertDialogTitle>
<AlertDialogDescription className="flex flex-col gap-2">
<span>
Re-roll this bot&apos;s authentication token. This will
not disconnect active connections.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
resetBotToken(user._id)
.then(() =>
toast({
title: "Reset bot token",
})
)
.catch((e) =>
toast({
title: "Failed to reset token",
description: String(e),
variant: "destructive",
})
)
}
>
Reset
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" disabled={!user.bot?.owner}>
Transfer bot
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Transfer bot</AlertDialogTitle>
<AlertDialogDescription className="flex flex-col gap-2">
<span>Transfer this bot to a new owner.</span>
<UserSelector onChange={setTransferTarget} />
<Checkbox
checked={transferResetToken}
onChange={(e) => setTransferResetToken(!!e)}
>
Also reset token
</Checkbox>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={!transferTarget}
onClick={() =>
transferBot(
user._id,
transferTarget!._id,
transferResetToken
)
.then(() =>
toast({
title: "Reset bot token",
})
)
.catch((e) =>
toast({
title: "Failed to reset token",
description: String(e),
variant: "destructive",
})
)
.finally(() => {
setTransferResetToken(true);
setTransferTarget(null);
})
}
>
Transfer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost">Close Open Reports</Button>

View File

@ -1,7 +1,7 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
@ -14,6 +14,8 @@ const badgeVariants = cva(
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
"relatively-destructive":
"border-transparent bg-destructive/60 text-destructive-foreground hover:bg-destructive/40",
outline: "text-foreground",
},
},
@ -21,7 +23,7 @@ const badgeVariants = cva(
variant: "default",
},
}
)
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
@ -30,7 +32,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View File

@ -4,14 +4,7 @@ import { SafetyNotes, insertAuditLog } from "./db";
type Permission =
| `authifier${
| ""
| `/classification${
| ""
| "/fetch"
| "/create"
| "/update"
| "/delete"
}`
}`
| `/classification${"" | "/fetch" | "/create" | "/update" | "/delete"}`}`
| "publish_message"
| "chat_message"
| `accounts${
@ -24,26 +17,37 @@ type Permission =
| `bots${
| ""
| `/fetch${"" | "/by-id" | "/by-user"}`
| `/update${"" | "/discoverability"}`}`
| `/update${"" | "/discoverability" | "/owner" | "/reset-token"}`}`
| `channels${
| ""
| `/fetch${"" | "/by-id" | "/by-server" | "/dm" | "/invites"}`
| `/create${"" | "/dm" | "/invites"}`
| `/update${"" | "/invites"}`}`
| `messages${"" | `/fetch${"" | "/by-id" | "/by-user"}`}`
| `cases${
| ""
| "/create"
| `/fetch${"" | "/by-id" | "/open"}`
| `/update${"" | "/close" | "/reopen" | "/notes"}`}`
| `reports${
| ""
| `/fetch${
| ""
| "/by-id"
| "/open"
| `/related${"" | "/by-content" | "/by-user" | "/against-user"}`
| `/related${
| ""
| "/by-content"
| "/by-user"
| "/by-case"
| "/against-user"}`
| `/snapshots${"" | "/by-report" | "/by-user"}`}`
| `/update${
| ""
| "/notes"
| "/resolve"
| "/reject"
| "/case"
| "/reopen"
| `/bulk-close${"" | "/by-user"}`}`}`
| `sessions${"" | `/fetch${"" | "/by-account-id"}`}`
@ -62,6 +66,8 @@ type Permission =
| `/fetch${
| ""
| "/by-id"
| "/by-tag"
| "/bulk-by-username"
| "/memberships"
| "/strikes"
| "/notices"
@ -101,6 +107,8 @@ const PermissionSets = {
// View open reports
"view-open-reports": [
"users/fetch/by-id",
"cases/fetch/open",
"cases/fetch/by-id",
"reports/fetch/open",
"reports/fetch/by-id",
"reports/fetch/related",
@ -112,7 +120,12 @@ const PermissionSets = {
"reports/update/notes",
"reports/update/resolve",
"reports/update/reject",
"reports/update/case",
"reports/update/reopen",
"cases/create",
"cases/update/notes",
"cases/update/close",
"cases/update/reopen",
] as Permission[],
// Revolt Discover
@ -139,7 +152,10 @@ const PermissionSets = {
"users/update/badges",
"servers/update/owner",
"servers/update/add-member",
"bots/fetch/by-user",
"bots/update/reset-token",
"bots/update/owner",
"accounts/fetch/by-id",
"accounts/fetch/by-email",
@ -160,12 +176,18 @@ const PermissionSets = {
// Moderate users
"moderate-users": [
"users/fetch/by-id",
"users/fetch/by-tag",
"users/fetch/bulk-by-username",
"users/fetch/strikes",
"users/fetch/notices",
"bots/fetch/by-user",
"bots/update/reset-token",
"bots/update/owner",
// "messages/fetch/by-user",
// "users/fetch/memberships",
"users/fetch/memberships",
"users/fetch/relations",
"servers/fetch",
"messages/fetch/by-id",
@ -175,6 +197,8 @@ const PermissionSets = {
"channels/create/dm",
"servers/update/quarantine",
"servers/update/owner",
"servers/update/add-member",
"backup/fetch",
"reports/fetch/related/by-user",
@ -199,9 +223,7 @@ const PermissionSets = {
"safety_notes/update",
] as Permission[],
"authifier": [
"authifier/classification",
] as Permission[],
authifier: ["authifier/classification"] as Permission[],
};
const Roles = {
@ -222,22 +244,32 @@ const ACL: Record<string, Set<Permission>> = {
...Roles["revolt-discover"],
...Roles["user-support"],
] as Permission[]),
"lea@janderedev.xyz": new Set([
"lea@revolt.chat": new Set([
...Roles["moderator"],
...Roles["revolt-discover"],
...Roles["user-support"],
] as Permission[]),
"infi@infi.sh": new Set([
"tom@revolt.chat": new Set([
...Roles["moderator"],
...Roles["revolt-discover"],
...Roles["user-support"],
] as Permission[]),
"beartechtalks@gmail.com": new Set([
"jen@revolt.chat": new Set([
...Roles["moderator"],
...Roles["revolt-discover"],
...Roles["user-support"],
] as Permission[]),
"me@zomatree.live": new Set([
"rexo@revolt.chat": new Set([
...Roles["moderator"],
...Roles["revolt-discover"],
...Roles["user-support"],
] as Permission[]),
"zomatree@revolt.chat": new Set([
...Roles["moderator"],
...Roles["revolt-discover"],
...Roles["user-support"],
] as Permission[]),
"vale@revolt.chat": new Set([
...Roles["moderator"],
...Roles["revolt-discover"],
...Roles["user-support"],
@ -274,5 +306,5 @@ export async function checkPermission(
if (!(await hasPermissionFromSession(permission)))
throw `Missing permission ${permission}`;
await insertAuditLog(permission, context, args);
return await insertAuditLog(permission, context, args);
}

View File

@ -4,8 +4,10 @@ import { readFile, readdir, writeFile } from "fs/promises";
import { PLATFORM_MOD_ID, RESTRICT_ACCESS_LIST } from "./constants";
import mongo, {
Account,
CaseDocument,
ChannelInvite,
EmailClassification,
ReportDocument,
createDM,
fetchAccountById,
findDM,
@ -28,6 +30,7 @@ import {
} from "revolt-api";
import { checkPermission } from "./accessPermissions";
import { Long } from "mongodb";
import { nanoid } from "nanoid";
export async function sendAlert(userId: string, content: string) {
await checkPermission("users/create/alert", userId, { content });
@ -97,6 +100,48 @@ export async function updateReportNotes(reportId: string, notes: string) {
);
}
export async function updateCaseNotes(caseId: string, notes: string) {
await checkPermission("cases/update/notes", caseId, { notes });
return await mongo()
.db("revolt")
.collection<CaseDocument>("safety_cases")
.updateOne(
{ _id: caseId },
{
$set: {
notes,
},
}
);
}
export async function assignReportToCase(reportId: string, caseId?: string) {
await checkPermission("reports/update/case", reportId);
const $set = {
case_id: (caseId ?? null)!,
} as ReportDocument;
await mongo()
.db("revolt")
.collection<ReportDocument>("safety_reports")
.updateOne(
{ _id: reportId },
(caseId
? {
$set,
}
: {
$unset: {
case_id: 1,
},
}) as never // fuck you
);
return $set;
}
export async function resolveReport(reportId: string) {
await checkPermission("reports/update/resolve", reportId);
@ -115,6 +160,24 @@ export async function resolveReport(reportId: string) {
return $set;
}
export async function closeCase(caseId: string) {
await checkPermission("cases/update/close", caseId);
const $set = {
status: "Closed",
closed_at: new Date().toISOString(),
} as CaseDocument;
await mongo().db("revolt").collection<CaseDocument>("safety_cases").updateOne(
{ _id: caseId },
{
$set,
}
);
return $set;
}
export async function rejectReport(reportId: string, reason: string) {
await checkPermission("reports/update/reject", reportId, { reason });
@ -157,6 +220,23 @@ export async function reopenReport(reportId: string) {
return $set;
}
export async function reopenCase(caseId: string) {
await checkPermission("cases/update/reopen", caseId);
const $set = {
status: "Open",
} as CaseDocument;
await mongo().db("revolt").collection<CaseDocument>("safety_cases").updateOne(
{ _id: caseId },
{
$set,
}
);
return $set;
}
export async function closeReportsByUser(userId: string) {
await checkPermission("reports/update/bulk-close/by-user", userId);
@ -207,8 +287,8 @@ export async function deleteMFARecoveryCodes(userId: string) {
$unset: {
"mfa.recovery_codes": 1,
},
},
)
}
);
}
export async function disableMFA(userId: string) {
@ -224,8 +304,8 @@ export async function disableMFA(userId: string) {
$unset: {
"mfa.totp_token": 1,
},
},
)
}
);
}
export async function changeAccountEmail(userId: string, email: string) {
@ -286,9 +366,7 @@ export async function verifyAccountEmail(userId: string) {
export async function lookupEmail(email: string): Promise<string | false> {
await checkPermission("accounts/fetch/by-email", email);
const accounts = mongo()
.db("revolt")
.collection<Account>("accounts");
const accounts = mongo().db("revolt").collection<Account>("accounts");
let result = await accounts.findOne({ email: email });
if (result) return result._id;
@ -368,12 +446,18 @@ export async function updateUserBadges(userId: string, badges: number) {
}
}
export async function wipeUser(userId: string, flags = 4) {
export async function wipeUser(
userId: string,
flags = 4,
onlyMessages = false
) {
if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access";
await checkPermission("users/action/wipe", userId, { flags });
const user = await mongo()
const user = onlyMessages
? null
: await mongo()
.db("revolt")
.collection<User>("users")
.findOne({ _id: userId });
@ -384,7 +468,9 @@ export async function wipeUser(userId: string, flags = 4) {
.find({ author: userId }, { sort: { _id: -1 } })
.toArray();
const dms = await mongo()
const dms = onlyMessages
? null
: await mongo()
.db("revolt")
.collection<Channel>("channels")
.find({
@ -393,7 +479,9 @@ export async function wipeUser(userId: string, flags = 4) {
})
.toArray();
const memberships = await mongo()
const memberships = onlyMessages
? null
: await mongo()
.db("revolt")
.collection<{ _id: { user: string; server: string } }>("server_members")
.find({ "_id.user": userId })
@ -401,7 +489,7 @@ export async function wipeUser(userId: string, flags = 4) {
// retrieve messages, dm channels, relationships, server memberships
const backup = {
_event: "wipe",
_event: onlyMessages ? "messages" : "wipe",
user,
messages,
dms,
@ -421,6 +509,7 @@ export async function wipeUser(userId: string, flags = 4) {
.filter((attachment) => attachment)
.map((attachment) => attachment!._id);
if (!onlyMessages) {
if (backup.user?.avatar) {
attachmentIds.push(backup.user.avatar._id);
}
@ -428,6 +517,7 @@ export async function wipeUser(userId: string, flags = 4) {
if (backup.user?.profile?.background) {
attachmentIds.push(backup.user.profile.background._id);
}
}
if (attachmentIds.length) {
await mongo()
@ -449,6 +539,7 @@ export async function wipeUser(userId: string, flags = 4) {
author: userId,
});
if (!onlyMessages) {
// delete server memberships
await mongo().db("revolt").collection<Member>("server_members").deleteMany({
"_id.user": userId,
@ -479,8 +570,8 @@ export async function wipeUser(userId: string, flags = 4) {
// broadcast wipe event
for (const topic of [
...backup.dms.map((x) => x._id),
...backup.memberships.map((x) => x._id.server),
...backup.dms!.map((x) => x._id),
...backup.memberships!.map((x) => x._id.server),
]) {
await publishMessage(topic, {
type: "UserPlatformWipe",
@ -488,6 +579,7 @@ export async function wipeUser(userId: string, flags = 4) {
flags,
});
}
}
}
export async function banUser(userId: string) {
@ -597,10 +689,7 @@ export async function updateServerOwner(serverId: string, userId: string) {
await mongo()
.db("revolt")
.collection<Server>("servers")
.updateOne(
{ _id: serverId },
{ $set: { owner: userId } },
);
.updateOne({ _id: serverId }, { $set: { owner: userId } });
await publishMessage(serverId, {
type: "ServerUpdate",
@ -612,8 +701,16 @@ export async function updateServerOwner(serverId: string, userId: string) {
});
}
export async function addServerMember(serverId: string, userId: string, withEvent: boolean) {
await checkPermission("servers/update/add-member", { serverId, userId, withEvent });
export async function addServerMember(
serverId: string,
userId: string,
withEvent: boolean
) {
await checkPermission("servers/update/add-member", {
serverId,
userId,
withEvent,
});
const server = await mongo()
.db("revolt")
@ -642,7 +739,7 @@ export async function addServerMember(serverId: string, userId: string, withEven
joined_at: Long.fromNumber(Date.now()) as unknown as string,
});
await publishMessage(userId + '!', {
await publishMessage(userId + "!", {
type: "ServerCreate",
id: serverId,
channels: channels,
@ -685,11 +782,11 @@ export async function quarantineServer(serverId: string, message: string) {
server,
members,
invites,
}
};
await writeFile(
`./exports/${new Date().toISOString()} - ${serverId}.json`,
JSON.stringify(backup),
JSON.stringify(backup)
);
await mongo()
@ -702,7 +799,7 @@ export async function quarantineServer(serverId: string, message: string) {
owner: "0".repeat(26),
analytics: false,
discoverable: false,
}
},
}
);
@ -729,7 +826,8 @@ export async function quarantineServer(serverId: string, message: string) {
const messageId = ulid();
let dm = await findDM(PLATFORM_MOD_ID, member._id.user);
if (!dm) dm = await createDM(PLATFORM_MOD_ID, member._id.user, messageId);
if (!dm)
dm = await createDM(PLATFORM_MOD_ID, member._id.user, messageId);
await sendChatMessage({
_id: messageId,
@ -819,6 +917,83 @@ export async function updateBotDiscoverability(botId: string, state: boolean) {
);
}
export async function resetBotToken(botId: string) {
await checkPermission("bots/update/reset-token", { botId });
// Should generate tokens the exact same as the backend generates them:
// https://github.com/revoltchat/backend/blob/41f20c2239ed6307ad821b321d13240dc6ff3327/crates/core/database/src/models/bots/model.rs#L106
await mongo()
.db("revolt")
.collection<Bot>("bots")
.updateOne(
{
_id: botId,
},
{
$set: {
token: nanoid(64),
},
}
);
}
export async function transferBot(
botId: string,
ownerId: string,
resetToken: boolean
) {
await checkPermission("bots/update/owner", { botId, ownerId, resetToken });
if (resetToken) {
await checkPermission("bots/update/reset-token", { botId });
}
await mongo()
.db("revolt")
.collection<Bot>("bots")
.updateOne(
{
_id: botId,
},
{
$set: {
owner: ownerId,
...(resetToken
? {
token: nanoid(64),
}
: {}),
},
}
);
await mongo()
.db("revolt")
.collection<User>("users")
.updateOne(
{
_id: botId,
},
{
$set: {
"bot.owner": ownerId,
},
}
);
// This doesn't appear to work, maybe Revite can't handle it. I'll leave it in regardless.
await publishMessage(botId, {
type: "UserUpdate",
id: botId,
data: {
bot: {
owner: ownerId,
},
},
});
}
export async function restoreAccount(accountId: string) {
if (RESTRICT_ACCESS_LIST.includes(accountId)) throw "restricted access";
await checkPermission("accounts/restore", accountId);
@ -886,15 +1061,19 @@ export async function fetchBackups() {
await checkPermission("backup/fetch", null);
return await Promise.all(
(await readdir("./exports", { withFileTypes: true }))
(
await readdir("./exports", { withFileTypes: true })
)
.filter((file) => file.isFile() && file.name.endsWith(".json"))
.map(async (file) => {
let type: string | null = null;
try {
type = JSON.parse((await readFile(`./exports/${file.name}`)).toString("utf-8"))._event;
} catch(e) {}
type = JSON.parse(
(await readFile(`./exports/${file.name}`)).toString("utf-8")
)._event;
} catch (e) {}
return { name: file.name, type: type }
return { name: file.name, type: type };
})
);
}
@ -905,7 +1084,9 @@ export async function fetchBackup(name: string) {
return JSON.parse((await readFile(`./exports/${name}`)).toString("utf-8"));
}
export async function fetchEmailClassifications(): Promise<EmailClassification[]> {
export async function fetchEmailClassifications(): Promise<
EmailClassification[]
> {
await checkPermission("authifier/classification/fetch", null);
return await mongo()
@ -915,27 +1096,34 @@ export async function fetchEmailClassifications(): Promise<EmailClassification[]
.toArray();
}
export async function createEmailClassification(domain: string, classification: string) {
await checkPermission("authifier/classification/create", { domain, classification });
export async function createEmailClassification(
domain: string,
classification: string
) {
await checkPermission("authifier/classification/create", {
domain,
classification,
});
await mongo()
.db("authifier")
.collection<EmailClassification>("email_classification")
.insertOne(
{ _id: domain, classification },
);
.insertOne({ _id: domain, classification });
}
export async function updateEmailClassification(domain: string, classification: string) {
await checkPermission("authifier/classification/update", { domain, classification });
export async function updateEmailClassification(
domain: string,
classification: string
) {
await checkPermission("authifier/classification/update", {
domain,
classification,
});
await mongo()
.db("authifier")
.collection<EmailClassification>("email_classification")
.updateOne(
{ _id: domain },
{ $set: { classification } },
);
.updateOne({ _id: domain }, { $set: { classification } });
}
export async function deleteEmailClassification(domain: string) {
@ -944,7 +1132,31 @@ export async function deleteEmailClassification(domain: string) {
await mongo()
.db("authifier")
.collection<EmailClassification>("email_classification")
.deleteOne(
{ _id: domain },
);
.deleteOne({ _id: domain });
}
export async function searchUserByTag(
username: string,
discriminator: string
): Promise<string | false> {
await checkPermission("users/fetch/by-tag", { username, discriminator });
const result = await mongo().db("revolt").collection<User>("users").findOne({
username,
discriminator,
});
return result?._id || false;
}
export async function fetchUsersByUsername(username: string) {
await checkPermission("users/fetch/bulk-by-username", { username });
return await mongo()
.db("revolt")
.collection<User>("users")
.find({
username,
})
.toArray();
}

117
lib/db.ts
View File

@ -21,6 +21,19 @@ import { getServerSession } from "next-auth";
let client: MongoClient;
export type CaseDocument = {
_id: string;
title: string;
notes?: string;
author: string;
status: "Open" | "Closed";
closed_at?: string;
};
export type ReportDocument = Report & {
case_id?: string;
};
function mongo() {
if (!client) {
client = new MongoClient(process.env.MONGODB!);
@ -55,6 +68,8 @@ export async function insertAuditLog(
context,
args,
});
return session!.user!.email!;
}
export async function fetchBotById(id: string) {
@ -116,11 +131,10 @@ export type Account = {
export async function fetchAccountById(id: string) {
await checkPermission("accounts/fetch/by-id", id);
return await mongo()
return (await mongo()
.db("revolt")
.collection<Account>("accounts")
.aggregate(
[
.aggregate([
{
$match: { _id: id },
},
@ -128,7 +142,7 @@ export async function fetchAccountById(id: string) {
$project: {
password: 0,
"mfa.totp_token.secret": 0,
}
},
},
{
$set: {
@ -136,14 +150,14 @@ export async function fetchAccountById(id: string) {
"mfa.recovery_codes": {
$cond: {
if: { $isArray: "$mfa.recovery_codes" },
then: { $size: "$mfa.recovery_codes", },
then: { $size: "$mfa.recovery_codes" },
else: undefined,
}
}
}
}
]
).next() as WithId<Account>;
},
},
},
},
])
.next()) as WithId<Account>;
}
export async function fetchSessionsByAccount(accountId: string) {
@ -288,7 +302,7 @@ export async function fetchServers(query: Filter<Server>) {
}
// `vanity` should eventually be added to the backend as well
export type ChannelInvite = Invite & { vanity?: boolean }
export type ChannelInvite = Invite & { vanity?: boolean };
export async function fetchInvites(query: Filter<ChannelInvite>) {
await checkPermission("channels/fetch/invites", query);
@ -311,7 +325,7 @@ export async function fetchInvites(query: Filter<ChannelInvite>) {
.find({ _id: { $in: invites.map((invite) => invite.creator) } })
.toArray();
return { invites, channels, users }
return { invites, channels, users };
}
export async function fetchMessageById(id: string) {
@ -358,7 +372,7 @@ export async function fetchOpenReports() {
return await mongo()
.db("revolt")
.collection<Report>("safety_reports")
.collection<ReportDocument>("safety_reports")
.find(
{ status: "Created" },
{
@ -370,6 +384,23 @@ export async function fetchOpenReports() {
.toArray();
}
export async function fetchOpenCases() {
await checkPermission("cases/fetch/open", "all");
return await mongo()
.db("revolt")
.collection<CaseDocument>("safety_cases")
.find(
{ status: "Open" },
{
sort: {
_id: -1,
},
}
)
.toArray();
}
export async function fetchRelatedReportsByContent(contentId: string) {
await checkPermission("reports/fetch/related/by-content", contentId);
@ -404,6 +435,23 @@ export async function fetchReportsByUser(userId: string) {
.toArray();
}
export async function fetchReportsByCase(caseId: string) {
await checkPermission("reports/fetch/related/by-case", caseId);
return await mongo()
.db("revolt")
.collection<ReportDocument>("safety_reports")
.find(
{ case_id: caseId },
{
sort: {
_id: -1,
},
}
)
.toArray();
}
export async function fetchReportsAgainstUser(userId: string) {
await checkPermission("reports/fetch/related/against-user", userId);
@ -463,6 +511,27 @@ export async function fetchReportById(id: string) {
.findOne({ _id: id });
}
export async function createCase(title: string) {
const id = ulid();
const author = await checkPermission("cases/create", { title });
await mongo()
.db("revolt")
.collection<CaseDocument>("safety_cases")
.insertOne({ _id: id, author, status: "Open", title });
return id;
}
export async function fetchCaseById(id: string) {
await checkPermission("cases/fetch/by-id", id);
return await mongo()
.db("revolt")
.collection<CaseDocument>("safety_cases")
.findOne({ _id: id });
}
export async function fetchMembershipsByUser(userId: string) {
await checkPermission("users/fetch/memberships", userId);
@ -562,13 +631,19 @@ export async function fetchAuthifierEmailClassification(provider: string) {
}
export type SafetyNotes = {
_id: { id: string, type: "message" | "channel" | "server" | "user" | "account" | "global" };
_id: {
id: string;
type: "message" | "channel" | "server" | "user" | "account" | "global";
};
text: string;
edited_by: string;
edited_at: Date;
}
};
export async function fetchSafetyNote(objectId: string, type: SafetyNotes["_id"]["type"]) {
export async function fetchSafetyNote(
objectId: string,
type: SafetyNotes["_id"]["type"]
) {
await checkPermission(`safety_notes/fetch/${type}`, objectId);
return mongo()
@ -577,7 +652,11 @@ export async function fetchSafetyNote(objectId: string, type: SafetyNotes["_id"]
.findOne({ _id: { id: objectId, type: type } });
}
export async function updateSafetyNote(objectId: string, type: SafetyNotes["_id"]["type"], note: string) {
export async function updateSafetyNote(
objectId: string,
type: SafetyNotes["_id"]["type"],
note: string
) {
await checkPermission(`safety_notes/update/${type}`, objectId);
const session = await getServerSession();
@ -594,6 +673,6 @@ export async function updateSafetyNote(objectId: string, type: SafetyNotes["_id"
edited_by: session?.user?.email ?? "",
},
},
{ upsert: true },
{ upsert: true }
);
}

View File

@ -34,6 +34,7 @@
"lodash.debounce": "^4.0.8",
"lucide-react": "^0.263.0",
"mongodb": "^5.7.0",
"nanoid": "^5.0.1",
"next": "13.4.12",
"next-auth": "^4.22.3",
"ntfy": "^1.3.1",

View File

@ -80,6 +80,9 @@ dependencies:
mongodb:
specifier: ^5.7.0
version: 5.7.0
nanoid:
specifier: ^5.0.1
version: 5.0.1
next:
specifier: 13.4.12
version: 13.4.12(react-dom@18.2.0)(react@18.2.0)(sass@1.64.1)
@ -3754,6 +3757,12 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
/nanoid@5.0.1:
resolution: {integrity: sha512-vWeVtV5Cw68aML/QaZvqN/3QQXc6fBfIieAlu05m7FZW2Dgb+3f0xc0TTxuJW+7u30t7iSDTV/j3kVI0oJqIfQ==}
engines: {node: ^18 || >=20}
hasBin: true
dev: false
/natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: false