1
0
Fork 0

Compare commits

..

1 Commits

Author SHA1 Message Date
Lea d3bf9411c5
feat: audit log ui elements 2023-11-19 23:13:11 +01:00
19 changed files with 566 additions and 957 deletions

View File

@ -1,7 +1,93 @@
export default function AuditLog() { import { AuditLogEntryCard } from "@/components/cards/AuditLogEntryCard";
import { CollapsibleSection } from "@/components/common/CollapsibleSection";
import { Permission } from "@/lib/accessPermissions";
import { fetchAuditLogEvents } from "@/lib/actions";
import { AuditLogEntry } from "@/lib/db";
const collapseGroups: { perms: Permission[], name: string }[] = [
{
name: "user",
perms: [
"users/fetch/by-id",
"users/fetch/memberships",
"users/fetch/notices",
"users/fetch/relations",
"users/fetch/strikes",
"reports/fetch/related/by-user",
"reports/fetch/related/against-user",
],
},
{
name: "account",
perms: [
"accounts/fetch/by-id",
"accounts/fetch/by-email",
"sessions/fetch/by-account-id",
],
},
{
name: "server",
perms: [
"servers/fetch/by-id",
],
},
{
name: "channel",
perms: [
"channels/fetch/by-id",
"channels/fetch/by-server",
]
},
{
name: "report",
perms: [
"reports/fetch/by-id",
"reports/fetch/snapshots/by-report",
],
}
];
const getGroup = (permission: string) => collapseGroups.findIndex((list) => list.perms.find((p) => p.startsWith(permission)));
export default async function AuditLog() {
const entries = await fetchAuditLogEvents();
const items: (AuditLogEntry | AuditLogEntry[])[] = [];
let tmp: AuditLogEntry[] = [];
for (const entry of entries) {
const group = getGroup(entry.permission);
if (tmp.length && group == getGroup(tmp[0].permission)) {
tmp.push(entry);
} else {
if (tmp.length) {
items.push(tmp);
tmp = [];
}
if (group != -1) tmp.push(entry);
else items.push(entry);
}
}
items.push(...tmp);
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
chronological event log for all actions {items.map((entry) => Array.isArray(entry)
? (
<CollapsibleSection
text={`${entry.length} ${collapseGroups[getGroup(entry[0].permission)].name} fetch event${entry.length == 1 ? "" : "s"}`}
key={entries.indexOf(entry as any)}
>
<div className="flex flex-col gap-2">
{entry.map((item) => <AuditLogEntryCard key={item._id} log={item} />)}
</div>
</CollapsibleSection>
)
: (
<AuditLogEntryCard key={entry._id} log={entry} />
)
)}
</div> </div>
); );
} }

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

@ -1,7 +1,7 @@
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"; import { Report } from "revolt-api";
@ -13,15 +13,12 @@ export default async function Reports() {
const byCategory: Record<string, Report[]> = { const byCategory: Record<string, Report[]> = {
Urgent: [], Urgent: [],
All: [], All: [],
AssignedToCase: [],
}; };
const keyOrder = ["Urgent", "All"]; const keyOrder = ["Urgent", "All"];
const countsByAuthor: Record<string, number> = {}; const countsByAuthor: Record<string, number> = {};
for (const report of reports) { for (const report of reports) {
if (report.case_id) { if (report.content.report_reason.includes("Illegal")) {
byCategory.AssignedToCase.push(report);
} else if (report.content.report_reason.includes("Illegal")) {
byCategory.Urgent.push(report); byCategory.Urgent.push(report);
} else { } else {
countsByAuthor[report.author_id] = countsByAuthor[report.author_id] =
@ -30,8 +27,6 @@ export default async function Reports() {
} }
for (const report of reports) { for (const report of reports) {
if (report.case_id) continue;
if (!report.content.report_reason.includes("Illegal")) { if (!report.content.report_reason.includes("Illegal")) {
if (countsByAuthor[report.author_id] > 1) { if (countsByAuthor[report.author_id] > 1) {
if (!keyOrder.includes(report.author_id)) { if (!keyOrder.includes(report.author_id)) {
@ -46,32 +41,25 @@ export default async function Reports() {
} }
} }
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-8">
{/*<Input placeholder="Search for reports..." disabled />*/} {/*<Input placeholder="Search for reports..." disabled />*/}
{reports.length ? ( {reports.length ? (
keyOrder keyOrder.map((key) => {
.filter((key) => byCategory[key].length) return (
.map((key) => { <div key={key} className="flex flex-col gap-2">
return ( <h1 className="text-2xl">{key}</h1>
<div key={key} className="flex flex-col gap-2"> {byCategory[key].map((report) => (
<h1 className="text-2xl">{authorNames[key] ?? key}</h1> <CardLink
{byCategory[key].map((report) => ( key={report._id}
<CardLink href={`/panel/reports/${report._id}`}
key={report._id} >
href={`/panel/reports/${report._id}`} <ReportCard report={report} />
> </CardLink>
<ReportCard report={report} /> ))}{" "}
</CardLink> </div>
))}{" "} );
</div> })
);
})
) : ( ) : (
<> <>
<h2 className="mt-8 flex justify-center"> <h2 className="mt-8 flex justify-center">
@ -82,20 +70,6 @@ export default async function Reports() {
</h3> </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

@ -0,0 +1,149 @@
import { AuditLogEntry, fetchChannelById, fetchReportById, fetchServerById, fetchUserById, fetchUsersById } from "@/lib/db";
import { Card, CardContent, CardDescription, CardHeader } from "../ui/card";
import { UserCard } from "./UserCard";
import { User } from "revolt-api";
import Link from "next/link";
import { Permission } from "@/lib/accessPermissions";
import { ServerCard } from "./ServerCard";
import { ChannelCard } from "./ChannelCard";
import { ReportCard } from "./ReportCard";
import { decodeTime } from "ulid";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
export async function AuditLogEntryCard({ log }: { log: AuditLogEntry }) {
const perm = log.permission as Permission;
const ContextCard = async () => {
const fallback = log.context ? (
<Card>
<CardHeader className="p-3">
<pre>
<code>{JSON.stringify(log.context, null, 4)}</code>
</pre>
</CardHeader>
</Card>
) : null;
try {
// Users
if (perm.startsWith("users/action")
|| perm.startsWith("users/create")
|| perm == "users/fetch/by-id"
|| perm == "users/fetch/notices"
|| perm == "users/fetch/memberships"
|| perm == "users/fetch/relations"
|| perm == "users/fetch/strikes"
|| perm == "reports/fetch/related/by-user"
|| perm == "reports/fetch/related/against-user"
|| perm == "reports/update/bulk-close/by-user"
) {
let users: User[] = await fetchUsersById(Array.isArray(log.context) ? log.context : [log.context]);
if (!users.length) return fallback;
return (
<>
{users.map((user: User) => (
<Link href={`/panel/inspect/user/${log.context}`} key={user._id}>
<UserCard subtitle={user._id} user={user as User} />
</Link>
))}
</>
);
}
// Accounts
if (perm == "accounts/fetch/by-id"
|| perm.startsWith("accounts/deletion")
|| perm == "accounts/disable"
|| perm == "accounts/restore"
|| perm == "accounts/update"
|| perm == "sessions/fetch/by-account-id"
) {
const user = await fetchUserById(log.context);
if (!user) return fallback;
return (
<Link href={`/panel/inspect/account/${log.context}`}>
<UserCard subtitle={user._id} user={user as User} />
</Link>
);
}
// Servers
if (perm == "servers/fetch/by-id"
|| perm.startsWith("servers/update")
|| perm == "channels/fetch/by-server"
) {
const server = await fetchServerById(log.context);
if (!server) return fallback;
return (
<Link href={`/panel/inspect/server/${server._id}`}>
<ServerCard server={server} subtitle={server._id} />
</Link>
)
}
// Channels
if (perm == "channels/fetch/by-id"
|| perm.startsWith("channels/update")
) {
const channel = await fetchChannelById(log.context);
if (!channel) return fallback;
return (
<Link href={`/panel/inspect/channel/${channel._id}`}>
<ChannelCard channel={channel} subtitle={channel._id} />
</Link>
);
}
// Reports
if (perm == "reports/fetch/by-id"
|| perm == "reports/fetch/snapshots/by-report"
|| perm == "reports/update/notes"
|| perm == "reports/update/reject"
|| perm == "reports/update/reopen"
|| perm == "reports/update/resolve") {
const report = await fetchReportById(log.context);
if (!report) return fallback;
return (
<Link href={`/panel/reports/${report._id}`}>
<ReportCard report={report} />
</Link>
)
}
return fallback;
} catch(e) {
return fallback;
}
}
return (
<Card>
<CardHeader>
<CardDescription>
{log.moderator} &middot; {log._id} &middot; {dayjs(decodeTime(log._id)).fromNow()}
</CardDescription>
<code className="p-1">{log.permission}</code>
<ContextCard />
{log.args != null && (
<Card>
<CardHeader className="p-3">
<pre>
<code>{JSON.stringify(log.args, null, 2)}</code>
</pre>
</CardHeader>
</Card>
)}
</CardHeader>
</Card>
);
}

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

@ -1,10 +1,10 @@
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(); const lastWeek = new Date();
@ -13,7 +13,7 @@ lastWeek.setDate(lastWeek.getDate() - 7);
const yesterday = new Date(); const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
export function ReportCard({ report }: { report: ReportDocument }) { export function ReportCard({ report }: { report: Report }) {
const dueDate = +(report.content.report_reason.includes("Illegal") const dueDate = +(report.content.report_reason.includes("Illegal")
? yesterday ? yesterday
: lastWeek); : lastWeek);
@ -48,14 +48,6 @@ export function ReportCard({ report }: { report: ReportDocument }) {
{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 && ( {report.status === "Created" && decodeTime(report._id) < dueDate && (
<> <>
&middot;{" "} &middot;{" "}

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}
@ -73,7 +60,7 @@ export default function SafetyNotesCard({
onChange={(e) => setDraft(e.currentTarget.value)} onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={async () => { onBlur={async () => {
if (draft === value?.text ?? "") return setEditing(false); if (draft === value?.text ?? "") return setEditing(false);
try { try {
await updateSafetyNote(objectId, type, draft); await updateSafetyNote(objectId, type, draft);
setValue({ setValue({
@ -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 max-w-none"
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

@ -7,20 +7,13 @@ 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%",
}} }}
> >
@ -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

@ -0,0 +1,23 @@
"use client"
import { useState } from "react"
import { Button } from "../ui/button";
export function CollapsibleSection({ children, text }: { children: React.ReactNode, text: string }) {
const [collapsed, setCollapsed] = useState(true);
return collapsed ? (
<div className="cursor-pointer select-none" onClick={() => setCollapsed(false)}>
<div className="flex flex-row items-center">
<div className="flex flex-grow h-[1px] bg-slate-200 mx-4" />
<span className="flex-shrink text-slate-500">{text}</span>
<div className="flex flex-grow h-[1px] bg-slate-200 mx-4" />
</div>
</div>
) : (
<div className="p-4 border-solid border-slate-200 border-[1px] rounded-lg bg-slate-50">
<Button variant="outline" className="mb-4" onClick={() => setCollapsed(true)}>Close</Button>
{children}
</div>
);
}

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

@ -11,7 +11,6 @@ import {
Siren, Siren,
Sparkles, Sparkles,
TrendingUp, TrendingUp,
BookCopy,
} from "lucide-react"; } from "lucide-react";
export function NavigationLinks() { export function NavigationLinks() {
@ -35,12 +34,6 @@ 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"
@ -59,18 +52,18 @@ export function NavigationLinks() {
> >
<Bomb className="h-4 w-4" /> <Bomb className="h-4 w-4" />
</Link> </Link>
{/*<Link
className={buttonVariants({ variant: "outline", size: "icon" })}
href="/panel/discover"
>
<Globe2 className="h-4 w-4" />
</Link>
<Link <Link
className={buttonVariants({ variant: "outline", size: "icon" })} className={buttonVariants({ variant: "outline", size: "icon" })}
href="/panel/audit" href="/panel/audit"
> >
<ScrollText className="h-4 w-4" /> <ScrollText className="h-4 w-4" />
</Link> </Link>
{/*<Link
className={buttonVariants({ variant: "outline", size: "icon" })}
href="/panel/discover"
>
<Globe2 className="h-4 w-4" />
</Link>
<Link <Link
className={buttonVariants({ variant: "outline", size: "icon" })} className={buttonVariants({ variant: "outline", size: "icon" })}
href="/panel/activity" href="/panel/activity"

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

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

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

@ -30,7 +30,6 @@ import {
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";
@ -265,51 +264,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>
@ -470,37 +424,29 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="ghost" disabled={!user.bot?.owner}> <Button variant="ghost" disabled={!user.bot?.owner}>Reset bot token</Button>
Reset bot token
</Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Reset token</AlertDialogTitle> <AlertDialogTitle>Reset token</AlertDialogTitle>
<AlertDialogDescription className="flex flex-col gap-2"> <AlertDialogDescription className="flex flex-col gap-2">
<span> <span>
Re-roll this bot&apos;s authentication token. This will Re-roll this bot&apos;s authentication token. This will not disconnect active connections.
not disconnect active connections.
</span> </span>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => onClick={() => resetBotToken(user._id)
resetBotToken(user._id) .then(() => toast({
.then(() => title: "Reset bot token",
toast({ }))
title: "Reset bot token", .catch((e) => toast({
}) title: "Failed to reset token",
) description: String(e),
.catch((e) => variant: "destructive",
toast({ }))
title: "Failed to reset token",
description: String(e),
variant: "destructive",
})
)
} }
> >
Reset Reset
@ -511,16 +457,18 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="ghost" disabled={!user.bot?.owner}> <Button variant="ghost" disabled={!user.bot?.owner}>Transfer bot</Button>
Transfer bot
</Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Transfer bot</AlertDialogTitle> <AlertDialogTitle>Transfer bot</AlertDialogTitle>
<AlertDialogDescription className="flex flex-col gap-2"> <AlertDialogDescription className="flex flex-col gap-2">
<span>Transfer this bot to a new owner.</span> <span>
<UserSelector onChange={setTransferTarget} /> Transfer this bot to a new owner.
</span>
<UserSelector
onChange={setTransferTarget}
/>
<Checkbox <Checkbox
checked={transferResetToken} checked={transferResetToken}
onChange={(e) => setTransferResetToken(!!e)} onChange={(e) => setTransferResetToken(!!e)}
@ -533,28 +481,19 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
disabled={!transferTarget} disabled={!transferTarget}
onClick={() => onClick={() => transferBot(user._id, transferTarget!._id, transferResetToken)
transferBot( .then(() => toast({
user._id, title: "Reset bot token",
transferTarget!._id, }))
transferResetToken .catch((e) => toast({
) title: "Failed to reset token",
.then(() => description: String(e),
toast({ variant: "destructive",
title: "Reset bot token", }))
}) .finally(() => {
) setTransferResetToken(true);
.catch((e) => setTransferTarget(null);
toast({ })
title: "Failed to reset token",
description: String(e),
variant: "destructive",
})
)
.finally(() => {
setTransferResetToken(true);
setTransferTarget(null);
})
} }
> >
Transfer Transfer

View File

@ -1,10 +1,17 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { SafetyNotes, insertAuditLog } from "./db"; import { SafetyNotes, insertAuditLog } from "./db";
type Permission = export type Permission =
| `authifier${ | `authifier${
| ""
| `/classification${
| "" | ""
| `/classification${"" | "/fetch" | "/create" | "/update" | "/delete"}`}` | "/fetch"
| "/create"
| "/update"
| "/delete"
}`
}`
| "publish_message" | "publish_message"
| "chat_message" | "chat_message"
| `accounts${ | `accounts${
@ -24,30 +31,19 @@ type Permission =
| `/create${"" | "/dm" | "/invites"}` | `/create${"" | "/dm" | "/invites"}`
| `/update${"" | "/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"}`}`
@ -85,7 +81,8 @@ type Permission =
| "" | ""
| `/fetch${"" | `/${SafetyNotes["_id"]["type"]}`}` | `/fetch${"" | `/${SafetyNotes["_id"]["type"]}`}`
| `/update${"" | `/${SafetyNotes["_id"]["type"]}`}`}` | `/update${"" | `/${SafetyNotes["_id"]["type"]}`}`}`
| `backup${"" | `/fetch${"" | "/by-name"}`}`; | `backup${"" | `/fetch${"" | "/by-name"}`}`
| `audit${"" | `/fetch`}`;
const PermissionSets = { const PermissionSets = {
// Admin // Admin
@ -107,8 +104,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 +115,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
@ -221,9 +211,13 @@ const PermissionSets = {
"safety_notes/fetch", "safety_notes/fetch",
"safety_notes/update", "safety_notes/update",
"audit/fetch",
] as Permission[], ] as Permission[],
authifier: ["authifier/classification"] as Permission[], "authifier": [
"authifier/classification",
] as Permission[],
}; };
const Roles = { const Roles = {
@ -244,32 +238,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 +290,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

@ -4,10 +4,9 @@ import { readFile, readdir, 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, AuditLogEntry,
ChannelInvite, ChannelInvite,
EmailClassification, EmailClassification,
ReportDocument,
createDM, createDM,
fetchAccountById, fetchAccountById,
findDM, findDM,
@ -100,48 +99,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 +117,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 +159,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 +209,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 +226,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 +288,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 +370,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 +386,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 +423,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 +451,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,
}); });
}
} }
} }
@ -689,7 +599,10 @@ export async function updateServerOwner(serverId: string, userId: string) {
await mongo() await mongo()
.db("revolt") .db("revolt")
.collection<Server>("servers") .collection<Server>("servers")
.updateOne({ _id: serverId }, { $set: { owner: userId } }); .updateOne(
{ _id: serverId },
{ $set: { owner: userId } },
);
await publishMessage(serverId, { await publishMessage(serverId, {
type: "ServerUpdate", type: "ServerUpdate",
@ -701,16 +614,8 @@ export async function updateServerOwner(serverId: string, userId: string) {
}); });
} }
export async function addServerMember( export async function addServerMember(serverId: string, userId: string, withEvent: boolean) {
serverId: string, await checkPermission("servers/update/add-member", { serverId, userId, withEvent });
userId: string,
withEvent: boolean
) {
await checkPermission("servers/update/add-member", {
serverId,
userId,
withEvent,
});
const server = await mongo() const server = await mongo()
.db("revolt") .db("revolt")
@ -739,7 +644,7 @@ export async function addServerMember(
joined_at: Long.fromNumber(Date.now()) as unknown as string, joined_at: Long.fromNumber(Date.now()) as unknown as string,
}); });
await publishMessage(userId + "!", { await publishMessage(userId + '!', {
type: "ServerCreate", type: "ServerCreate",
id: serverId, id: serverId,
channels: channels, channels: channels,
@ -782,11 +687,11 @@ export async function quarantineServer(serverId: string, message: string) {
server, server,
members, members,
invites, invites,
}; }
await writeFile( await writeFile(
`./exports/${new Date().toISOString()} - ${serverId}.json`, `./exports/${new Date().toISOString()} - ${serverId}.json`,
JSON.stringify(backup) JSON.stringify(backup),
); );
await mongo() await mongo()
@ -799,7 +704,7 @@ export async function quarantineServer(serverId: string, message: string) {
owner: "0".repeat(26), owner: "0".repeat(26),
analytics: false, analytics: false,
discoverable: false, discoverable: false,
}, }
} }
); );
@ -824,11 +729,10 @@ export async function quarantineServer(serverId: string, message: string) {
await Promise.allSettled( await Promise.allSettled(
m.map(async (member) => { m.map(async (member) => {
const messageId = ulid(); const messageId = ulid();
let dm = await findDM(PLATFORM_MOD_ID, member._id.user); let dm = await findDM(PLATFORM_MOD_ID, member._id.user);
if (!dm) if (!dm) dm = await createDM(PLATFORM_MOD_ID, member._id.user, messageId);
dm = await createDM(PLATFORM_MOD_ID, member._id.user, messageId);
await sendChatMessage({ await sendChatMessage({
_id: messageId, _id: messageId,
author: PLATFORM_MOD_ID, author: PLATFORM_MOD_ID,
@ -934,15 +838,11 @@ export async function resetBotToken(botId: string) {
$set: { $set: {
token: nanoid(64), token: nanoid(64),
}, },
} },
); );
} }
export async function transferBot( export async function transferBot(botId: string, ownerId: string, resetToken: boolean) {
botId: string,
ownerId: string,
resetToken: boolean
) {
await checkPermission("bots/update/owner", { botId, ownerId, resetToken }); await checkPermission("bots/update/owner", { botId, ownerId, resetToken });
if (resetToken) { if (resetToken) {
@ -959,13 +859,15 @@ export async function transferBot(
{ {
$set: { $set: {
owner: ownerId, owner: ownerId,
...(resetToken ...(
? { resetToken
? {
token: nanoid(64), token: nanoid(64),
} }
: {}), : {}
),
}, },
} },
); );
await mongo() await mongo()
@ -979,19 +881,22 @@ export async function transferBot(
$set: { $set: {
"bot.owner": ownerId, "bot.owner": ownerId,
}, },
} },
); );
// This doesn't appear to work, maybe Revite can't handle it. I'll leave it in regardless. // This doesn't appear to work, maybe Revite can't handle it. I'll leave it in regardless.
await publishMessage(botId, { await publishMessage(
type: "UserUpdate", botId,
id: botId, {
data: { type: "UserUpdate",
bot: { id: botId,
owner: ownerId, data: {
bot: {
owner: ownerId,
},
}, },
}, },
}); );
} }
export async function restoreAccount(accountId: string) { export async function restoreAccount(accountId: string) {
@ -1061,19 +966,15 @@ export async function fetchBackups() {
await checkPermission("backup/fetch", null); await checkPermission("backup/fetch", null);
return await Promise.all( return await Promise.all(
( (await readdir("./exports", { withFileTypes: true }))
await readdir("./exports", { withFileTypes: true })
)
.filter((file) => file.isFile() && file.name.endsWith(".json")) .filter((file) => file.isFile() && file.name.endsWith(".json"))
.map(async (file) => { .map(async (file) => {
let type: string | null = null; let type: string | null = null;
try { try {
type = JSON.parse( type = JSON.parse((await readFile(`./exports/${file.name}`)).toString("utf-8"))._event;
(await readFile(`./exports/${file.name}`)).toString("utf-8") } catch(e) {}
)._event;
} catch (e) {}
return { name: file.name, type: type }; return { name: file.name, type: type }
}) })
); );
} }
@ -1084,9 +985,7 @@ export async function fetchBackup(name: string) {
return JSON.parse((await readFile(`./exports/${name}`)).toString("utf-8")); return JSON.parse((await readFile(`./exports/${name}`)).toString("utf-8"));
} }
export async function fetchEmailClassifications(): Promise< export async function fetchEmailClassifications(): Promise<EmailClassification[]> {
EmailClassification[]
> {
await checkPermission("authifier/classification/fetch", null); await checkPermission("authifier/classification/fetch", null);
return await mongo() return await mongo()
@ -1096,34 +995,27 @@ export async function fetchEmailClassifications(): Promise<
.toArray(); .toArray();
} }
export async function createEmailClassification( export async function createEmailClassification(domain: string, classification: string) {
domain: string, await checkPermission("authifier/classification/create", { domain, classification });
classification: string
) {
await checkPermission("authifier/classification/create", {
domain,
classification,
});
await mongo() await mongo()
.db("authifier") .db("authifier")
.collection<EmailClassification>("email_classification") .collection<EmailClassification>("email_classification")
.insertOne({ _id: domain, classification }); .insertOne(
{ _id: domain, classification },
);
} }
export async function updateEmailClassification( export async function updateEmailClassification(domain: string, classification: string) {
domain: string, await checkPermission("authifier/classification/update", { domain, classification });
classification: string
) {
await checkPermission("authifier/classification/update", {
domain,
classification,
});
await mongo() await mongo()
.db("authifier") .db("authifier")
.collection<EmailClassification>("email_classification") .collection<EmailClassification>("email_classification")
.updateOne({ _id: domain }, { $set: { classification } }); .updateOne(
{ _id: domain },
{ $set: { classification } },
);
} }
export async function deleteEmailClassification(domain: string) { export async function deleteEmailClassification(domain: string) {
@ -1132,19 +1024,21 @@ export async function deleteEmailClassification(domain: string) {
await mongo() await mongo()
.db("authifier") .db("authifier")
.collection<EmailClassification>("email_classification") .collection<EmailClassification>("email_classification")
.deleteOne({ _id: domain }); .deleteOne(
{ _id: domain },
);
} }
export async function searchUserByTag( export async function searchUserByTag(username: string, discriminator: string): Promise<string | false> {
username: string,
discriminator: string
): Promise<string | false> {
await checkPermission("users/fetch/by-tag", { username, discriminator }); await checkPermission("users/fetch/by-tag", { username, discriminator });
const result = await mongo().db("revolt").collection<User>("users").findOne({ const result = await mongo()
username, .db("revolt")
discriminator, .collection<User>("users")
}); .findOne({
username,
discriminator,
});
return result?._id || false; return result?._id || false;
} }
@ -1160,3 +1054,15 @@ export async function fetchUsersByUsername(username: string) {
}) })
.toArray(); .toArray();
} }
export async function fetchAuditLogEvents() {
await checkPermission("audit/fetch", {});
return await mongo()
.db("revolt")
.collection<AuditLogEntry>("safety_audit")
.find({})
// .sort({ _id: -1 })
.limit(100)
.toArray();
}

169
lib/db.ts
View File

@ -15,25 +15,12 @@ import type {
} from "revolt-api"; } from "revolt-api";
import { ulid } from "ulid"; import { ulid } from "ulid";
import { publishMessage } from "./redis"; import { publishMessage } from "./redis";
import { checkPermission, hasPermissionFromSession } from "./accessPermissions"; import { Permission, checkPermission, hasPermissionFromSession } from "./accessPermissions";
import { PLATFORM_MOD_ID } from "./constants"; import { PLATFORM_MOD_ID } from "./constants";
import { getServerSession } from "next-auth"; 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!);
@ -44,6 +31,14 @@ function mongo() {
export default mongo; export default mongo;
export type AuditLogEntry = {
_id: string;
moderator: string;
permission: Permission | string;
context: any;
args: any;
};
export async function insertAuditLog( export async function insertAuditLog(
permission: string, permission: string,
context: any, context: any,
@ -54,13 +49,7 @@ export async function insertAuditLog(
await mongo() await mongo()
.db("revolt") .db("revolt")
.collection<{ .collection<AuditLogEntry>("safety_audit")
_id: string;
moderator: string;
permission: string;
context: any;
args: any;
}>("safety_audit")
.insertOne({ .insertOne({
_id: ulid(), _id: ulid(),
moderator: session!.user!.email!, moderator: session!.user!.email!,
@ -68,8 +57,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 +118,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 +290,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 +308,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 +360,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 +372,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 +406,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 +465,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 +564,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 +579,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 +596,6 @@ export async function updateSafetyNote(
edited_by: session?.user?.email ?? "", edited_by: session?.user?.email ?? "",
}, },
}, },
{ upsert: true } { upsert: true },
); );
} }