1
0
Fork 0

Compare commits

..

15 Commits

47 changed files with 236 additions and 2263 deletions

View File

@ -1,24 +0,0 @@
# Revolt instance
REDIS=
MONGODB=
# Authentication
AUTHENTIK_ID=
AUTHENTIK_SECRET=
AUTHENTIK_ISSUER=https://sso.revolt.chat/application/o/admin-panel
# Next Auth
NEXTAUTH_SECRET=
NEXTAUTH_URL=https://admin.revolt.chat
# Web server
PORT=3000
# Notifications using ntfy.sh
NTFY_SERVER=https://ntfy.revolt.wtf
NTFY_TOPIC=reports
NTFY_USERNAME=admin-panel
NTFY_PASSWORD=
# Debugging
# BYPASS_ACL=1

2
.gitignore vendored
View File

@ -37,5 +37,3 @@ next-env.d.ts
# data # data
exports/** exports/**
!exports/.gitkeep !exports/.gitkeep
.pnpm-store

View File

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

View File

@ -1,8 +0,0 @@
import { fetchBackup } from "@/lib/actions";
import { NextResponse } from "next/server";
export async function GET(req: Request, { params }: { params: { name: string } }) {
const name = decodeURIComponent(params.name);
const backup = await fetchBackup(name);
return NextResponse.json(backup);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,37 +0,0 @@
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { fetchBackups } from "@/lib/actions";
import Link from "next/link";
export default async function Backups() {
const backups = await fetchBackups();
return (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{backups.map((backup) => (
<TableRow key={backup.name}>
<TableCell>{backup.name}</TableCell>
<TableCell>{backup.type}</TableCell>
<TableCell>
<Link target="_blank" href={`/api/download/backup/${encodeURIComponent(backup.name)}`}>
<Button>
Download
</Button>
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
);
}

View File

@ -1,41 +0,0 @@
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>
);
}

View File

@ -1,37 +0,0 @@
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

@ -22,10 +22,9 @@ import dayjs from "dayjs";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { User } from "revolt-api"; import { User } from "revolt-api";
import { decodeTime } from "ulid"; import { decodeTime } from "ulid";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import SafetyNotesCard from "@/components/cards/SafetyNotesCard"; import SafetyNotesCard from "@/components/cards/SafetyNotesCard";
import { RestrictedUserCard } from "@/components/cards/RestrictedUserCard";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
export default async function User({ export default async function User({
@ -42,9 +41,7 @@ export default async function User({
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<NavigationToolbar>Inspecting Account</NavigationToolbar> <NavigationToolbar>Inspecting Account</NavigationToolbar>
{user && <UserCard user={user} subtitle={account.email} withLink />}
<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} /> <AccountActions account={account} user={user as User} />
<EmailClassificationCard email={account.email} /> <EmailClassificationCard email={account.email} />
<SafetyNotesCard objectId={account._id} type="account" /> <SafetyNotesCard objectId={account._id} type="account" />

View File

@ -9,11 +9,6 @@ import { Separator } from "@/components/ui/separator";
import { fetchChannelById, fetchServerById, fetchUsersById } from "@/lib/db"; import { fetchChannelById, fetchServerById, fetchUsersById } from "@/lib/db";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { decodeTime } from "ulid";
dayjs.extend(relativeTime);
export default async function Message({ params }: { params: { id: string } }) { export default async function Message({ params }: { params: { id: string } }) {
const channel = await fetchChannelById(params.id); const channel = await fetchChannelById(params.id);
@ -32,7 +27,7 @@ export default async function Message({ params }: { params: { id: string } }) {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<NavigationToolbar>Inspecting Channel</NavigationToolbar> <NavigationToolbar>Inspecting Channel</NavigationToolbar>
<ChannelCard channel={channel!} subtitle={`Channel · Created ${dayjs(decodeTime(channel._id)).fromNow()}`} /> <ChannelCard channel={channel!} subtitle="Channel" />
{server && ( {server && (
<Link href={`/panel/inspect/server/${server._id}`}> <Link href={`/panel/inspect/server/${server._id}`}>

View File

@ -3,7 +3,7 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/use-toast"; import { toast } from "@/components/ui/use-toast";
import { lookupEmail, searchUserByTag } from "@/lib/actions"; import { lookupEmail } from "@/lib/actions";
import { API_URL } from "@/lib/constants"; import { API_URL } from "@/lib/constants";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
@ -11,8 +11,6 @@ import { useState } from "react";
export default function Inspect() { export default function Inspect() {
const [id, setId] = useState(""); const [id, setId] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [discriminator, setDiscriminator] = useState("");
const router = useRouter(); const router = useRouter();
const searchEmail = async () => { const searchEmail = async () => {
@ -33,29 +31,6 @@ 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) => () => const createHandler = (type: string) => () =>
router.push(`/panel/inspect/${type}/${id}`); router.push(`/panel/inspect/${type}/${id}`);
@ -66,7 +41,7 @@ export default function Inspect() {
value={id} value={id}
onChange={(e) => setId(e.currentTarget.value)} onChange={(e) => setId(e.currentTarget.value)}
/> />
<div className="flex flex-col md:flex-row gap-2"> <div className="flex gap-2">
<Button <Button
className="flex-1" className="flex-1"
variant="outline" variant="outline"
@ -117,50 +92,22 @@ export default function Inspect() {
</Button> </Button>
</div> </div>
<hr /> <hr />
<div className="flex flex-col lg:flex-row gap-2 w-full"> <div className="flex gap-2 justify-between">
<div className="flex gap-2 justify-between grow"> <Input
<Input placeholder="Enter an email..."
placeholder="Enter an email..." value={email}
value={email} onChange={(e) => setEmail(e.currentTarget.value)}
onChange={(e) => setEmail(e.currentTarget.value)} onKeyDown={(e) => e.key == "Enter" && email && searchEmail()}
onKeyDown={(e) => e.key == "Enter" && email && searchEmail()} />
/> <Button
<Button className="flex"
className="flex" variant="outline"
variant="outline" disabled={!email}
disabled={!email} onClick={searchEmail}
onClick={searchEmail} >
> Lookup
Lookup </Button>
</Button>
</div> </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> </div>
); );
} }

View File

@ -1,34 +0,0 @@
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

@ -8,13 +8,8 @@ import { ServerActions } from "@/components/pages/inspector/ServerActions";
import { Card, CardHeader } from "@/components/ui/card"; import { Card, CardHeader } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { fetchServerById, fetchUserById } from "@/lib/db"; import { fetchServerById, fetchUserById } from "@/lib/db";
import dayjs from "dayjs";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { decodeTime } from "ulid";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
export default async function Server({ params }: { params: { id: string } }) { export default async function Server({ params }: { params: { id: string } }) {
const server = await fetchServerById(params.id); const server = await fetchServerById(params.id);
@ -25,7 +20,7 @@ export default async function Server({ params }: { params: { id: string } }) {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<NavigationToolbar>Inspecting Server</NavigationToolbar> <NavigationToolbar>Inspecting Server</NavigationToolbar>
<ServerCard server={server} subtitle={`Server · Created ${dayjs(decodeTime(server._id)).fromNow()}`} /> <ServerCard server={server} subtitle="Server" />
<ServerActions server={server} /> <ServerActions server={server} />
<SafetyNotesCard objectId={server._id} type="server" /> <SafetyNotesCard objectId={server._id} type="server" />
{server.description && ( {server.description && (
@ -36,11 +31,9 @@ export default async function Server({ params }: { params: { id: string } }) {
</Card> </Card>
)} )}
{ <Link href={`/panel/inspect/user/${owner!._id}`}>
owner && <Link href={`/panel/inspect/user/${owner!._id}`}>
<UserCard user={owner!} subtitle="Server Owner" /> <UserCard user={owner!} subtitle="Server Owner" />
</Link> </Link>
}
<Separator /> <Separator />
<RecentMessages query={{ channel: { $in: server.channels } }} users /> <RecentMessages query={{ channel: { $in: server.channels } }} users />

View File

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

View File

@ -1,101 +1,32 @@
import { ReportCard } from "@/components/cards/ReportCard"; import { ReportCard } from "@/components/cards/ReportCard";
import { CardLink } from "@/components/common/CardLink"; import { CardLink } from "@/components/common/CardLink";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { fetchOpenReports, fetchUsersById } from "@/lib/db"; import { fetchOpenReports } from "@/lib/db";
import { PizzaIcon } from "lucide-react"; import { PizzaIcon } from "lucide-react";
import { Report } from "revolt-api";
export default async function Reports() { export default async function Reports() {
const reports = (await fetchOpenReports()) const reports = (await fetchOpenReports())
.reverse() .reverse()
.sort((b, _) => (b.content.report_reason.includes("Illegal") ? -1 : 0)); .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 ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-2">
{/*<Input placeholder="Search for reports..." disabled />*/} <Input placeholder="Search for reports..." disabled />
{reports.length ? ( {reports.length
keyOrder ? reports.map((report) => (
.filter((key) => byCategory[key].length) <CardLink key={report._id} href={`/panel/reports/${report._id}`}>
.map((key) => { <ReportCard report={report} />
return ( </CardLink>
<div key={key} className="flex flex-col gap-2"> ))
<h1 className="text-2xl">{authorNames[key] ?? key}</h1> : (<>
{byCategory[key].map((report) => ( <h2 className="mt-8 flex justify-center">
<CardLink <PizzaIcon className="text-gray-400" />
key={report._id} </h2>
href={`/panel/reports/${report._id}`} <h3 className="text-xs text-center pb-2 text-gray-400">
> You&lsquo;ve caught up for now.
<ReportCard report={report} /> </h3>
</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> </div>
); );
} }

View File

@ -1,164 +0,0 @@
"use client";
import EmailClassificationRow, {
CLASSIFICATIONS,
} from "@/components/pages/shield/EmailClassificationRow";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Command, CommandItem } from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { toast } from "@/components/ui/use-toast";
import {
createEmailClassification,
fetchEmailClassifications,
} from "@/lib/actions";
import { EmailClassification } from "@/lib/db";
import { useEffect, useState } from "react";
export default function Classifications() {
const [loaded, setLoaded] = useState(false);
const [domains, setDomains] = useState([] as EmailClassification[]);
const [domainDraft, setDomainDraft] = useState("");
const [classificationDraft, setClassificationDraft] = useState<string>("");
const [classificationOpen, setClassificationOpen] = useState(false);
useEffect(() => {
fetchEmailClassifications().then((domains) => {
setDomains(domains.sort((a, b) => a._id.localeCompare(b._id)));
setLoaded(true);
});
}, []);
return (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Domain</TableHead>
<TableHead>Classification</TableHead>
<TableHead className="flex flex-row items-center justify-between gap-2 pr-1">
<span>Action</span>
<div className="text-end pr-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button disabled={!loaded}>Add</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Create classification</AlertDialogTitle>
<AlertDialogDescription className="flex flex-row gap-1">
<Input
value={domainDraft}
onChange={(e) =>
setDomainDraft(e.currentTarget.value)
}
placeholder="reddit.com"
/>
<Popover
open={classificationOpen}
onOpenChange={setClassificationOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={classificationOpen}
>
{classificationDraft || "Classification"}
</Button>
</PopoverTrigger>
<PopoverContent>
<Command>
{CLASSIFICATIONS.map((c) => (
<CommandItem
key={c}
onSelect={() => {
setClassificationDraft(c);
setClassificationOpen(false);
}}
>
{c}
</CommandItem>
))}
</Command>
</PopoverContent>
</Popover>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={!domainDraft || !classificationDraft}
onClick={async () => {
try {
await createEmailClassification(
domainDraft,
classificationDraft
);
setDomains([
...domains,
{
_id: domainDraft,
classification: classificationDraft,
},
]);
setDomainDraft("");
setClassificationDraft("");
setClassificationOpen(false);
toast({
title: "Classification created",
});
} catch (e) {
toast({
title: "Failed to create classification",
description: String(e),
variant: "destructive",
});
}
}}
>
Create
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{domains.map((domain) => (
<EmailClassificationRow
key={domain._id}
domain={domain._id}
classification={domain.classification}
/>
))}
</TableBody>
</Table>
</Card>
);
}

View File

@ -1,14 +0,0 @@
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { redirect } from "next/navigation";
export default function Shield() {
// todo add a list of buttons here once there's more categories
redirect("/panel/shield/classifications");
// return (
// <div className="flex flex-row gap-2">
// <Link href="/panel/shield/classifications"><Button>Email Classifications</Button></Link>
// </div>
// );
}

View File

@ -1,25 +1,7 @@
import { execSync } from "child_process";
export default function Sparkle() { export default function Sparkle() {
const hash = execSync("git rev-parse HEAD", { cwd: process.cwd() }).toString().trim();
const branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: process.cwd() }).toString().trim();
return ( return (
<div> <div>
<h1 className="text-xl text-center"> <h1 className="text-xl text-center">Running version v0.0.1</h1>
<span>Running version </span>
<code>
<a href={`https://git.revolt.chat/administration/panel/commit/${hash}`} target="_blank">
{hash.substring(0, 10)}
</a>
</code>
<span> on branch </span>
<code>
<a href={`https://git.revolt.chat/administration/panel/src/branch/${branch}`} target="_blank">
{branch}
</a>
</code>
</h1>
<img <img
className="absolute right-0 bottom-0" className="absolute right-0 bottom-0"
src="https://api.gifbox.me/file/posts/MF3oORlDjfHAVJ-DgPyRQSjMdy9WNIxk.webp" src="https://api.gifbox.me/file/posts/MF3oORlDjfHAVJ-DgPyRQSjMdy9WNIxk.webp"

View File

@ -1,32 +0,0 @@
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

@ -19,7 +19,7 @@ export function ChannelCard({
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-1"> <CardTitle>
<Avatar> <Avatar>
{channel.channel_type !== "DirectMessage" && ( {channel.channel_type !== "DirectMessage" && (
<AvatarImage src={`${AUTUMN_URL}/icons/${channel.icon?._id}`} /> <AvatarImage src={`${AUTUMN_URL}/icons/${channel.icon?._id}`} />

View File

@ -1,23 +1,13 @@
import { Report } from "revolt-api";
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card"; import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Badge } from "../ui/badge"; import { Badge } from "../ui/badge";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { decodeTime } from "ulid"; import { decodeTime } from "ulid";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import { ReportDocument } from "@/lib/db";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
const lastWeek = new Date(); export function ReportCard({ report }: { report: Report }) {
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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@ -47,25 +37,6 @@ export function ReportCard({ report }: { report: ReportDocument }) {
{dayjs(decodeTime(report._id)).fromNow()}{" "} {dayjs(decodeTime(report._id)).fromNow()}{" "}
{report.status !== "Created" && report.closed_at && ( {report.status !== "Created" && report.closed_at && (
<>&middot; Closed {dayjs(report.closed_at).fromNow()}</> <>&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> </CardDescription>
</CardHeader> </CardHeader>

View File

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

View File

@ -13,7 +13,7 @@ export function ServerCard({
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-1"> <CardTitle>
<Avatar> <Avatar>
<AvatarImage src={`${AUTUMN_URL}/icons/${server.icon?._id}`} /> <AvatarImage src={`${AUTUMN_URL}/icons/${server.icon?._id}`} />
<AvatarFallback> <AvatarFallback>

View File

@ -7,25 +7,18 @@ import Link from "next/link";
import { ExternalLinkIcon } from "lucide-react"; import { ExternalLinkIcon } from "lucide-react";
export function UserCard({ user, subtitle, withLink }: { user: User; subtitle: string, withLink?: boolean }) { 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 ( return (
<Card <Card
className="bg-no-repeat bg-right text-left" className="bg-no-repeat bg-right text-left"
style={{ style={{
backgroundImage: user.profile?.background backgroundImage: user.profile?.background
? `${gradient}, url('${AUTUMN_URL}/backgrounds/${user.profile.background._id}')` ? `linear-gradient(to right, white, rgba(255,0,0,0)), url('${AUTUMN_URL}/backgrounds/${user.profile.background._id}')`
: gradient, : "",
backgroundSize: "75%", backgroundSize: "75%",
}} }}
> >
<CardHeader> <CardHeader>
<CardTitle className="overflow-hidden overflow-ellipsis whitespace-nowrap flex items-center gap-1"> <CardTitle className="overflow-hidden overflow-ellipsis whitespace-nowrap">
<Avatar> <Avatar>
<AvatarImage src={`${AUTUMN_URL}/avatars/${user.avatar?._id}`} /> <AvatarImage src={`${AUTUMN_URL}/avatars/${user.avatar?._id}`} />
<AvatarFallback className="overflow-hidden overflow-ellipsis whitespace-nowrap"> <AvatarFallback className="overflow-hidden overflow-ellipsis whitespace-nowrap">
@ -37,8 +30,6 @@ export function UserCard({ user, subtitle, withLink }: { user: User; subtitle: s
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
{user.bot && <Badge className="align-middle">Bot</Badge>}{" "} {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"> <div className="flex gap-2">
{user.username}#{user.discriminator} {user.display_name} {user.username}#{user.discriminator} {user.display_name}
{ {

View File

@ -1,54 +0,0 @@
"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

@ -1,17 +1,14 @@
import Link from "next/link"; import Link from "next/link";
import { buttonVariants } from "../ui/button"; import { buttonVariants } from "../ui/button";
import { import {
Bomb,
Eye, Eye,
Globe2, Globe2,
Home, Home,
ScrollText, ScrollText,
Search, Search,
Shield,
Siren, Siren,
Sparkles, Sparkles,
TrendingUp, TrendingUp,
BookCopy,
} from "lucide-react"; } from "lucide-react";
export function NavigationLinks() { export function NavigationLinks() {
@ -35,30 +32,12 @@ export function NavigationLinks() {
> >
<Siren className="h-4 w-4" /> <Siren className="h-4 w-4" />
</Link> </Link>
<Link
className={buttonVariants({ variant: "outline", size: "icon" })}
href="/panel/cases"
>
<BookCopy className="h-4 w-4" />
</Link>
<Link <Link
className={buttonVariants({ variant: "outline", size: "icon" })} className={buttonVariants({ variant: "outline", size: "icon" })}
href="/panel/inspect" href="/panel/inspect"
> >
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
</Link> </Link>
<Link
className={buttonVariants({ variant: "outline", size: "icon" })}
href="/panel/shield"
>
<Shield className="h-4 w-4" />
</Link>
<Link
className={buttonVariants({ variant: "outline", size: "icon" })}
href="/panel/backups"
>
<Bomb className="h-4 w-4" />
</Link>
{/*<Link {/*<Link
className={buttonVariants({ variant: "outline", size: "icon" })} className={buttonVariants({ variant: "outline", size: "icon" })}
href="/panel/discover" href="/panel/discover"

View File

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

View File

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

View File

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

View File

@ -1,95 +0,0 @@
"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,24 +18,12 @@ export function RelevantObjects({
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<div className="flex-1 min-w-0 flex flex-col gap-2"> <div className="flex-1 min-w-0 flex flex-col gap-2">
<h2 className="text-md text-center pb-2">Bots & Relations</h2> <h2 className="text-md text-center pb-2">Bots & Friends</h2>
<ListCompactor <ListCompactor
data={[ data={users}
// 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 }) => ( Component={({ item }) => (
<Link href={`/panel/inspect/user/${item._id}`}> <Link href={`/panel/inspect/user/${item._id}`}>
<UserCard <UserCard user={item} subtitle="" />
user={item}
subtitle={
item.bot?.owner == userId
? "Owned bot"
: item.relations?.find((relation) => relation._id == userId)?.status || ""
}
/>
</Link> </Link>
)} )}
/> />
@ -43,21 +31,12 @@ export function RelevantObjects({
<div className="flex-1 min-w-0 flex flex-col gap-2"> <div className="flex-1 min-w-0 flex flex-col gap-2">
<h2 className="text-md text-center pb-2">Servers</h2> <h2 className="text-md text-center pb-2">Servers</h2>
<ListCompactor <ListCompactor
// same as above data={servers}
data={[
...servers.filter((server) => userId == server.owner),
...servers.filter((server) => userId != server.owner),
]}
Component={({ item }) => ( Component={({ item }) => (
<Link href={`/panel/inspect/server/${item._id}`}> <Link href={`/panel/inspect/server/${item._id}`}>
<ServerCard <ServerCard
server={item} server={item}
subtitle={ subtitle={userId === item.owner ? "Server Owner" : ""}
[
userId === item.owner ? "Server Owner" : null,
item.discoverable ? "Discoverable" : null,
].filter(i => i).join(" · ")
}
/> />
</Link> </Link>
)} )}

View File

@ -14,7 +14,6 @@ import {
import { useState } from "react"; import { useState } from "react";
import { useToast } from "../../ui/use-toast"; import { useToast } from "../../ui/use-toast";
import { import {
assignReportToCase,
rejectReport, rejectReport,
reopenReport, reopenReport,
resolveReport, resolveReport,
@ -33,10 +32,6 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from "../../ui/alert-dialog"; } from "../../ui/alert-dialog";
import { ReportCard } from "../../cards/ReportCard"; 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> = { const template: Record<string, (ref: string) => string> = {
resolved: (ref) => resolved: (ref) =>
@ -48,7 +43,7 @@ const template: Record<string, (ref: string) => string> = {
"not enough evidence": (ref) => "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.`, `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) => clarify: (ref) =>
`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.`, `Your report (${ref}) needs clarification, please provide additional information.`,
acknowledged: (ref) => acknowledged: (ref) =>
`Your report (${ref}) has been acknowledged, we will be monitoring the situation.`, `Your report (${ref}) has been acknowledged, we will be monitoring the situation.`,
default: (ref) => default: (ref) =>
@ -59,12 +54,11 @@ export function ReportActions({
report, report,
reference, reference,
}: { }: {
report: ReportDocument; report: Report;
reference: string; reference: string;
}) { }) {
const { toast } = useToast(); const { toast } = useToast();
const [reportDraft, setDraft] = useState(report); const [reportDraft, setDraft] = useState(report);
const [availableCases, setAvailableCases] = useState<CaseDocument[]>([]);
function rejectHandler(reason: string) { function rejectHandler(reason: string) {
return async () => { return async () => {
@ -89,7 +83,6 @@ export function ReportActions({
<ReportCard report={reportDraft} /> <ReportCard report={reportDraft} />
<Textarea <Textarea
rows={8}
placeholder="Enter notes here... (save on unfocus)" placeholder="Enter notes here... (save on unfocus)"
className="!min-h-0 !h-[76px]" className="!min-h-0 !h-[76px]"
defaultValue={report.notes} defaultValue={report.notes}
@ -113,74 +106,6 @@ 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"> <div className="flex gap-2">
{reportDraft.status === "Created" ? ( {reportDraft.status === "Created" ? (
<> <>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { Server, User } from "revolt-api"; import { Server } from "revolt-api";
import { Button, buttonVariants } from "../../ui/button"; import { Button, buttonVariants } from "../../ui/button";
import { import {
Command, Command,
@ -14,28 +14,17 @@ import {
import { Check, ChevronsUpDown } from "lucide-react"; import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useState } from "react"; import { useState } from "react";
import { addServerMember, quarantineServer, updateServerDiscoverability, updateServerFlags, updateServerOwner } from "@/lib/actions"; import { updateServerDiscoverability, updateServerFlags } from "@/lib/actions";
import { useToast } from "../../ui/use-toast"; import { useToast } from "../../ui/use-toast";
import Link from "next/link"; import Link from "next/link";
import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu";
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Checkbox } from "@/components/ui/checkbox";
import UserSelector from "@/components/ui/user-selector";
import { Textarea } from "@/components/ui/textarea";
import { SEVRER_REMOVAL_MESSAGE } from "@/lib/constants";
export function ServerActions({ server }: { server: Server }) { export function ServerActions({ server }: { server: Server }) {
const [selectBadges, setSelectBadges] = useState(false); const [selectBadges, setSelectBadges] = useState(false);
const [serverDraft, setDraft] = useState(server); const [serverDraft, setDraft] = useState(server);
const [quarantineMessage, setQuarantineMessage] = useState(SEVRER_REMOVAL_MESSAGE(server));
const [newOwner, setNewOwner] = useState<User | null>(null);
const [newMember, setNewMember] = useState<User | null>(null);
const [newMemberEvent, setNewMemberEvent] = useState(true);
const { toast } = useToast(); const { toast } = useToast();
return ( return (
<div className="flex flex-col md:flex-row gap-2"> <div className="flex gap-2">
{serverDraft.discoverable ? ( {serverDraft.discoverable ? (
<Button <Button
className="flex-1" className="flex-1"
@ -146,156 +135,9 @@ export function ServerActions({ server }: { server: Server }) {
Invites Invites
</Link> </Link>
<AlertDialog> <Button className="flex-1" variant="destructive">
<AlertDialogTrigger asChild> Quarantine
<Button className="flex-1" variant="destructive"> </Button>
Quarantine
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Quarantine server
</AlertDialogTitle>
<AlertDialogDescription className="flex flex-col gap-1">
<span>This will remove all members from this server and revoke all invites.</span>
<span className="text-red-700">This action is irreversible!</span>
<br />
<Textarea
placeholder="Removal message"
value={quarantineMessage}
onChange={(e) => setQuarantineMessage(e.currentTarget.value)}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-red-800 hover:bg-red-700"
disabled={quarantineMessage == SEVRER_REMOVAL_MESSAGE(server) || !quarantineMessage}
onClick={async () => {
if (serverDraft.flags) {
// Intentionally not clearing the quarantine message draft
toast({
title: "Refusing to quarantine",
description: "This server is marked as verified or official",
variant: "destructive",
});
return;
}
try {
await quarantineServer(server._id, quarantineMessage);
toast({
title: "Quarantined server",
});
setQuarantineMessage(SEVRER_REMOVAL_MESSAGE(server));
} catch(e) {
toast({
title: "Failed to quarantine",
description: String(e),
variant: "destructive",
});
}
}}
>
Quarantine
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="flex-1">
More Options
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="flex flex-col">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost">
Change owner
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Change server owner
</AlertDialogTitle>
<AlertDialogDescription className="flex flex-col gap-2">
Enter the ID of the new server owner.
<UserSelector
onChange={(user) => setNewOwner(user)}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={!newOwner}
onClick={async () => {
try {
await updateServerOwner(server._id, newOwner!._id);
setNewOwner(null);
toast({ title: "Server owner changed" });
} catch(e) {
toast({
title: "Owner update failed",
description: String(e),
variant: "destructive",
});
}
}}
>
Update
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost">
Add member
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Add member to server
</AlertDialogTitle>
<AlertDialogDescription className="flex flex-col gap-2">
Enter the ID of the user you want to add.
<UserSelector onChange={(user) => setNewMember(user)} />
<Checkbox checked={newMemberEvent} onChange={(state) => setNewMemberEvent(state === true)}>Publish join event</Checkbox>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={!newMember}
onClick={async () => {
try {
await addServerMember(server._id, newMember!._id, newMemberEvent);
setNewMember(null);
toast({ title: "User added to server" });
} catch(e) {
toast({
title: "Failed to add user",
description: String(e),
variant: "destructive",
});
}
}}
>
Update
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
); );
} }

View File

@ -23,14 +23,11 @@ import { Input } from "../../ui/input";
import { import {
banUser, banUser,
closeReportsByUser, closeReportsByUser,
resetBotToken,
sendAlert, sendAlert,
suspendUser, suspendUser,
transferBot,
unsuspendUser, unsuspendUser,
updateBotDiscoverability, updateBotDiscoverability,
updateUserBadges, updateUserBadges,
wipeUser,
wipeUserProfile, wipeUserProfile,
} from "@/lib/actions"; } from "@/lib/actions";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
@ -40,8 +37,6 @@ import { Card, CardHeader } from "../../ui/card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { decodeTime } from "ulid"; import { decodeTime } from "ulid";
import { Checkbox } from "@/components/ui/checkbox"; 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]; const badges = [1, 2, 4, 8, 16, 32, 128, 0, 256, 512, 1024];
@ -58,8 +53,6 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
displayName: false, displayName: false,
status: false, status: false,
}); });
const [transferTarget, setTransferTarget] = useState<User | null>(null);
const [transferResetToken, setTransferResetToken] = useState(true);
const userInaccessible = userDraft.flags === 4 || userDraft.flags === 2; const userInaccessible = userDraft.flags === 4 || userDraft.flags === 2;
@ -110,7 +103,7 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
</CardHeader> </CardHeader>
</Card> </Card>
<div className="flex flex-col md:flex-row gap-2"> <div className="flex gap-2">
{bot ? ( {bot ? (
botDraft!.discoverable ? ( botDraft!.discoverable ? (
<Button <Button
@ -265,51 +258,6 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </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> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button className="flex-1 bg-yellow-600">Bees</Button> <Button className="flex-1 bg-yellow-600">Bees</Button>
@ -349,7 +297,7 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
This will send a message from the Platform Moderation This will send a message from the Platform Moderation
account. account.
</span> </span>
<Textarea <Input
placeholder="Enter a message..." placeholder="Enter a message..."
name="message" name="message"
onChange={(e) => onChange={(e) =>
@ -468,101 +416,6 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </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> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="ghost">Close Open Reports</Button> <Button variant="ghost">Close Open Reports</Button>

View File

@ -1,135 +0,0 @@
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTrigger,
AlertDialogCancel,
AlertDialogDescription,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Command, CommandItem } from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { TableCell, TableRow } from "@/components/ui/table";
import { toast } from "@/components/ui/use-toast";
import {
deleteEmailClassification,
updateEmailClassification,
} from "@/lib/actions";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import { useState } from "react";
export const CLASSIFICATIONS = ["DISPOSABLE", "PRONE_TO_ABUSE", "ALIAS"];
export default function EmailClassificationRow({
domain,
...props
}: {
domain: string;
classification: string;
}) {
const [classification, setClassification] = useState(props.classification);
const [selectClassification, setSelectClassification] = useState(false);
const [deleted, setDeleted] = useState(false);
return deleted ? null : (
<TableRow>
<TableCell>{domain}</TableCell>
<TableCell>
<Popover
open={selectClassification}
onOpenChange={setSelectClassification}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={selectClassification}
>
{classification}
</Button>
</PopoverTrigger>
<PopoverContent>
<Command>
{CLASSIFICATIONS.map((c) => (
<CommandItem
key={c}
onSelect={async () => {
try {
await updateEmailClassification(domain, c);
setSelectClassification(false);
setClassification(c);
toast({
title: "Classification updated",
description: `${domain} is now classified as ${c}`,
});
} catch (e) {
toast({
title: "Failed to update classification",
description: String(e),
variant: "destructive",
});
}
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
c == classification ? "opacity-100" : "opacity-0"
)}
/>
{c}
</CommandItem>
))}
</Command>
</PopoverContent>
</Popover>
</TableCell>
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button>Remove</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogDescription>
Delete classification for {domain}?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
try {
await deleteEmailClassification(domain);
setDeleted(true);
toast({
title: "Classification deleted",
});
} catch (e) {
toast({
title: "Failed to delete classification",
description: String(e),
variant: "destructive",
});
}
}}
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
);
}

View File

@ -1,7 +1,7 @@
import * as React from "react"; import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
const badgeVariants = cva( 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", "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,8 +14,6 @@ const badgeVariants = cva(
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", "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", outline: "text-foreground",
}, },
}, },
@ -23,7 +21,7 @@ const badgeVariants = cva(
variant: "default", variant: "default",
}, },
} }
); )
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
@ -32,7 +30,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return ( return (
<div className={cn(badgeVariants({ variant }), className)} {...props} /> <div className={cn(badgeVariants({ variant }), className)} {...props} />
); )
} }
export { Badge, badgeVariants }; export { Badge, badgeVariants }

View File

@ -1,88 +0,0 @@
import { useEffect, useState } from "react";
import { Input } from "./input";
import { Card, CardDescription, CardHeader, CardTitle } from "./card";
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
import { User } from "revolt-api";
import { AUTUMN_URL } from "@/lib/constants";
import { fetchUserById } from "@/lib/db";
export default function UserSelector({ onChange }: {
onChange?: (user: User | null) => any,
}) {
const [input, setInput] = useState("");
const [user, setUser] = useState<User | null>(null);
const [searching, setSearching] = useState(false);
useEffect(() => {
if (input.length != 26) {
onChange?.(null);
if (user) setUser(null);
return;
}
if (!searching) return;
if (input != user?._id) {
setSearching(true);
fetchUserById(input)
.then((user) => {
setUser(user);
onChange?.(user);
})
.catch((e) => {
setUser(null);
onChange?.(null);
})
.finally(() => setSearching(false));
}
else setUser(null);
}, [input, user, searching, onChange]);
return (
<div>
<Input
className="rounded-b-none"
style={{ boxShadow: "none" }} // doing this with tailwind just... doesnt work
placeholder="Enter an ID..."
value={input}
onChange={(e) => {
setInput(e.currentTarget.value);
setSearching(true);
}}
/>
<Card className="border-t-0 rounded-t-none">
<CardHeader>
<CardTitle className={`flex items-center gap-1 ${user ? "" : "text-gray-400"}`}>
<Avatar>
{user && <AvatarImage src={`${AUTUMN_URL}/avatars/${user.avatar?._id}`} />}
<AvatarFallback className="overflow-hidden overflow-ellipsis whitespace-nowrap">
{user
? (user.display_name ?? user.username)
.split(" ")
.slice(0, 2)
.map((x) => String.fromCodePoint(x.codePointAt(0) ?? 32) ?? "")
.join("")
: "?"}
</AvatarFallback>
</Avatar>
{user
? <>{user.username}#{user.discriminator} {user.display_name}</>
: "User#0000"
}
</CardTitle>
<CardDescription>
{
!input
? "Enter an ID..."
: input.length != 26
? "Invalid ID"
: searching
? "Searching..."
: user
? "User exists!"
: "Unknown user"
}
</CardDescription>
</CardHeader>
</Card>
</div>
)
}

View File

@ -1,9 +0,0 @@
with import <nixpkgs> { };
pkgs.mkShell {
name = "adminEnv";
buildInputs = [
pkgs.git
pkgs.nodejs
pkgs.nodePackages.pnpm
];
}

View File

@ -2,9 +2,7 @@ import { getServerSession } from "next-auth";
import { SafetyNotes, insertAuditLog } from "./db"; import { SafetyNotes, insertAuditLog } from "./db";
type Permission = type Permission =
| `authifier${ | "authifier"
| ""
| `/classification${"" | "/fetch" | "/create" | "/update" | "/delete"}`}`
| "publish_message" | "publish_message"
| "chat_message" | "chat_message"
| `accounts${ | `accounts${
@ -17,75 +15,45 @@ type Permission =
| `bots${ | `bots${
| "" | ""
| `/fetch${"" | "/by-id" | "/by-user"}` | `/fetch${"" | "/by-id" | "/by-user"}`
| `/update${"" | "/discoverability" | "/owner" | "/reset-token"}`}` | `/update${"" | "/discoverability"}`}`
| `channels${ | `channels${"" | `/fetch${"" | "/by-id" | "/by-server" | "/dm" | "/invites"}` | `/create${"" | "/dm" | "/invites"}` | `/update${"" | "/invites"}`}`
| ""
| `/fetch${"" | "/by-id" | "/by-server" | "/dm" | "/invites"}`
| `/create${"" | "/dm" | "/invites"}`
| `/update${"" | "/invites"}`}`
| `messages${"" | `/fetch${"" | "/by-id" | "/by-user"}`}` | `messages${"" | `/fetch${"" | "/by-id" | "/by-user"}`}`
| `cases${
| ""
| "/create"
| `/fetch${"" | "/by-id" | "/open"}`
| `/update${"" | "/close" | "/reopen" | "/notes"}`}`
| `reports${ | `reports${
| "" | ""
| `/fetch${ | `/fetch${
| "" | ""
| "/by-id" | "/by-id"
| "/open" | "/open"
| `/related${ | `/related${"" | "/by-content" | "/by-user" | "/against-user"}`
| ""
| "/by-content"
| "/by-user"
| "/by-case"
| "/against-user"}`
| `/snapshots${"" | "/by-report" | "/by-user"}`}` | `/snapshots${"" | "/by-report" | "/by-user"}`}`
| `/update${ | `/update${
| "" | ""
| "/notes" | "/notes"
| "/resolve" | "/resolve"
| "/reject" | "/reject"
| "/case"
| "/reopen" | "/reopen"
| `/bulk-close${"" | "/by-user"}`}`}` | `/bulk-close${"" | "/by-user"}`}`}`
| `sessions${"" | `/fetch${"" | "/by-account-id"}`}` | `sessions${"" | `/fetch${"" | "/by-account-id"}`}`
| `servers${ | `servers${
| "" | ""
| `/fetch${"" | "/by-id"}` | `/fetch${"" | "/by-id"}`
| `/update${ | `/update${"" | "/flags" | "/discoverability"}`}`
| ""
| "/flags"
| "/discoverability"
| "/owner"
| "/add-member"
| "/quarantine"}`}`
| `users${ | `users${
| "" | ""
| `/fetch${ | `/fetch${
| "" | ""
| "/by-id" | "/by-id"
| "/by-tag"
| "/bulk-by-username"
| "/memberships" | "/memberships"
| "/strikes" | "/strikes"
| "/notices" | "/notices"
| "/relations"}` | "/relations"}`
| `/create${"" | "/alert" | "/strike"}` | `/create${"" | "/alert" | "/strike"}`
| `/update${"" | "/badges"}` | `/update${"" | "/badges"}`
| `/action${ | `/action${"" | "/unsuspend" | "/suspend" | "/wipe" | "/ban" | "/wipe-profile"}`}`
| ""
| "/unsuspend"
| "/suspend"
| "/wipe"
| "/ban"
| "/wipe-profile"}`}`
| `safety_notes${ | `safety_notes${
| "" | ""
| `/fetch${"" | `/${SafetyNotes["_id"]["type"]}`}` | `/fetch${"" | `/${SafetyNotes["_id"]["type"]}`}`
| `/update${"" | `/${SafetyNotes["_id"]["type"]}`}`}` | `/update${"" | `/${SafetyNotes["_id"]["type"]}`}`}`;
| `backup${"" | `/fetch${"" | "/by-name"}`}`;
const PermissionSets = { const PermissionSets = {
// Admin // Admin
@ -107,8 +75,6 @@ const PermissionSets = {
// View open reports // View open reports
"view-open-reports": [ "view-open-reports": [
"users/fetch/by-id", "users/fetch/by-id",
"cases/fetch/open",
"cases/fetch/by-id",
"reports/fetch/open", "reports/fetch/open",
"reports/fetch/by-id", "reports/fetch/by-id",
"reports/fetch/related", "reports/fetch/related",
@ -120,12 +86,7 @@ const PermissionSets = {
"reports/update/notes", "reports/update/notes",
"reports/update/resolve", "reports/update/resolve",
"reports/update/reject", "reports/update/reject",
"reports/update/case",
"reports/update/reopen", "reports/update/reopen",
"cases/create",
"cases/update/notes",
"cases/update/close",
"cases/update/reopen",
] as Permission[], ] as Permission[],
// Revolt Discover // Revolt Discover
@ -151,12 +112,6 @@ const PermissionSets = {
"users/fetch/notices", "users/fetch/notices",
"users/update/badges", "users/update/badges",
"servers/update/owner",
"bots/fetch/by-user",
"bots/update/reset-token",
"bots/update/owner",
"accounts/fetch/by-id", "accounts/fetch/by-id",
"accounts/fetch/by-email", "accounts/fetch/by-email",
"accounts/disable", "accounts/disable",
@ -176,18 +131,12 @@ const PermissionSets = {
// Moderate users // Moderate users
"moderate-users": [ "moderate-users": [
"users/fetch/by-id", "users/fetch/by-id",
"users/fetch/by-tag",
"users/fetch/bulk-by-username",
"users/fetch/strikes", "users/fetch/strikes",
"users/fetch/notices", "users/fetch/notices",
"bots/fetch/by-user", "bots/fetch/by-user",
"bots/update/reset-token",
"bots/update/owner",
// "messages/fetch/by-user", // "messages/fetch/by-user",
"users/fetch/memberships", // "users/fetch/memberships",
"users/fetch/relations",
"servers/fetch", "servers/fetch",
"messages/fetch/by-id", "messages/fetch/by-id",
@ -196,15 +145,9 @@ const PermissionSets = {
"channels/fetch/invites", "channels/fetch/invites",
"channels/create/dm", "channels/create/dm",
"servers/update/quarantine",
"servers/update/owner",
"servers/update/add-member",
"backup/fetch",
"reports/fetch/related/by-user", "reports/fetch/related/by-user",
"reports/fetch/related/by-content", "reports/fetch/related/by-content",
"reports/fetch/related/against-user", "reports/fetch/related/against-user",
"reports/update/bulk-close/by-user",
"users/create/alert", "users/create/alert",
"users/create/strike", "users/create/strike",
@ -222,8 +165,6 @@ const PermissionSets = {
"safety_notes/fetch", "safety_notes/fetch",
"safety_notes/update", "safety_notes/update",
] as Permission[], ] as Permission[],
authifier: ["authifier/classification"] as Permission[],
}; };
const Roles = { const Roles = {
@ -231,7 +172,6 @@ const Roles = {
...PermissionSets["view-open-reports"], ...PermissionSets["view-open-reports"],
...PermissionSets["edit-reports"], ...PermissionSets["edit-reports"],
...PermissionSets["moderate-users"], ...PermissionSets["moderate-users"],
...PermissionSets["authifier"],
], ],
"user-support": [...PermissionSets["user-support"]], "user-support": [...PermissionSets["user-support"]],
"revolt-discover": [...PermissionSets["revolt-discover"]], "revolt-discover": [...PermissionSets["revolt-discover"]],
@ -244,32 +184,22 @@ const ACL: Record<string, Set<Permission>> = {
...Roles["revolt-discover"], ...Roles["revolt-discover"],
...Roles["user-support"], ...Roles["user-support"],
] as Permission[]), ] as Permission[]),
"lea@revolt.chat": new Set([ "lea@janderedev.xyz": new Set([
...Roles["moderator"], ...Roles["moderator"],
...Roles["revolt-discover"], ...Roles["revolt-discover"],
...Roles["user-support"], ...Roles["user-support"],
] as Permission[]), ] as Permission[]),
"tom@revolt.chat": new Set([ "infi@infi.sh": new Set([
...Roles["moderator"], ...Roles["moderator"],
...Roles["revolt-discover"], ...Roles["revolt-discover"],
...Roles["user-support"], ...Roles["user-support"],
] as Permission[]), ] as Permission[]),
"jen@revolt.chat": new Set([ "beartechtalks@gmail.com": new Set([
...Roles["moderator"], ...Roles["moderator"],
...Roles["revolt-discover"], ...Roles["revolt-discover"],
...Roles["user-support"], ...Roles["user-support"],
] as Permission[]), ] as Permission[]),
"rexo@revolt.chat": new Set([ "me@zomatree.live": 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["moderator"],
...Roles["revolt-discover"], ...Roles["revolt-discover"],
...Roles["user-support"], ...Roles["user-support"],
@ -306,5 +236,5 @@ export async function checkPermission(
if (!(await hasPermissionFromSession(permission))) if (!(await hasPermissionFromSession(permission)))
throw `Missing permission ${permission}`; throw `Missing permission ${permission}`;
return await insertAuditLog(permission, context, args); await insertAuditLog(permission, context, args);
} }

View File

@ -1,13 +1,10 @@
"use server"; "use server";
import { readFile, readdir, writeFile } from "fs/promises"; import { writeFile } from "fs/promises";
import { PLATFORM_MOD_ID, RESTRICT_ACCESS_LIST } from "./constants"; import { PLATFORM_MOD_ID, RESTRICT_ACCESS_LIST } from "./constants";
import mongo, { import mongo, {
Account, Account,
CaseDocument,
ChannelInvite, ChannelInvite,
EmailClassification,
ReportDocument,
createDM, createDM,
fetchAccountById, fetchAccountById,
findDM, findDM,
@ -20,7 +17,6 @@ import {
Bot, Bot,
Channel, Channel,
File, File,
Invite,
Member, Member,
Message, Message,
Report, Report,
@ -29,8 +25,6 @@ import {
User, User,
} from "revolt-api"; } from "revolt-api";
import { checkPermission } from "./accessPermissions"; import { checkPermission } from "./accessPermissions";
import { Long } from "mongodb";
import { nanoid } from "nanoid";
export async function sendAlert(userId: string, content: string) { export async function sendAlert(userId: string, content: string) {
await checkPermission("users/create/alert", userId, { content }); await checkPermission("users/create/alert", userId, { content });
@ -100,48 +94,6 @@ 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) { export async function resolveReport(reportId: string) {
await checkPermission("reports/update/resolve", reportId); await checkPermission("reports/update/resolve", reportId);
@ -160,24 +112,6 @@ export async function resolveReport(reportId: string) {
return $set; 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) { export async function rejectReport(reportId: string, reason: string) {
await checkPermission("reports/update/reject", reportId, { reason }); await checkPermission("reports/update/reject", reportId, { reason });
@ -220,23 +154,6 @@ export async function reopenReport(reportId: string) {
return $set; 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) { export async function closeReportsByUser(userId: string) {
await checkPermission("reports/update/bulk-close/by-user", userId); await checkPermission("reports/update/bulk-close/by-user", userId);
@ -287,8 +204,8 @@ export async function deleteMFARecoveryCodes(userId: string) {
$unset: { $unset: {
"mfa.recovery_codes": 1, "mfa.recovery_codes": 1,
}, },
} },
); )
} }
export async function disableMFA(userId: string) { export async function disableMFA(userId: string) {
@ -304,8 +221,8 @@ export async function disableMFA(userId: string) {
$unset: { $unset: {
"mfa.totp_token": 1, "mfa.totp_token": 1,
}, },
} },
); )
} }
export async function changeAccountEmail(userId: string, email: string) { export async function changeAccountEmail(userId: string, email: string) {
@ -366,7 +283,9 @@ export async function verifyAccountEmail(userId: string) {
export async function lookupEmail(email: string): Promise<string | false> { export async function lookupEmail(email: string): Promise<string | false> {
await checkPermission("accounts/fetch/by-email", email); 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 }); let result = await accounts.findOne({ email: email });
if (result) return result._id; if (result) return result._id;
@ -446,21 +365,15 @@ export async function updateUserBadges(userId: string, badges: number) {
} }
} }
export async function wipeUser( export async function wipeUser(userId: string, flags = 4) {
userId: string,
flags = 4,
onlyMessages = false
) {
if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access"; if (RESTRICT_ACCESS_LIST.includes(userId)) throw "restricted access";
await checkPermission("users/action/wipe", userId, { flags }); await checkPermission("users/action/wipe", userId, { flags });
const user = onlyMessages const user = await mongo()
? null .db("revolt")
: await mongo() .collection<User>("users")
.db("revolt") .findOne({ _id: userId });
.collection<User>("users")
.findOne({ _id: userId });
const messages = await mongo() const messages = await mongo()
.db("revolt") .db("revolt")
@ -468,28 +381,24 @@ export async function wipeUser(
.find({ author: userId }, { sort: { _id: -1 } }) .find({ author: userId }, { sort: { _id: -1 } })
.toArray(); .toArray();
const dms = onlyMessages const dms = await mongo()
? null .db("revolt")
: await mongo() .collection<Channel>("channels")
.db("revolt") .find({
.collection<Channel>("channels") channel_type: "DirectMessage",
.find({ recipients: userId,
channel_type: "DirectMessage", })
recipients: userId, .toArray();
})
.toArray();
const memberships = onlyMessages const memberships = await mongo()
? null .db("revolt")
: await mongo() .collection<{ _id: { user: string; server: string } }>("server_members")
.db("revolt") .find({ "_id.user": userId })
.collection<{ _id: { user: string; server: string } }>("server_members") .toArray();
.find({ "_id.user": userId })
.toArray();
// retrieve messages, dm channels, relationships, server memberships // retrieve messages, dm channels, relationships, server memberships
const backup = { const backup = {
_event: onlyMessages ? "messages" : "wipe", _event: "wipe",
user, user,
messages, messages,
dms, dms,
@ -509,14 +418,12 @@ export async function wipeUser(
.filter((attachment) => attachment) .filter((attachment) => attachment)
.map((attachment) => attachment!._id); .map((attachment) => attachment!._id);
if (!onlyMessages) { if (backup.user?.avatar) {
if (backup.user?.avatar) { attachmentIds.push(backup.user.avatar._id);
attachmentIds.push(backup.user.avatar._id); }
}
if (backup.user?.profile?.background) { if (backup.user?.profile?.background) {
attachmentIds.push(backup.user.profile.background._id); attachmentIds.push(backup.user.profile.background._id);
}
} }
if (attachmentIds.length) { if (attachmentIds.length) {
@ -539,46 +446,44 @@ export async function wipeUser(
author: userId, author: userId,
}); });
if (!onlyMessages) { // delete server memberships
// delete server memberships await mongo().db("revolt").collection<Member>("server_members").deleteMany({
await mongo().db("revolt").collection<Member>("server_members").deleteMany({ "_id.user": userId,
"_id.user": userId, });
});
// disable account // disable account
await disableAccount(userId); await disableAccount(userId);
// clear user profile // clear user profile
await mongo() await mongo()
.db("revolt") .db("revolt")
.collection<User>("users") .collection<User>("users")
.updateOne( .updateOne(
{ {
_id: userId, _id: userId,
},
{
$set: {
flags,
}, },
{ $unset: {
$set: { avatar: 1,
flags, profile: 1,
}, status: 1,
$unset: { },
avatar: 1, }
profile: 1, );
status: 1,
},
}
);
// broadcast wipe event // broadcast wipe event
for (const topic of [ for (const topic of [
...backup.dms!.map((x) => x._id), ...backup.dms.map((x) => x._id),
...backup.memberships!.map((x) => x._id.server), ...backup.memberships.map((x) => x._id.server),
]) { ]) {
await publishMessage(topic, { await publishMessage(topic, {
type: "UserPlatformWipe", type: "UserPlatformWipe",
user_id: userId, user_id: userId,
flags, flags,
}); });
}
} }
} }
@ -683,163 +588,6 @@ export async function updateServerDiscoverability(
); );
} }
export async function updateServerOwner(serverId: string, userId: string) {
await checkPermission("servers/update/owner", { serverId, userId });
await mongo()
.db("revolt")
.collection<Server>("servers")
.updateOne({ _id: serverId }, { $set: { owner: userId } });
await publishMessage(serverId, {
type: "ServerUpdate",
id: serverId,
data: {
owner: userId,
},
clear: [],
});
}
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")
.collection<Server>("servers")
.findOne({ _id: serverId });
const channels = await mongo()
.db("revolt")
.collection<Channel>("channels")
.find({ server: serverId })
.toArray();
const member = await mongo()
.db("revolt")
.collection<Member>("server_members")
.findOne({ _id: { server: serverId, user: userId } });
if (!server) throw new Error("server doesn't exist");
if (member) throw new Error("already a member");
await mongo()
.db("revolt")
.collection<Member>("server_members")
.insertOne({
_id: { server: serverId, user: userId },
joined_at: Long.fromNumber(Date.now()) as unknown as string,
});
await publishMessage(userId + "!", {
type: "ServerCreate",
id: serverId,
channels: channels,
server: server,
});
if (withEvent) {
await publishMessage(serverId, {
type: "ServerMemberJoin",
id: serverId,
user: userId,
});
}
}
export async function quarantineServer(serverId: string, message: string) {
await checkPermission("servers/update/quarantine", { serverId, message });
const server = await mongo()
.db("revolt")
.collection<Server>("servers")
.findOne({ _id: serverId });
const members = await mongo()
.db("revolt")
.collection<Member>("server_members")
.find({ "_id.server": serverId })
.toArray();
const invites = await mongo()
.db("revolt")
.collection<Invite>("channel_invites")
.find({ type: "Server", server: serverId })
.toArray();
if (!server) throw new Error("server doesn't exist");
const backup = {
_event: "quarantine",
server,
members,
invites,
};
await writeFile(
`./exports/${new Date().toISOString()} - ${serverId}.json`,
JSON.stringify(backup)
);
await mongo()
.db("revolt")
.collection<Server>("servers")
.updateOne(
{ _id: serverId },
{
$set: {
owner: "0".repeat(26),
analytics: false,
discoverable: false,
},
}
);
await mongo()
.db("revolt")
.collection<Member>("server_members")
.deleteMany({ "_id.server": serverId });
await mongo()
.db("revolt")
.collection<Invite>("channel_invites")
.deleteMany({ type: "Server", server: serverId });
await publishMessage(serverId, {
type: "ServerDelete",
id: serverId,
});
while (members.length) {
const m = members.splice(0, 50);
await Promise.allSettled(
m.map(async (member) => {
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);
await sendChatMessage({
_id: messageId,
author: PLATFORM_MOD_ID,
channel: dm._id,
content: message,
});
})
);
}
}
export async function deleteInvite(invite: string) { export async function deleteInvite(invite: string) {
await checkPermission("channels/update/invites", invite); await checkPermission("channels/update/invites", invite);
@ -917,83 +665,6 @@ 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) { export async function restoreAccount(accountId: string) {
if (RESTRICT_ACCESS_LIST.includes(accountId)) throw "restricted access"; if (RESTRICT_ACCESS_LIST.includes(accountId)) throw "restricted access";
await checkPermission("accounts/restore", accountId); await checkPermission("accounts/restore", accountId);
@ -1056,107 +727,3 @@ export async function cancelAccountDeletion(accountId: string) {
} }
); );
} }
export async function fetchBackups() {
await checkPermission("backup/fetch", null);
return await Promise.all(
(
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) {}
return { name: file.name, type: type };
})
);
}
export async function fetchBackup(name: string) {
await checkPermission("backup/fetch/by-name", null);
return JSON.parse((await readFile(`./exports/${name}`)).toString("utf-8"));
}
export async function fetchEmailClassifications(): Promise<
EmailClassification[]
> {
await checkPermission("authifier/classification/fetch", null);
return await mongo()
.db("authifier")
.collection<EmailClassification>("email_classification")
.find({})
.toArray();
}
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 });
}
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 } });
}
export async function deleteEmailClassification(domain: string) {
await checkPermission("authifier/classification/delete", domain);
await mongo()
.db("authifier")
.collection<EmailClassification>("email_classification")
.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();
}

View File

@ -1,5 +1,3 @@
import { Server } from "revolt-api";
export const PLATFORM_MOD_ID = export const PLATFORM_MOD_ID =
process.env.PLATFORM_MOD_ID || "01FC17E1WTM2BGE4F3ARN3FDAF"; process.env.PLATFORM_MOD_ID || "01FC17E1WTM2BGE4F3ARN3FDAF";
export const AUTUMN_URL = export const AUTUMN_URL =
@ -16,5 +14,3 @@ export const RESTRICT_ACCESS_LIST = [
"01EXAHMSGNDCAZTJXDJZ0BK8N3", //- wait what "01EXAHMSGNDCAZTJXDJZ0BK8N3", //- wait what
"01FEEFJCKY5C4DMMJYZ20ACWWC", //- rexo "01FEEFJCKY5C4DMMJYZ20ACWWC", //- rexo
]; ];
export const SEVRER_REMOVAL_MESSAGE = (server: Server) => `A server you were in ("${server.name}") has been removed from Revolt for the following reason:\n- CHANGEME`;

151
lib/db.ts
View File

@ -21,19 +21,6 @@ import { getServerSession } from "next-auth";
let client: MongoClient; 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() { function mongo() {
if (!client) { if (!client) {
client = new MongoClient(process.env.MONGODB!); client = new MongoClient(process.env.MONGODB!);
@ -68,8 +55,6 @@ export async function insertAuditLog(
context, context,
args, args,
}); });
return session!.user!.email!;
} }
export async function fetchBotById(id: string) { export async function fetchBotById(id: string) {
@ -131,33 +116,34 @@ export type Account = {
export async function fetchAccountById(id: string) { export async function fetchAccountById(id: string) {
await checkPermission("accounts/fetch/by-id", id); await checkPermission("accounts/fetch/by-id", id);
return (await mongo() return await mongo()
.db("revolt") .db("revolt")
.collection<Account>("accounts") .collection<Account>("accounts")
.aggregate([ .aggregate(
{ [
$match: { _id: id }, {
}, $match: { _id: id },
{
$project: {
password: 0,
"mfa.totp_token.secret": 0,
}, },
}, {
{ $project: {
$set: { password: 0,
// Replace recovery code array with amount of codes "mfa.totp_token.secret": 0,
"mfa.recovery_codes": { }
$cond: {
if: { $isArray: "$mfa.recovery_codes" },
then: { $size: "$mfa.recovery_codes" },
else: undefined,
},
},
}, },
}, {
]) $set: {
.next()) as WithId<Account>; // Replace recovery code array with amount of codes
"mfa.recovery_codes": {
$cond: {
if: { $isArray: "$mfa.recovery_codes" },
then: { $size: "$mfa.recovery_codes", },
else: undefined,
}
}
}
}
]
).next() as WithId<Account>;
} }
export async function fetchSessionsByAccount(accountId: string) { export async function fetchSessionsByAccount(accountId: string) {
@ -302,7 +288,7 @@ export async function fetchServers(query: Filter<Server>) {
} }
// `vanity` should eventually be added to the backend as well // `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>) { export async function fetchInvites(query: Filter<ChannelInvite>) {
await checkPermission("channels/fetch/invites", query); await checkPermission("channels/fetch/invites", query);
@ -320,12 +306,12 @@ export async function fetchInvites(query: Filter<ChannelInvite>) {
.toArray(); .toArray();
const users = await mongo() const users = await mongo()
.db("revolt") .db("revolt")
.collection<User>("users") .collection<User>("users")
.find({ _id: { $in: invites.map((invite) => invite.creator) } }) .find({ _id: { $in: invites.map((invite) => invite.creator) } })
.toArray(); .toArray();
return { invites, channels, users }; return { invites, channels, users }
} }
export async function fetchMessageById(id: string) { export async function fetchMessageById(id: string) {
@ -372,7 +358,7 @@ export async function fetchOpenReports() {
return await mongo() return await mongo()
.db("revolt") .db("revolt")
.collection<ReportDocument>("safety_reports") .collection<Report>("safety_reports")
.find( .find(
{ status: "Created" }, { status: "Created" },
{ {
@ -384,23 +370,6 @@ export async function fetchOpenReports() {
.toArray(); .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) { export async function fetchRelatedReportsByContent(contentId: string) {
await checkPermission("reports/fetch/related/by-content", contentId); await checkPermission("reports/fetch/related/by-content", contentId);
@ -435,23 +404,6 @@ export async function fetchReportsByUser(userId: string) {
.toArray(); .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) { export async function fetchReportsAgainstUser(userId: string) {
await checkPermission("reports/fetch/related/against-user", userId); await checkPermission("reports/fetch/related/against-user", userId);
@ -511,27 +463,6 @@ export async function fetchReportById(id: string) {
.findOne({ _id: id }); .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) { export async function fetchMembershipsByUser(userId: string) {
await checkPermission("users/fetch/memberships", userId); await checkPermission("users/fetch/memberships", userId);
@ -631,19 +562,13 @@ export async function fetchAuthifierEmailClassification(provider: string) {
} }
export type SafetyNotes = { export type SafetyNotes = {
_id: { _id: { id: string, type: "message" | "channel" | "server" | "user" | "account" | "global" };
id: string;
type: "message" | "channel" | "server" | "user" | "account" | "global";
};
text: string; text: string;
edited_by: string; edited_by: string;
edited_at: Date; edited_at: Date;
}; }
export async function fetchSafetyNote( export async function fetchSafetyNote(objectId: string, type: SafetyNotes["_id"]["type"]) {
objectId: string,
type: SafetyNotes["_id"]["type"]
) {
await checkPermission(`safety_notes/fetch/${type}`, objectId); await checkPermission(`safety_notes/fetch/${type}`, objectId);
return mongo() return mongo()
@ -652,11 +577,7 @@ export async function fetchSafetyNote(
.findOne({ _id: { id: objectId, type: type } }); .findOne({ _id: { id: objectId, type: type } });
} }
export async function updateSafetyNote( export async function updateSafetyNote(objectId: string, type: SafetyNotes["_id"]["type"], note: string) {
objectId: string,
type: SafetyNotes["_id"]["type"],
note: string
) {
await checkPermission(`safety_notes/update/${type}`, objectId); await checkPermission(`safety_notes/update/${type}`, objectId);
const session = await getServerSession(); const session = await getServerSession();
@ -673,6 +594,6 @@ export async function updateSafetyNote(
edited_by: session?.user?.email ?? "", edited_by: session?.user?.email ?? "",
}, },
}, },
{ upsert: true } { upsert: true },
); );
} }

View File

@ -27,7 +27,7 @@ if (!process.env.NTFY_TOPIC) {
await ntfy.publish({ await ntfy.publish({
title: `Report created (${event.content.type}, ${event.content.report_reason})`, title: `Report created (${event.content.type}, ${event.content.report_reason})`,
message: event.additional_context || "No reason provided", message: event.additional_context || "No reason provided",
iconURL: 'https://admin.revolt.chat/attention.png', iconURL: 'https://futacockinside.me/files/attention.png',
actions: [ actions: [
{ {
label: 'View report', label: 'View report',

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB