diff --git a/packages/admin/src/components/sidebar/Sidebar.tsx b/packages/admin/src/components/sidebar/Sidebar.tsx
index 9e8dc2d6fc52e39117c1eea0f6bc4e00b6a8ec06..15a6eb570147b0769e9c39540565de781ce99282 100644
--- a/packages/admin/src/components/sidebar/Sidebar.tsx
+++ b/packages/admin/src/components/sidebar/Sidebar.tsx
@@ -9,6 +9,7 @@ import {
faCog,
faHashtag,
faHome,
+ faList,
faServer,
faShieldHalved,
faSquare,
@@ -54,6 +55,12 @@ export const SidebarWrapper = () => {
isActive={pathname === "/"}
href="/"
/>
+ }
+ isActive={pathname === "/audit"}
+ href="/audit"
+ />
}
title="Stats"
diff --git a/packages/admin/src/main.tsx b/packages/admin/src/main.tsx
index 2eff391f87dec41a54a879751ff0ca3b6b181604..b1a8e3bc7834e16ff00d4040598678c08c0c916d 100644
--- a/packages/admin/src/main.tsx
+++ b/packages/admin/src/main.tsx
@@ -9,6 +9,7 @@ import { HomePage } from "./pages/Home/page.tsx";
import { AccountsPage } from "./pages/Accounts/Accounts/page.tsx";
import { ServiceSettingsPage } from "./pages/Service/settings.tsx";
import { ToastContainer } from "react-toastify";
+import { AuditLog } from "./pages/AuditLog/auditlog.tsx";
const router = createBrowserRouter(
[
@@ -28,6 +29,10 @@ const router = createBrowserRouter(
path: "/service/settings",
element: ,
},
+ {
+ path: "/audit",
+ element: ,
+ },
],
},
],
diff --git a/packages/admin/src/pages/AuditLog/auditlog.tsx b/packages/admin/src/pages/AuditLog/auditlog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e2a2d652134d4bb282074d563f343f28c8b1519c
--- /dev/null
+++ b/packages/admin/src/pages/AuditLog/auditlog.tsx
@@ -0,0 +1,77 @@
+import { useEffect, useState } from "react";
+import { api, handleError } from "../../lib/utils";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableColumn,
+ TableHeader,
+ TableRow,
+} from "@nextui-org/react";
+
+type AuditLogAction = "BAN_CREATE" | "BAN_UPDATE" | "BAN_DELETE";
+
+type AuditLog = {
+ id: number;
+ userId: string;
+ action: AuditLogAction;
+ reason?: string;
+ comment?: string;
+
+ banId?: number;
+
+ createdAt: string;
+ updatedAt?: string;
+};
+
+export const AuditLog = () => {
+ const [auditLogs, setAuditLogs] = useState([]);
+
+ useEffect(() => {
+ api<{ auditLogs: AuditLog[] }>("/api/admin/audit", "GET").then(
+ ({ status, data }) => {
+ if (status === 200) {
+ if (data.success) {
+ setAuditLogs(data.auditLogs);
+ } else {
+ handleError(status, data);
+ }
+ } else {
+ handleError(status, data);
+ }
+ }
+ );
+ }, []);
+
+ return (
+ <>
+ Audit Log
+
+
+
+ ID
+ User ID
+ Action
+ Reason
+ Comment
+ Created At / Updated At
+
+
+ {auditLogs.map((log) => (
+
+ {log.id}
+ {log.userId}
+ {log.action}
+ {log.reason}
+ {log.comment}
+
+ {log.createdAt} / {log.updatedAt}
+
+
+ ))}
+
+
+
+ >
+ );
+};
diff --git a/packages/server/prisma/dbml/schema.dbml b/packages/server/prisma/dbml/schema.dbml
index aeaab2d0911aff02ad697a1f51b18b3cdd34478f..dc5146464f7b899034d2d1b84c753f773785a664 100644
--- a/packages/server/prisma/dbml/schema.dbml
+++ b/packages/server/prisma/dbml/schema.dbml
@@ -20,6 +20,7 @@ Table User {
pixels Pixel [not null]
FactionMember FactionMember [not null]
Ban Ban
+ AuditLog AuditLog [not null]
}
Table Instance {
@@ -111,8 +112,30 @@ Table Ban {
privateNote String
publicNote String
expiresAt DateTime [not null]
+ createdAt DateTime [default: `now()`, not null]
+ updatedAt DateTime
user User
instance Instance
+ AuditLog AuditLog [not null]
+}
+
+Table AuditLog {
+ id Int [pk, increment]
+ userId String
+ action AuditLogAction [not null]
+ reason String
+ comment String
+ banId Int
+ createdAt DateTime [default: `now()`, not null]
+ updatedAt DateTime
+ user User
+ ban Ban
+}
+
+Enum AuditLogAction {
+ BAN_CREATE
+ BAN_UPDATE
+ BAN_DELETE
}
Ref: Pixel.userId > User.sub
@@ -133,4 +156,8 @@ Ref: FactionSetting.factionId > Faction.id
Ref: Ban.userId - User.sub
-Ref: Ban.instanceId - Instance.id
\ No newline at end of file
+Ref: Ban.instanceId - Instance.id
+
+Ref: AuditLog.userId > User.sub
+
+Ref: AuditLog.banId > Ban.id
\ No newline at end of file
diff --git a/packages/server/prisma/migrations/20240707193624_add_audit_log_model/migration.sql b/packages/server/prisma/migrations/20240707193624_add_audit_log_model/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..f5c9f267d83076aa8da83c191fb17a237dcabe89
--- /dev/null
+++ b/packages/server/prisma/migrations/20240707193624_add_audit_log_model/migration.sql
@@ -0,0 +1,22 @@
+-- CreateEnum
+CREATE TYPE "AuditLogAction" AS ENUM ('BAN_CREATE', 'BAN_UPDATE', 'BAN_DELETE');
+
+-- CreateTable
+CREATE TABLE "AuditLog" (
+ "id" SERIAL NOT NULL,
+ "userId" TEXT,
+ "action" "AuditLogAction" NOT NULL,
+ "reason" TEXT,
+ "comment" TEXT,
+ "banId" INTEGER,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3),
+
+ CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("sub") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_banId_fkey" FOREIGN KEY ("banId") REFERENCES "Ban"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/server/prisma/migrations/20240707200036_add_dates_to_bans/migration.sql b/packages/server/prisma/migrations/20240707200036_add_dates_to_bans/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..eeef322545718804ab2dc161561bd183c3e6d807
--- /dev/null
+++ b/packages/server/prisma/migrations/20240707200036_add_dates_to_bans/migration.sql
@@ -0,0 +1,4 @@
+-- AlterTable
+ALTER TABLE "Ban" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ADD COLUMN "deletedAt" TIMESTAMP(3),
+ADD COLUMN "updatedAt" TIMESTAMP(3);
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index f1ff2084887b559c6c43b563f88ec76ca23b6e45..ec4cf74b9aad189fc194bbf57f6c04282e8938da 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -35,6 +35,7 @@ model User {
pixels Pixel[]
FactionMember FactionMember[]
Ban Ban?
+ AuditLog AuditLog[]
}
model Instance {
@@ -140,8 +141,32 @@ model Ban {
publicNote String?
expiresAt DateTime
- // TODO: link audit log
+ createdAt DateTime @default(now())
+ updatedAt DateTime?
+
+ user User? @relation(fields: [userId], references: [sub])
+ instance Instance? @relation(fields: [instanceId], references: [id])
+ AuditLog AuditLog[]
+}
+
+enum AuditLogAction {
+ BAN_CREATE
+ BAN_UPDATE
+ BAN_DELETE
+}
+
+model AuditLog {
+ id Int @id @default(autoincrement())
+ userId String?
+ action AuditLogAction
+ reason String?
+ comment String? // service comment
+
+ banId Int?
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime?
- user User? @relation(fields: [userId], references: [sub])
- instance Instance? @relation(fields: [instanceId], references: [id])
+ user User? @relation(fields: [userId], references: [sub])
+ ban Ban? @relation(fields: [banId], references: [id])
}
diff --git a/packages/server/src/api/admin.ts b/packages/server/src/api/admin.ts
index 7099cc08b7d05d88529a53c4fc05500b7e5cd4eb..72812a3f36a8088df9b79988c5b278e07d19dcac 100644
--- a/packages/server/src/api/admin.ts
+++ b/packages/server/src/api/admin.ts
@@ -1,5 +1,5 @@
import { Router } from "express";
-import { User, UserNotFound } from "../models/User";
+import { User, UserNotBanned, UserNotFound } from "../models/User";
import Canvas from "../lib/Canvas";
import { getLogger } from "../lib/Logger";
import { RateLimiter } from "../lib/RateLimiter";
@@ -10,6 +10,7 @@ import {
InstanceNotBanned,
InstanceNotFound,
} from "../models/Instance";
+import { AuditLog } from "../models/AuditLog";
const app = Router();
const Logger = getLogger("HTTP/ADMIN");
@@ -206,6 +207,15 @@ app.put("/canvas/fill", async (req, res) => {
res.json({ success: true });
});
+/**
+ * Create or ban a user
+ *
+ * @header X-Audit
+ * @param :sub User sub claim
+ * @body expiresAt string! ISO date time string
+ * @body publicNote string?
+ * @body privateNote string?
+ */
app.put("/user/:sub/ban", async (req, res) => {
let user: User;
let expires: Date;
@@ -270,23 +280,8 @@ app.put("/user/:sub/ban", async (req, res) => {
privateNote = req.body.privateNote;
}
- const existingBan = user.ban;
-
- const ban = await prisma.ban.upsert({
- where: { userId: user.sub },
- create: {
- userId: user.sub,
- expiresAt: expires,
- publicNote,
- privateNote,
- },
- update: {
- expiresAt: expires,
- publicNote,
- privateNote,
- },
- });
- await user.update(true);
+ const existingBan = user.getBan();
+ const ban = await user.ban(expires, publicNote, privateNote);
let shouldNotifyUser = false;
@@ -312,11 +307,27 @@ app.put("/user/:sub/ban", async (req, res) => {
user.updateStanding();
- // todo: audit log
-
- res.json({ success: true });
+ const adminUser = (await User.fromAuthSession(req.session.user!))!;
+ const audit = await AuditLog.Factory(adminUser.sub)
+ .doing(existingBan ? "BAN_UPDATE" : "BAN_CREATE")
+ .reason(req.header("X-Audit") || null)
+ .withComment(
+ existingBan
+ ? `Updated ban on ${user.sub}`
+ : `Created a ban for ${user.sub}`
+ )
+ .withBan(ban)
+ .create();
+
+ res.json({ success: true, audit });
});
+/**
+ * Delete a user ban
+ *
+ * @header X-Audit
+ * @param :sub User sub
+ */
app.delete("/user/:sub/ban", async (req, res) => {
// delete ban ("unban")
@@ -334,18 +345,20 @@ app.delete("/user/:sub/ban", async (req, res) => {
return;
}
- if (!user.ban?.id) {
- res.status(400).json({
- success: false,
- error: "User is not banned",
- });
+ try {
+ await user.unban();
+ } catch (e) {
+ if (e instanceof UserNotBanned) {
+ res.status(404).json({ success: false, error: "User is not banned" });
+ } else {
+ Logger.error(
+ `/instance/${req.params.sub}/ban Error ` + (e as any)?.message
+ );
+ res.status(500).json({ success: false, error: "Internal error" });
+ }
return;
}
- await prisma.ban.delete({
- where: { id: user.ban.id },
- });
-
user.notify({
is: "modal",
action: "moderation",
@@ -357,9 +370,14 @@ app.delete("/user/:sub/ban", async (req, res) => {
await user.update(true);
user.updateStanding();
- // todo: audit log
+ const adminUser = (await User.fromAuthSession(req.session.user!))!;
+ const audit = await AuditLog.Factory(adminUser.sub)
+ .doing("BAN_DELETE")
+ .reason(req.header("X-Audit") || null)
+ .withComment(`Deleted ban for ${user.sub}`)
+ .create();
- res.json({ success: true });
+ res.json({ success: true, audit });
});
app.get("/instance/:domain/ban", async (req, res) => {
@@ -392,6 +410,15 @@ app.get("/instance/:domain/ban", async (req, res) => {
res.json({ success: true, ban });
});
+/**
+ * Create or update a ban for an instance (and subdomains)
+ *
+ * @header X-Audit
+ * @param :domain Domain for the instance
+ * @body expiresAt string! ISO date time string
+ * @body publicNote string?
+ * @body privateNote string?
+ */
app.put("/instance/:domain/ban", async (req, res) => {
// ban domain & subdomains
@@ -460,15 +487,34 @@ app.put("/instance/:domain/ban", async (req, res) => {
privateNote = req.body.privateNote;
}
- await instance.ban(expires, publicNote, privateNote);
-
- // todo: audit log
+ const hasExistingBan = await instance.getBan();
+
+ const user = (await User.fromAuthSession(req.session.user!))!;
+ const ban = await instance.ban(expires, publicNote, privateNote);
+ const audit = await AuditLog.Factory(user.sub)
+ .doing(hasExistingBan ? "BAN_UPDATE" : "BAN_CREATE")
+ .reason(req.header("X-Audit") || null)
+ .withComment(
+ hasExistingBan
+ ? `Updated ban for ${instance.hostname}`
+ : `Created a ban for ${instance.hostname}`
+ )
+ .withBan(ban)
+ .create();
res.json({
success: true,
+ ban,
+ audit,
});
});
+/**
+ * Delete an instance ban
+ *
+ * @header X-Audit
+ * @param :domain The instance domain
+ */
app.delete("/instance/:domain/ban", async (req, res) => {
// unban domain & subdomains
@@ -488,8 +534,9 @@ app.delete("/instance/:domain/ban", async (req, res) => {
return;
}
+ let ban;
try {
- await instance.unban();
+ ban = await instance.unban();
} catch (e) {
if (e instanceof InstanceNotBanned) {
res.status(404).json({ success: false, error: "instance not banned" });
@@ -502,9 +549,104 @@ app.delete("/instance/:domain/ban", async (req, res) => {
return;
}
- // todo: audit log
+ const user = (await User.fromAuthSession(req.session.user!))!;
+ const audit = await AuditLog.Factory(user.sub)
+ .doing("BAN_DELETE")
+ .reason(req.header("X-Audit") || null)
+ .withComment(`Deleted ban for ${instance.hostname}`)
+ .create();
- res.json({ success: true });
+ res.json({ success: true, audit });
+});
+
+/**
+ * Get all audit logs
+ *
+ * TODO: pagination
+ */
+app.get("/audit", async (req, res) => {
+ const auditLogs = await prisma.auditLog.findMany({
+ orderBy: {
+ createdAt: "desc",
+ },
+ });
+
+ res.json({ success: true, auditLogs });
+});
+
+/**
+ * Get audit log entry by ID
+ *
+ * @param :id Audit log ID
+ */
+app.get("/audit/:id", async (req, res) => {
+ let id = parseInt(req.params.id);
+
+ if (isNaN(id)) {
+ return res
+ .status(400)
+ .json({ success: false, error: "id is not a number" });
+ }
+
+ const auditLog = await prisma.auditLog.findFirst({ where: { id } });
+
+ if (!auditLog) {
+ return res
+ .status(404)
+ .json({ success: false, error: "Audit log not found" });
+ }
+
+ res.json({ success: true, auditLog });
+});
+
+/**
+ * Update audit log reason
+ *
+ * @param :id Audit log id
+ * @body reason string|null
+ */
+app.put("/audit/:id/reason", async (req, res) => {
+ let id = parseInt(req.params.id);
+ let reason: string;
+
+ if (isNaN(id)) {
+ return res
+ .status(400)
+ .json({ success: false, error: "id is not a number" });
+ }
+
+ if (typeof req.body.reason !== "string" && req.body.reason !== null) {
+ return res
+ .status(400)
+ .json({ success: false, error: "reason is not a string or null" });
+ }
+
+ reason = req.body.reason;
+
+ const auditLog = await prisma.auditLog.findFirst({
+ where: {
+ id,
+ },
+ });
+
+ if (!auditLog) {
+ return res
+ .status(404)
+ .json({ success: false, error: "audit log is not found" });
+ }
+
+ const newAudit = await prisma.auditLog.update({
+ where: { id },
+ data: {
+ reason,
+ updatedAt: new Date(),
+ },
+ });
+
+ res.json({
+ success: true,
+ auditLog: newAudit,
+ });
});
export default app;
diff --git a/packages/server/src/lib/SocketServer.ts b/packages/server/src/lib/SocketServer.ts
index 987669ea7bcd5a3d024537c574b4bd1df8bb31ea..6163a8546a98e0a6600bf9a842e087bd0131b939 100644
--- a/packages/server/src/lib/SocketServer.ts
+++ b/packages/server/src/lib/SocketServer.ts
@@ -173,24 +173,25 @@ export class SocketServer {
if (socket.request.session.user) {
// inform the client of their session if it exists
socket.emit("user", socket.request.session.user);
+ }
+
+ if (user) {
+ socket.emit("availablePixels", user.pixelStack);
+ socket.emit("pixelLastPlaced", user.lastPixelTime.getTime());
+ const ban = user.getBan();
socket.emit(
"standing",
- user?.ban
+ ban
? {
banned: true,
- until: user.ban.expires.toISOString(),
- reason: user.ban.publicNote || undefined,
+ until: ban.expires.toISOString(),
+ reason: ban.publicNote || undefined,
}
: { banned: false }
);
}
- if (user) {
- socket.emit("availablePixels", user.pixelStack);
- socket.emit("pixelLastPlaced", user.lastPixelTime.getTime());
- }
-
socket.emit("config", getClientConfig());
{
let _clientNotifiedAboutCache = false;
@@ -261,7 +262,7 @@ export class SocketServer {
return;
}
- if (user.ban && user.ban.expires > new Date()) {
+ if ((user.getBan()?.expires || 0) > new Date()) {
ack({ success: false, error: "banned" });
return;
}
diff --git a/packages/server/src/lib/utils.ts b/packages/server/src/lib/utils.ts
index 30c31af2236d2d506d858602886e9cd3e6ec96f2..bf9fc21edc1149d91b09d599cb5a962ca56bb5df 100644
--- a/packages/server/src/lib/utils.ts
+++ b/packages/server/src/lib/utils.ts
@@ -14,3 +14,8 @@ export const createEnum = (values: T[]): { [k in T]: k } => {
return ret;
};
+
+export type ConditionalPromise<
+ T,
+ UsePromise extends boolean = false,
+> = UsePromise extends true ? Promise : UsePromise extends false ? T : never;
diff --git a/packages/server/src/models/AuditLog.ts b/packages/server/src/models/AuditLog.ts
new file mode 100644
index 0000000000000000000000000000000000000000..436c6951f5270d477571a11e7ae990bd7d03c968
--- /dev/null
+++ b/packages/server/src/models/AuditLog.ts
@@ -0,0 +1,94 @@
+import { AuditLog as AuditLogDB, Ban, User } from "@prisma/client";
+import { prisma } from "../lib/prisma";
+
+export class AuditLog {
+ static Factory(user: User | string | null) {
+ return new AuditLogFactory(user);
+ }
+
+ static async createEmpty(
+ user: User,
+ action: AuditLogDB["action"],
+ reason?: string
+ ) {
+ return await prisma.auditLog.create({
+ data: {
+ userId: user.sub,
+ action,
+ reason,
+ },
+ });
+ }
+}
+
+class AuditLogFactory {
+ /**
+ * User who committed the action
+ *
+ * If null; the system did the action
+ *
+ * @nullable
+ */
+ private _userId: string | null;
+ /**
+ * @required
+ */
+ private _action?: AuditLogDB["action"];
+ private _reason: string | null = null;
+ private _comment: string | null = null;
+
+ /**
+ * Associated ban, if applicable
+ */
+ private _ban?: Ban;
+
+ constructor(user: User | string | null) {
+ if (typeof user === "string" || user === null) {
+ this._userId = user;
+ } else {
+ this._userId = user.sub;
+ }
+ }
+
+ doing(action: AuditLogDB["action"]) {
+ this._action = action;
+ return this;
+ }
+
+ reason(reason: string | null) {
+ this._reason = reason;
+ return this;
+ }
+
+ /**
+ * Add comment from the service
+ * @param comment
+ * @returns
+ */
+ withComment(comment: string | null) {
+ this._comment = comment;
+ return this;
+ }
+
+ withBan(ban: Ban) {
+ this._ban = ban;
+ return this;
+ }
+
+ async create() {
+ if (!this._action) {
+ throw new Error("Missing action");
+ }
+
+ return await prisma.auditLog.create({
+ data: {
+ action: this._action,
+ userId: this._userId || null,
+ reason: this._reason,
+ comment: this._comment,
+
+ banId: this._ban?.id,
+ },
+ });
+ }
+}
diff --git a/packages/server/src/models/Instance.ts b/packages/server/src/models/Instance.ts
index 0a1c3b8abed5157371896604110d7c360f8cd395..0b8ad5c079df1c857cb945714de2ccad1a81e74d 100644
--- a/packages/server/src/models/Instance.ts
+++ b/packages/server/src/models/Instance.ts
@@ -14,6 +14,10 @@ export class Instance {
this.instance = data;
}
+ get hostname() {
+ return this.instance.hostname;
+ }
+
/**
* Update Instance instance
*
@@ -91,10 +95,10 @@ export class Instance {
publicNote: string | null | undefined,
privateNote: string | null | undefined
) {
- const subdomains = await Instance.getRegisteredSubdomains(
+ /*const subdomains = await Instance.getRegisteredSubdomains(
this.instance.hostname
);
- const existing = await this.getBan();
+ const existing = await this.getBan();*/
const ban = await prisma.ban.upsert({
where: {
instanceId: this.instance.id,
@@ -112,6 +116,8 @@ export class Instance {
privateNote,
},
});
+
+ return ban;
}
/**
@@ -126,11 +132,11 @@ export class Instance {
if (!existing) throw new InstanceNotBanned();
- await prisma.ban.delete({
- where: {
- id: existing.id,
- },
+ const ban = await prisma.ban.delete({
+ where: { id: existing.id },
});
+
+ return ban;
}
static async fromDomain(hostname: string): Promise {
diff --git a/packages/server/src/models/User.ts b/packages/server/src/models/User.ts
index cd56a12ec8d2c30d618cb95eedcc03cb351129c5..17be6070993b41c5642aa275aad9ff0cc7b7fbde 100644
--- a/packages/server/src/models/User.ts
+++ b/packages/server/src/models/User.ts
@@ -9,6 +9,7 @@ import {
} from "@sc07-canvas/lib/src/net";
import { Ban, User as UserDB } from "@prisma/client";
import { Instance } from "./Instance";
+import { ConditionalPromise } from "../lib/utils";
const Logger = getLogger();
@@ -40,7 +41,7 @@ export class User {
pixelStack: number;
authSession?: AuthSession;
undoExpires?: Date;
- ban?: IUserBan;
+ private _ban?: IUserBan;
isAdmin: boolean;
isModerator: boolean;
@@ -90,7 +91,7 @@ export class User {
private async updateBanFromUserData(userData: UserDB & { Ban: Ban | null }) {
if (userData.Ban) {
- this.ban = {
+ this._ban = {
id: userData.Ban.id,
expires: userData.Ban.expiresAt,
publicNote: userData.Ban.publicNote,
@@ -173,12 +174,14 @@ export class User {
* Sends packet to all user's sockets with current standing information
*/
updateStanding() {
- if (this.ban) {
+ const ban = this.getBan();
+
+ if (ban) {
for (const socket of this.sockets) {
socket.emit("standing", {
banned: true,
- until: this.ban.expires.toISOString(),
- reason: this.ban.publicNote || undefined,
+ until: ban.expires.toISOString(),
+ reason: ban.publicNote || undefined,
});
}
} else {
@@ -188,6 +191,83 @@ export class User {
}
}
+ getBan(
+ update: DoUpdate = false as DoUpdate
+ ): ConditionalPromise {
+ if (update) {
+ return new Promise(async (res) => {
+ const user = await prisma.user.findFirst({
+ where: {
+ sub: this.sub,
+ },
+ include: {
+ Ban: true,
+ },
+ });
+
+ if (!user?.Ban) {
+ return res(undefined);
+ }
+
+ this._ban = {
+ type: "user",
+ id: user.Ban.id,
+ expires: user.Ban.expiresAt,
+ publicNote: user.Ban.publicNote,
+ };
+
+ res(this._ban);
+ }) as any;
+ } else {
+ return this._ban as any;
+ }
+ }
+
+ async ban(
+ expires: Date,
+ publicNote: string | null | undefined,
+ privateNote: string | null | undefined
+ ) {
+ const ban = await prisma.ban.upsert({
+ where: {
+ userId: this.sub,
+ },
+ create: {
+ userId: this.sub,
+ expiresAt: expires,
+ publicNote,
+ privateNote,
+ },
+ update: {
+ userId: this.sub,
+ expiresAt: expires,
+ publicNote,
+ privateNote,
+ },
+ });
+
+ this._ban = {
+ id: ban.id,
+ type: "user",
+ expires,
+ publicNote: publicNote || null,
+ };
+
+ return ban;
+ }
+
+ async unban() {
+ const existing = await this.getBan(true);
+
+ if (!existing) throw new UserNotBanned();
+
+ const ban = await prisma.ban.delete({
+ where: { id: existing.id },
+ });
+
+ return ban;
+ }
+
/**
* Notifies all sockets for this user of a message
* @param alert
@@ -249,3 +329,10 @@ export class UserNotFound extends Error {
this.name = "UserNotFound";
}
}
+
+export class UserNotBanned extends Error {
+ constructor() {
+ super();
+ this.name = "UserNotBanned";
+ }
+}