(
+ network.getState("standing")?.[0]
+ );
+
+ const handleStanding = useCallback(
+ (standing: IAccountStanding) => {
+ setStanding(standing);
+ },
+ [setStanding]
+ );
+
+ useEffect(() => {
+ network.on("standing", handleStanding);
+
+ return () => {
+ network.off("standing", handleStanding);
+ };
+ }, []);
+
+ return (
+ <>
+ {standing?.banned && (
+
+ You are banned
+
+
+
+ )}
+
+ setStandingInfo(false)}>
+
+ {(onClose) => (
+ <>
+ Account Standing
+
+ {standing?.banned ? (
+ <>
+ You are banned until {standing.until}
+
+ {standing.reason ? (
+ <>Public reason given: {standing.reason}>
+ ) : (
+ <>No reason given>
+ )}
+ >
+ ) : (
+ <>Your account is in good standing>
+ )}
+
+
+
+
+ >
+ )}
+
+
+ >
+ );
+};
diff --git a/packages/client/src/components/Header/Header.tsx b/packages/client/src/components/Header/Header.tsx
index a96b675..6959189 100644
--- a/packages/client/src/components/Header/Header.tsx
+++ b/packages/client/src/components/Header/Header.tsx
@@ -3,6 +3,7 @@ import { useAppContext } from "../../contexts/AppContext";
import { User } from "./User";
import { Debug } from "@sc07-canvas/lib/src/debug";
import React, { lazy } from "react";
+import { AccountStanding } from "./AccountStanding";
const OpenChatButton = lazy(() => import("../Chat/OpenChatButton"));
@@ -37,6 +38,7 @@ const HeaderLeft = () => {
return (
+
diff --git a/packages/client/src/components/Toolbar/CanvasMeta.tsx b/packages/client/src/components/Toolbar/CanvasMeta.tsx
index f7818f7..9a8f5d3 100644
--- a/packages/client/src/components/Toolbar/CanvasMeta.tsx
+++ b/packages/client/src/components/Toolbar/CanvasMeta.tsx
@@ -57,7 +57,7 @@ const OnlineCount = () => {
setOnline(count);
}
- network.waitFor("online").then(([count]) => setOnline(count));
+ network.waitForState("online").then(([count]) => setOnline(count));
network.on("online", handleOnline);
return () => {
diff --git a/packages/client/src/contexts/AppContext.tsx b/packages/client/src/contexts/AppContext.tsx
index dca939a..f505e40 100644
--- a/packages/client/src/contexts/AppContext.tsx
+++ b/packages/client/src/contexts/AppContext.tsx
@@ -5,7 +5,7 @@ import React, {
useEffect,
useState,
} from "react";
-import { AuthSession, ClientConfig, IPosition } from "@sc07-canvas/lib/src/net";
+import { AuthSession, ClientConfig } from "@sc07-canvas/lib/src/net";
import Network from "../lib/network";
import { Spinner } from "@nextui-org/react";
import { api } from "../lib/utils";
@@ -178,7 +178,7 @@ export const AppContext = ({ children }: PropsWithChildren) => {
Network.on("user", handleUser);
Network.on("config", handleConfig);
- Network.waitFor("pixels").then(([data]) => handlePixels(data));
+ Network.waitForState("pixels").then(([data]) => handlePixels(data));
Network.on("pixels", handlePixels);
Network.on("undo", handleUndo);
diff --git a/packages/client/src/lib/alerts.tsx b/packages/client/src/lib/alerts.tsx
index 1fcd443..ac8bcb7 100644
--- a/packages/client/src/lib/alerts.tsx
+++ b/packages/client/src/lib/alerts.tsx
@@ -2,7 +2,8 @@
* Handle alerts sent by the server (moderation or internal)
*/
-import { IAlert } from "@sc07-canvas/lib/src/net";
+import { IAlert, IAlertKeyedMessages } from "@sc07-canvas/lib/src/net";
+import EventEmitter from "eventemitter3";
import { toast } from "react-toastify";
/**
@@ -24,13 +25,73 @@ export const handleDismiss = (id: string) => {
toast.dismiss(id);
};
+export interface IDynamicModal {
+ title: string | JSX.Element;
+ body: string | JSX.Element;
+}
+
+/**
+ * Dynamic modal event root
+ *
+ * These are consumed by src/DynamicModals.tsx
+ */
+interface IDynamicModalEvents {
+ showModal: (modal: IDynamicModal) => void;
+}
+class DynamicModalClass extends EventEmitter {}
+export const DynamicModal = new DynamicModalClass();
+
+const getMessage = (
+ key: T,
+ metadata: IAlertKeyedMessages[T]
+): { title: string | JSX.Element; body: string | JSX.Element } => {
+ switch (key) {
+ case "banned": {
+ let metadata_ = metadata as IAlertKeyedMessages["banned"];
+ const until = new Date(metadata_.until);
+
+ return {
+ title: "You have been banned.",
+ body:
+ "You will be unbanned in " +
+ ((until.getTime() - Date.now()) / 1000).toFixed(0) +
+ " seconds",
+ };
+ }
+ case "unbanned": {
+ return {
+ title: "You have been unbanned.",
+ body: "",
+ };
+ }
+ default:
+ return {
+ title: "Unknown Message?",
+ body: "Unknown message: " + key,
+ };
+ }
+};
+
const handleToast = (alert: IAlert<"toast">) => {
- const Body = (
- <>
- {alert.title}
- {alert.body && <> {alert.body}>}
- >
- );
+ let Body: JSX.Element;
+
+ if ("title" in alert) {
+ Body = (
+ <>
+ {alert.title}
+ {alert.body && <> {alert.body}>}
+ >
+ );
+ } else {
+ const message = getMessage(alert.message_key, alert.metadata);
+
+ Body = (
+ <>
+ {message.title}
+ {message.body}
+ >
+ );
+ }
toast(Body, {
toastId: alert.id,
@@ -40,5 +101,21 @@ const handleToast = (alert: IAlert<"toast">) => {
};
const handleModal = (alert: IAlert<"modal">) => {
- window.alert("alerts#handleModal triggered, but no implementation exists");
+ let modal: IDynamicModal;
+
+ if ("title" in alert) {
+ modal = {
+ title: alert.title,
+ body: alert.body || "",
+ };
+ } else {
+ const message = getMessage(alert.message_key, alert.metadata);
+
+ modal = {
+ title: message.title,
+ body: message.body,
+ };
+ }
+
+ DynamicModal.emit("showModal", modal);
};
diff --git a/packages/client/src/lib/canvas.ts b/packages/client/src/lib/canvas.ts
index cdf8dc6..7c32f5b 100644
--- a/packages/client/src/lib/canvas.ts
+++ b/packages/client/src/lib/canvas.ts
@@ -53,7 +53,7 @@ export class Canvas extends EventEmitter {
this.PanZoom.addListener("click", this.handleMouseDown.bind(this));
this.PanZoom.addListener("longPress", this.handleLongPress);
- Network.waitFor("pixelLastPlaced").then(
+ Network.waitForState("pixelLastPlaced").then(
([time]) => (this.lastPlace = time)
);
Network.on("pixel", this.handlePixel);
@@ -100,9 +100,10 @@ export class Canvas extends EventEmitter {
// we want the new one if possible
// (this might cause a timing issue though)
// if we don't clear the old one, if the canvas gets resized we get weird stretching
- if (Object.keys(this.pixels).length > 0) Network.clearPrevious("canvas");
+ if (Object.keys(this.pixels).length > 0)
+ Network.clearPreviousState("canvas");
- Network.waitFor("canvas").then(([pixels]) => {
+ Network.waitForState("canvas").then(([pixels]) => {
console.log("loadConfig just received new canvas data");
this.handleBatch(pixels);
});
diff --git a/packages/client/src/lib/network.ts b/packages/client/src/lib/network.ts
index b10b065..a136f8b 100644
--- a/packages/client/src/lib/network.ts
+++ b/packages/client/src/lib/network.ts
@@ -4,6 +4,7 @@ import {
AuthSession,
ClientConfig,
ClientToServerEvents,
+ IAccountStanding,
Pixel,
ServerToClientEvents,
Subscription,
@@ -16,6 +17,7 @@ export interface INetworkEvents {
disconnected: () => void;
user: (user: AuthSession) => void;
+ standing: (standing: IAccountStanding) => void;
config: (user: ClientConfig) => void;
canvas: (pixels: string[]) => void;
pixels: (data: { available: number }) => void;
@@ -48,7 +50,7 @@ class Network extends EventEmitter {
}
);
private online_count = 0;
- private sentEvents: {
+ private stateEvents: {
[key in keyof INetworkEvents]?: SentEventValue;
} = {};
@@ -89,10 +91,14 @@ class Network extends EventEmitter {
console.log("Reconnect failed");
});
- this.socket.on("user", (user: AuthSession) => {
+ this.socket.on("user", (user) => {
this.emit("user", user);
});
+ this.socket.on("standing", (standing) => {
+ this.acceptState("standing", standing);
+ });
+
this.socket.on("config", (config) => {
console.info("Server sent config", config);
@@ -109,19 +115,19 @@ class Network extends EventEmitter {
});
this.socket.on("canvas", (pixels) => {
- this._emit("canvas", pixels);
+ this.acceptState("canvas", pixels);
});
this.socket.on("availablePixels", (count) => {
- this._emit("pixels", { available: count });
+ this.acceptState("pixels", { available: count });
});
this.socket.on("pixelLastPlaced", (time) => {
- this._emit("pixelLastPlaced", time);
+ this.acceptState("pixelLastPlaced", time);
});
this.socket.on("online", ({ count }) => {
- this._emit("online", count);
+ this.acceptState("online", count);
});
this.socket.on("pixel", (pixel) => {
@@ -161,8 +167,8 @@ class Network extends EventEmitter {
* @param args
* @returns
*/
- private _emit: typeof this.emit = (event, ...args) => {
- this.sentEvents[event] = args;
+ acceptState: typeof this.emit = (event, ...args) => {
+ this.stateEvents[event] = args;
return this.emit(event, ...args);
};
@@ -170,8 +176,10 @@ class Network extends EventEmitter {
* Discard the existing state-like event, if it exists in cache
* @param ev
*/
- clearPrevious(ev: Ev) {
- delete this.sentEvents[ev];
+ clearPreviousState(
+ ev: Ev
+ ) {
+ delete this.stateEvents[ev];
}
/**
@@ -182,11 +190,11 @@ class Network extends EventEmitter {
* @param ev
* @returns
*/
- waitFor(
+ waitForState(
ev: Ev
): Promise> {
return new Promise((res) => {
- if (this.sentEvents[ev]) return res(this.sentEvents[ev]!);
+ if (this.stateEvents[ev]) return res(this.stateEvents[ev]!);
this.once(ev, (...data) => {
res(data);
@@ -194,6 +202,17 @@ class Network extends EventEmitter {
});
}
+ /**
+ * Get current value of state event
+ * @param event
+ * @returns
+ */
+ getState(
+ event: Ev
+ ): SentEventValue | undefined {
+ return this.stateEvents[event];
+ }
+
/**
* Get online user count
* @returns online users count
diff --git a/packages/lib/src/net.ts b/packages/lib/src/net.ts
index e0891e6..8e765cb 100644
--- a/packages/lib/src/net.ts
+++ b/packages/lib/src/net.ts
@@ -5,6 +5,7 @@ export type Subscription = "heatmap";
export interface ServerToClientEvents {
canvas: (pixels: string[]) => void;
user: (user: AuthSession) => void;
+ standing: (standing: IAccountStanding) => void;
config: (config: ClientConfig) => void;
pixel: (pixel: Pixel) => void;
online: (count: { count: number }) => void;
@@ -44,6 +45,7 @@ export interface ClientToServerEvents {
| "pixel_cooldown"
| "palette_color_invalid"
| "you_already_placed_that"
+ | "banned"
>
) => void
) => void;
@@ -60,12 +62,39 @@ export interface IPosition {
y: number;
}
+export type IAccountStanding =
+ | {
+ banned: false;
+ }
+ | {
+ banned: true;
+ /**
+ * ISO timestamp
+ */
+ until: string;
+ reason?: string;
+ };
+
+/**
+ * Typescript magic
+ *
+ * key => name of the event
+ * value => what metadata the message will include
+ */
+export interface IAlertKeyedMessages {
+ banned: {
+ /**
+ * ISO date
+ */
+ until: string;
+ };
+ unbanned: {};
+}
+
export type IAlert = {
is: Is;
action: "system" | "moderation";
id?: string;
- title: string;
- body?: string;
} & (
| {
is: "toast";
@@ -76,7 +105,22 @@ export type IAlert = {
is: "modal";
dismissable: boolean;
}
-);
+) &
+ (IAlertKeyed | { title: string; body?: string });
+
+/**
+ * Typescript magic
+ *
+ * #metadata depends on message_key and is mapped via IAlertKeyedMessages
+ */
+type IAlertKeyed = keyof IAlertKeyedMessages extends infer MessageKey
+ ? MessageKey extends keyof IAlertKeyedMessages
+ ? {
+ message_key: MessageKey;
+ metadata: IAlertKeyedMessages[MessageKey];
+ }
+ : never
+ : never;
// other
diff --git a/packages/server/prisma/dbml/schema.dbml b/packages/server/prisma/dbml/schema.dbml
index 7861b1a..aeaab2d 100644
--- a/packages/server/prisma/dbml/schema.dbml
+++ b/packages/server/prisma/dbml/schema.dbml
@@ -19,6 +19,7 @@ Table User {
isModerator Boolean [not null, default: false]
pixels Pixel [not null]
FactionMember FactionMember [not null]
+ Ban Ban
}
Table Instance {
@@ -27,6 +28,7 @@ Table Instance {
name String
logo_url String
banner_url String
+ Ban Ban
}
Table PaletteColor {
@@ -102,6 +104,17 @@ Table FactionSettingDefinition {
FactionSetting FactionSetting [not null]
}
+Table Ban {
+ id Int [pk, increment]
+ userId String [unique]
+ instanceId Int [unique]
+ privateNote String
+ publicNote String
+ expiresAt DateTime [not null]
+ user User
+ instance Instance
+}
+
Ref: Pixel.userId > User.sub
Ref: Pixel.color > PaletteColor.hex
@@ -116,4 +129,8 @@ Ref: FactionSocial.factionId > Faction.id
Ref: FactionSetting.key > FactionSettingDefinition.id
-Ref: FactionSetting.factionId > Faction.id
\ No newline at end of file
+Ref: FactionSetting.factionId > Faction.id
+
+Ref: Ban.userId - User.sub
+
+Ref: Ban.instanceId - Instance.id
\ No newline at end of file
diff --git a/packages/server/prisma/migrations/20240706033830_add_ban_model/migration.sql b/packages/server/prisma/migrations/20240706033830_add_ban_model/migration.sql
new file mode 100644
index 0000000..1cfb6a5
--- /dev/null
+++ b/packages/server/prisma/migrations/20240706033830_add_ban_model/migration.sql
@@ -0,0 +1,23 @@
+-- CreateTable
+CREATE TABLE "Ban" (
+ "id" SERIAL NOT NULL,
+ "userId" TEXT,
+ "instanceId" INTEGER,
+ "privateNote" TEXT,
+ "publicNote" TEXT,
+ "expiresAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Ban_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Ban_userId_key" ON "Ban"("userId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Ban_instanceId_key" ON "Ban"("instanceId");
+
+-- AddForeignKey
+ALTER TABLE "Ban" ADD CONSTRAINT "Ban_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("sub") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Ban" ADD CONSTRAINT "Ban_instanceId_fkey" FOREIGN KEY ("instanceId") REFERENCES "Instance"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index 9a401ae..f1ff208 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -34,6 +34,7 @@ model User {
pixels Pixel[]
FactionMember FactionMember[]
+ Ban Ban?
}
model Instance {
@@ -42,6 +43,7 @@ model Instance {
name String?
logo_url String?
banner_url String?
+ Ban Ban?
}
model PaletteColor {
@@ -128,3 +130,18 @@ model FactionSettingDefinition {
minimumLevel Int // what level is needed to modify this setting (>=)
FactionSetting FactionSetting[]
}
+
+model Ban {
+ id Int @id @default(autoincrement())
+ userId String? @unique
+ instanceId Int? @unique
+
+ privateNote String?
+ publicNote String?
+ expiresAt DateTime
+
+ // TODO: link audit log
+
+ user User? @relation(fields: [userId], references: [sub])
+ instance Instance? @relation(fields: [instanceId], references: [id])
+}
diff --git a/packages/server/src/api/admin.ts b/packages/server/src/api/admin.ts
index e6a0600..7099cc0 100644
--- a/packages/server/src/api/admin.ts
+++ b/packages/server/src/api/admin.ts
@@ -1,10 +1,15 @@
import { Router } from "express";
-import { User } from "../models/User";
+import { User, UserNotFound } from "../models/User";
import Canvas from "../lib/Canvas";
import { getLogger } from "../lib/Logger";
import { RateLimiter } from "../lib/RateLimiter";
import { prisma } from "../lib/prisma";
import { SocketServer } from "../lib/SocketServer";
+import {
+ Instance,
+ InstanceNotBanned,
+ InstanceNotFound,
+} from "../models/Instance";
const app = Router();
const Logger = getLogger("HTTP/ADMIN");
@@ -201,4 +206,305 @@ app.put("/canvas/fill", async (req, res) => {
res.json({ success: true });
});
+app.put("/user/:sub/ban", async (req, res) => {
+ let user: User;
+ let expires: Date;
+ let publicNote: string | undefined | null;
+ let privateNote: string | undefined | null;
+
+ try {
+ user = await User.fromSub(req.params.sub);
+ } catch (e) {
+ if (e instanceof UserNotFound) {
+ res.status(404).json({ success: false, error: "User not found" });
+ } else {
+ Logger.error(`/user/${req.params.sub}/ban Error ` + (e as any)?.message);
+ res.status(500).json({ success: false, error: "Internal error" });
+ }
+ return;
+ }
+
+ if (typeof req.body.expiresAt !== "string") {
+ res
+ .status(400)
+ .json({ success: false, error: "expiresAt is not a string" });
+ return;
+ }
+
+ expires = new Date(req.body.expiresAt);
+
+ if (!isFinite(expires.getTime())) {
+ res
+ .status(400)
+ .json({ success: false, error: "expiresAt is not a valid date" });
+ return;
+ }
+
+ if (typeof req.body.publicNote !== "undefined") {
+ if (
+ typeof req.body.publicNote !== "string" &&
+ req.body.privateNote !== null
+ ) {
+ res.status(400).json({
+ success: false,
+ error: "publicNote is set and is not a string",
+ });
+ return;
+ }
+
+ publicNote = req.body.publicNote;
+ }
+
+ if (typeof req.body.privateNote !== "undefined") {
+ if (
+ typeof req.body.privateNote !== "string" &&
+ req.body.privateNote !== null
+ ) {
+ res.status(400).json({
+ success: false,
+ error: "privateNote is set and is not a string",
+ });
+ return;
+ }
+
+ 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);
+
+ let shouldNotifyUser = false;
+
+ if (existingBan) {
+ if (existingBan.expires.getTime() !== ban.expiresAt.getTime()) {
+ shouldNotifyUser = true;
+ }
+ } else {
+ shouldNotifyUser = true;
+ }
+
+ if (shouldNotifyUser) {
+ user.notify({
+ is: "modal",
+ action: "moderation",
+ dismissable: true,
+ message_key: "banned",
+ metadata: {
+ until: expires.toISOString(),
+ },
+ });
+ }
+
+ user.updateStanding();
+
+ // todo: audit log
+
+ res.json({ success: true });
+});
+
+app.delete("/user/:sub/ban", async (req, res) => {
+ // delete ban ("unban")
+
+ let user: User;
+
+ try {
+ user = await User.fromSub(req.params.sub);
+ } catch (e) {
+ if (e instanceof UserNotFound) {
+ res.status(404).json({ success: false, error: "User not found" });
+ } else {
+ Logger.error(`/user/${req.params.sub}/ban Error ` + (e as any)?.message);
+ res.status(500).json({ success: false, error: "Internal error" });
+ }
+ return;
+ }
+
+ if (!user.ban?.id) {
+ res.status(400).json({
+ success: false,
+ error: "User is not banned",
+ });
+ return;
+ }
+
+ await prisma.ban.delete({
+ where: { id: user.ban.id },
+ });
+
+ user.notify({
+ is: "modal",
+ action: "moderation",
+ dismissable: true,
+ message_key: "unbanned",
+ metadata: {},
+ });
+
+ await user.update(true);
+ user.updateStanding();
+
+ // todo: audit log
+
+ res.json({ success: true });
+});
+
+app.get("/instance/:domain/ban", async (req, res) => {
+ // get ban information
+
+ let instance: Instance;
+
+ try {
+ instance = await Instance.fromDomain(req.params.domain);
+ } catch (e) {
+ if (e instanceof InstanceNotFound) {
+ res.status(404).json({ success: false, error: "instance not found" });
+ } else {
+ Logger.error(
+ `/instance/${req.params.domain}/ban Error ` + (e as any)?.message
+ );
+ res.status(500).json({ success: false, error: "Internal error" });
+ }
+ return;
+ }
+
+ const ban = await instance.getEffectiveBan();
+
+ if (!ban) {
+ return res
+ .status(404)
+ .json({ success: false, error: "Instance not banned" });
+ }
+
+ res.json({ success: true, ban });
+});
+
+app.put("/instance/:domain/ban", async (req, res) => {
+ // ban domain & subdomains
+
+ let instance: Instance;
+ let expires: Date;
+ let publicNote: string | null | undefined;
+ let privateNote: string | null | undefined;
+
+ try {
+ instance = await Instance.fromDomain(req.params.domain);
+ } catch (e) {
+ if (e instanceof InstanceNotFound) {
+ res.status(404).json({ success: false, error: "instance not found" });
+ } else {
+ Logger.error(
+ `/instance/${req.params.domain}/ban Error ` + (e as any)?.message
+ );
+ res.status(500).json({ success: false, error: "Internal error" });
+ }
+ return;
+ }
+
+ if (typeof req.body.expiresAt !== "string") {
+ res
+ .status(400)
+ .json({ success: false, error: "expiresAt is not a string" });
+ return;
+ }
+
+ expires = new Date(req.body.expiresAt);
+
+ if (!isFinite(expires.getTime())) {
+ res
+ .status(400)
+ .json({ success: false, error: "expiresAt is not a valid date" });
+ return;
+ }
+
+ if (typeof req.body.publicNote !== "undefined") {
+ if (
+ typeof req.body.publicNote !== "string" &&
+ req.body.privateNote !== null
+ ) {
+ res.status(400).json({
+ success: false,
+ error: "publicNote is set and is not a string",
+ });
+ return;
+ }
+
+ publicNote = req.body.publicNote;
+ }
+
+ if (typeof req.body.privateNote !== "undefined") {
+ if (
+ typeof req.body.privateNote !== "string" &&
+ req.body.privateNote !== null
+ ) {
+ res.status(400).json({
+ success: false,
+ error: "privateNote is set and is not a string",
+ });
+ return;
+ }
+
+ privateNote = req.body.privateNote;
+ }
+
+ await instance.ban(expires, publicNote, privateNote);
+
+ // todo: audit log
+
+ res.json({
+ success: true,
+ });
+});
+
+app.delete("/instance/:domain/ban", async (req, res) => {
+ // unban domain & subdomains
+
+ let instance: Instance;
+
+ try {
+ instance = await Instance.fromDomain(req.params.domain);
+ } catch (e) {
+ if (e instanceof InstanceNotFound) {
+ res.status(404).json({ success: false, error: "instance not found" });
+ } else {
+ Logger.error(
+ `/instance/${req.params.domain}/ban Error ` + (e as any)?.message
+ );
+ res.status(500).json({ success: false, error: "Internal error" });
+ }
+ return;
+ }
+
+ try {
+ await instance.unban();
+ } catch (e) {
+ if (e instanceof InstanceNotBanned) {
+ res.status(404).json({ success: false, error: "instance not banned" });
+ } else {
+ Logger.error(
+ `/instance/${req.params.domain}/ban Error ` + (e as any)?.message
+ );
+ res.status(500).json({ success: false, error: "Internal error" });
+ }
+ return;
+ }
+
+ // todo: audit log
+
+ res.json({ success: true });
+});
+
export default app;
diff --git a/packages/server/src/api/client.ts b/packages/server/src/api/client.ts
index 134c69e..82010b3 100644
--- a/packages/server/src/api/client.ts
+++ b/packages/server/src/api/client.ts
@@ -5,6 +5,7 @@ import { TokenSet, errors as OIDC_Errors } from "openid-client";
import { getLogger } from "../lib/Logger";
import Canvas from "../lib/Canvas";
import { RateLimiter } from "../lib/RateLimiter";
+import { Instance } from "../models/Instance";
const Logger = getLogger("HTTP/CLIENT");
@@ -38,7 +39,17 @@ app.get("/login", (req, res) => {
);
});
-// TODO: logout endpoint
+app.get("/logout", (req, res) => {
+ res.send(
+ ``
+ );
+});
+
+app.post("/logout", (req, res) => {
+ req.session.destroy(() => {
+ res.redirect("/");
+ });
+});
/**
* Process token exchange from openid server
@@ -144,6 +155,19 @@ app.get("/callback", RateLimiter.HIGH, async (req, res) => {
const [username, hostname] = whoami.sub.split("@");
+ const instance = await Instance.fromAuth(hostname, whoami.instance.instance);
+ const instanceBan = await instance.getEffectiveBan();
+ if (instanceBan) {
+ res.redirect(
+ "/" +
+ buildQuery({
+ TYPE: "banned",
+ ERROR_DESC: instanceBan.publicNote || undefined,
+ })
+ );
+ return;
+ }
+
const sub = [username, hostname].join("@");
await prisma.user.upsert({
where: {
@@ -163,24 +187,6 @@ app.get("/callback", RateLimiter.HIGH, async (req, res) => {
},
});
- await prisma.instance.upsert({
- where: {
- hostname,
- },
- update: {
- hostname,
- name: whoami.instance.instance.name,
- logo_url: whoami.instance.instance.logo_uri,
- banner_url: whoami.instance.instance.banner_uri,
- },
- create: {
- hostname,
- name: whoami.instance.instance.name,
- logo_url: whoami.instance.instance.logo_uri,
- banner_url: whoami.instance.instance.banner_uri,
- },
- });
-
req.session.user = {
service: {
...whoami.instance,
diff --git a/packages/server/src/lib/SocketServer.ts b/packages/server/src/lib/SocketServer.ts
index 91e5d39..987669e 100644
--- a/packages/server/src/lib/SocketServer.ts
+++ b/packages/server/src/lib/SocketServer.ts
@@ -173,6 +173,17 @@ export class SocketServer {
if (socket.request.session.user) {
// inform the client of their session if it exists
socket.emit("user", socket.request.session.user);
+
+ socket.emit(
+ "standing",
+ user?.ban
+ ? {
+ banned: true,
+ until: user.ban.expires.toISOString(),
+ reason: user.ban.publicNote || undefined,
+ }
+ : { banned: false }
+ );
}
if (user) {
@@ -250,6 +261,11 @@ export class SocketServer {
return;
}
+ if (user.ban && user.ban.expires > new Date()) {
+ ack({ success: false, error: "banned" });
+ return;
+ }
+
const paletteColor = await prisma.paletteColor.findFirst({
where: {
id: pixel.color,
diff --git a/packages/server/src/models/Instance.ts b/packages/server/src/models/Instance.ts
new file mode 100644
index 0000000..0a1c3b8
--- /dev/null
+++ b/packages/server/src/models/Instance.ts
@@ -0,0 +1,225 @@
+import { Ban, Instance as InstanceDB } from "@prisma/client";
+import { prisma } from "../lib/prisma";
+
+export interface IInstanceMeta {
+ logo_uri?: string;
+ banner_uri?: string;
+ name?: string;
+}
+
+export class Instance {
+ private instance: InstanceDB;
+
+ private constructor(data: InstanceDB) {
+ this.instance = data;
+ }
+
+ /**
+ * Update Instance instance
+ *
+ * @throws InstanceNotFound Instance no longer exists (was deleted?)
+ */
+ async update() {
+ const instance = await prisma.instance.findFirst({
+ where: {
+ id: this.instance.id,
+ },
+ });
+
+ if (!instance) throw new InstanceNotFound("Instance no longer exists");
+
+ this.instance = instance;
+ }
+
+ /**
+ * Get effective ban
+ *
+ * Filters through any subdomain bans
+ */
+ async getEffectiveBan(): Promise<(Ban & { hostname: string }) | undefined> {
+ let applicable: Ban | undefined | null;
+ let hostname: string = this.instance.hostname;
+
+ const check = async (domain: string): Promise => {
+ const instance = await Instance.fromDomain(domain);
+ hostname = domain;
+ applicable = await instance.getBan();
+
+ if (!applicable) {
+ const newDomain = domain.split(".").slice(1).join(".");
+ if (newDomain) {
+ return check(newDomain);
+ }
+ }
+ };
+
+ await check(this.instance.hostname);
+
+ return applicable
+ ? {
+ ...applicable,
+ hostname,
+ }
+ : undefined;
+ }
+
+ /**
+ * Get ban for this hostname
+ *
+ * @see Instance#getBans use this instead
+ */
+ async getBan() {
+ const ban = await prisma.ban.findFirst({
+ where: {
+ instanceId: this.instance.id,
+ },
+ });
+
+ return ban;
+ }
+
+ /**
+ * Bans an instance (create / update)
+ *
+ * This bans all subdomains
+ *
+ * @note does not create audit log
+ * @note does not retroactively ban users, only blocks new users
+ */
+ async ban(
+ expires: Date,
+ publicNote: string | null | undefined,
+ privateNote: string | null | undefined
+ ) {
+ const subdomains = await Instance.getRegisteredSubdomains(
+ this.instance.hostname
+ );
+ const existing = await this.getBan();
+ const ban = await prisma.ban.upsert({
+ where: {
+ instanceId: this.instance.id,
+ },
+ create: {
+ instanceId: this.instance.id,
+ expiresAt: expires,
+ publicNote,
+ privateNote,
+ },
+ update: {
+ instanceId: this.instance.id,
+ expiresAt: expires,
+ publicNote,
+ privateNote,
+ },
+ });
+ }
+
+ /**
+ * Unbans an instance
+ *
+ * @note does not create audit log
+ * @note does not unban a subdomain that was banned because of inheritance
+ * @throws InstanceNotBanned
+ */
+ async unban() {
+ const existing = await this.getBan();
+
+ if (!existing) throw new InstanceNotBanned();
+
+ await prisma.ban.delete({
+ where: {
+ id: existing.id,
+ },
+ });
+ }
+
+ static async fromDomain(hostname: string): Promise {
+ const instance = await prisma.instance.upsert({
+ where: {
+ hostname,
+ },
+ update: {},
+ create: {
+ hostname,
+ },
+ });
+
+ return new this(instance);
+ }
+
+ /**
+ * Get instance from hostname & update with new instance meta
+ * @param hostname
+ * @param instanceMeta
+ * @returns
+ */
+ static async fromAuth(
+ hostname: string,
+ instanceMeta: IInstanceMeta
+ ): Promise {
+ if (!this.isHostnameValid(hostname)) {
+ throw new InstanceInvalid();
+ }
+
+ const instance = await prisma.instance.upsert({
+ where: {
+ hostname,
+ },
+ update: {
+ hostname,
+ name: instanceMeta.name,
+ logo_url: instanceMeta.logo_uri,
+ banner_url: instanceMeta.banner_uri,
+ },
+ create: {
+ hostname,
+ name: instanceMeta.name,
+ logo_url: instanceMeta.logo_uri,
+ banner_url: instanceMeta.banner_uri,
+ },
+ });
+
+ return new this(instance);
+ }
+
+ /**
+ * Get all registered subdomains from a domain
+ * @param hostname
+ */
+ static async getRegisteredSubdomains(hostname: string): Promise {
+ return [];
+ }
+
+ /**
+ * Determine if a hostname is valid to be an instance
+ *
+ * Currently restricts the amount of domain parts
+ *
+ * @param hostname
+ * @returns
+ */
+ static isHostnameValid(hostname: string): boolean {
+ return (hostname.match(/\./g) || []).length <= 5;
+ }
+}
+
+export class InstanceInvalid extends Error {
+ constructor() {
+ super();
+ this.name = "InstanceInvalid";
+ }
+}
+
+export class InstanceNotFound extends Error {
+ constructor(message?: string) {
+ super(message);
+ this.name = "InstanceNotFound";
+ }
+}
+
+export class InstanceNotBanned extends Error {
+ constructor() {
+ super();
+ this.name = "InstanceNotBanned";
+ }
+}
diff --git a/packages/server/src/models/User.ts b/packages/server/src/models/User.ts
index 8c5a6cd..cd56a12 100644
--- a/packages/server/src/models/User.ts
+++ b/packages/server/src/models/User.ts
@@ -4,19 +4,33 @@ import { prisma } from "../lib/prisma";
import {
AuthSession,
ClientToServerEvents,
+ IAlert,
ServerToClientEvents,
} from "@sc07-canvas/lib/src/net";
+import { Ban, User as UserDB } from "@prisma/client";
+import { Instance } from "./Instance";
const Logger = getLogger();
-interface IUserData {
- sub: string;
- lastPixelTime: Date;
- pixelStack: number;
- undoExpires: Date | null;
- isAdmin: boolean;
- isModerator: boolean;
-}
+/**
+ * Represents a user ban
+ *
+ * Has implementation in here for making instance bans retroactive,
+ * but at time of writing, instance bans will only block new users
+ */
+export type IUserBan = {
+ id: number;
+ expires: Date;
+ publicNote: string | null;
+} & (
+ | {
+ type: "user";
+ }
+ | {
+ type: "instance";
+ hostname: string;
+ }
+);
export class User {
static instances: Map = new Map();
@@ -26,6 +40,7 @@ export class User {
pixelStack: number;
authSession?: AuthSession;
undoExpires?: Date;
+ ban?: IUserBan;
isAdmin: boolean;
isModerator: boolean;
@@ -34,7 +49,7 @@ export class User {
private _updatedAt: number;
- private constructor(data: IUserData) {
+ private constructor(data: UserDB & { Ban: Ban | null }) {
Logger.debug("User class instansiated for " + data.sub);
this.sub = data.sub;
@@ -45,6 +60,8 @@ export class User {
this.isAdmin = data.isAdmin;
this.isModerator = data.isModerator;
+ this.updateBanFromUserData(data).then(() => {});
+
this._updatedAt = Date.now();
}
@@ -55,6 +72,9 @@ export class User {
where: {
sub: this.sub,
},
+ include: {
+ Ban: true,
+ },
});
if (!userData) throw new UserNotFound();
@@ -64,6 +84,38 @@ export class User {
this.undoExpires = userData.undoExpires || undefined;
this.isAdmin = userData.isAdmin;
this.isModerator = userData.isModerator;
+
+ await this.updateBanFromUserData(userData);
+ }
+
+ private async updateBanFromUserData(userData: UserDB & { Ban: Ban | null }) {
+ if (userData.Ban) {
+ this.ban = {
+ id: userData.Ban.id,
+ expires: userData.Ban.expiresAt,
+ publicNote: userData.Ban.publicNote,
+ type: "user",
+ };
+ } else {
+ // the code below is for making instance bans retroactive
+ //
+ // const instance = await this.getInstance();
+ // const instanceBan = await instance.getEffectiveBan();
+ // if (instanceBan) {
+ // this.ban = {
+ // id: instanceBan.id,
+ // expires: instanceBan.expiresAt,
+ // publicNote: instanceBan.publicNote,
+ // type: "instance",
+ // hostname: instanceBan.hostname,
+ // };
+ // }
+ }
+ }
+
+ async getInstance(): Promise {
+ const [local, hostname] = this.sub.split("@");
+ return await Instance.fromDomain(hostname);
}
async modifyStack(modifyBy: number): Promise {
@@ -117,6 +169,35 @@ export class User {
await this.update(true);
}
+ /**
+ * Sends packet to all user's sockets with current standing information
+ */
+ updateStanding() {
+ if (this.ban) {
+ for (const socket of this.sockets) {
+ socket.emit("standing", {
+ banned: true,
+ until: this.ban.expires.toISOString(),
+ reason: this.ban.publicNote || undefined,
+ });
+ }
+ } else {
+ for (const socket of this.sockets) {
+ socket.emit("standing", { banned: false });
+ }
+ }
+ }
+
+ /**
+ * Notifies all sockets for this user of a message
+ * @param alert
+ */
+ notify(alert: IAlert) {
+ for (const socket of this.sockets) {
+ socket.emit("alert", alert);
+ }
+ }
+
/**
* Determine if this user data is stale and should be updated
* @see User#update
@@ -149,6 +230,9 @@ export class User {
where: {
sub,
},
+ include: {
+ Ban: true,
+ },
});
if (!userData) throw new UserNotFound();