diff --git a/packages/client/src/components/App.tsx b/packages/client/src/components/App.tsx index 263032e..fcba090 100644 --- a/packages/client/src/components/App.tsx +++ b/packages/client/src/components/App.tsx @@ -18,6 +18,7 @@ import { ProfileModal } from "./Profile/ProfileModal"; import { WelcomeModal } from "./Welcome/WelcomeModal"; import { InfoSidebar } from "./Info/InfoSidebar"; import { ModModal } from "./Moderation/ModModal"; +import { DynamicModals } from "./DynamicModals"; const Chat = lazy(() => import("./Chat/Chat")); @@ -152,6 +153,7 @@ const AppInner = () => { + ); }; diff --git a/packages/client/src/components/AuthErrors.tsx b/packages/client/src/components/AuthErrors.tsx index 54fc6b6..934730d 100644 --- a/packages/client/src/components/AuthErrors.tsx +++ b/packages/client/src/components/AuthErrors.tsx @@ -28,7 +28,7 @@ export const AuthErrors = () => { const onClose = () => { const url = new URL(window.location.href); url.search = ""; - // window.history.replaceState({}, "", url.toString()); + window.history.replaceState({}, "", url.toString()); setParams(new URLSearchParams(window.location.search)); }; @@ -45,10 +45,47 @@ export const AuthErrors = () => { onClose={onClose} params={params} /> + ); }; +const BannedError = ({ + isOpen, + onClose, + params, +}: { + isOpen: boolean; + onClose: () => void; + params: URLSearchParams; +}) => { + return ( + + + {(onClose) => ( + <> + Login Error + + Your instance is banned. You cannot proceed. +
+
+ {params.has(Params.ERROR_DESC) ? ( + <>Reason: {params.get(Params.ERROR_DESC)} + ) : ( + <>No reason provided + )} +
+ + )} +
+
+ ); +}; + /** * This is for RP errors, which can be triggered by modifying data sent in callbacks * @@ -67,7 +104,7 @@ const RPError = ({ params: URLSearchParams; }) => { return ( - + {(onClose) => ( <> @@ -117,7 +154,7 @@ const OPError = ({ }, [params]); return ( - + {(onClose) => ( <> diff --git a/packages/client/src/components/DynamicModals.tsx b/packages/client/src/components/DynamicModals.tsx new file mode 100644 index 0000000..a1118b0 --- /dev/null +++ b/packages/client/src/components/DynamicModals.tsx @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useState } from "react"; +import { DynamicModal, IDynamicModal } from "../lib/alerts"; +import { + Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from "@nextui-org/react"; + +interface IModal { + id: number; + open: boolean; + modal: IDynamicModal; +} + +/** + * React base to hold dynamic modals + * + * Dynamic modals are created via lib/alerts.tsx + * + * @returns + */ +export const DynamicModals = () => { + const [modals, setModals] = useState([]); + + const handleShowModal = useCallback( + (modal: IDynamicModal) => { + setModals((modals) => [ + ...modals, + { + id: Math.floor(Math.random() * 9999), + open: true, + modal, + }, + ]); + }, + [setModals] + ); + + const handleHideModal = useCallback( + (modalId: number) => { + setModals((modals_) => { + const modals = [...modals_]; + + if (modals.find((m) => m.id === modalId)) { + modals.find((m) => m.id === modalId)!.open = false; + } + + return modals; + }); + + setTimeout(() => { + setModals((modals_) => { + const modals = [...modals_]; + + if (modals.find((m) => m.id === modalId)) { + modals.splice( + modals.indexOf(modals.find((m) => m.id === modalId)!), + 1 + ); + } + + return modals; + }); + }, 1000); + }, + [setModals] + ); + + useEffect(() => { + DynamicModal.on("showModal", handleShowModal); + + return () => { + DynamicModal.off("showModal", handleShowModal); + }; + }, []); + + return ( + <> + {modals.map(({ id, open, modal }) => ( + handleHideModal(id)}> + + {(onClose) => ( + <> + {modal.title} + {modal.body} + + + + + )} + + + ))} + + ); +}; diff --git a/packages/client/src/components/Header/AccountStanding.tsx b/packages/client/src/components/Header/AccountStanding.tsx new file mode 100644 index 0000000..0fd7434 --- /dev/null +++ b/packages/client/src/components/Header/AccountStanding.tsx @@ -0,0 +1,75 @@ +import { IAccountStanding } from "@sc07-canvas/lib/src/net"; +import { useCallback, useEffect, useState } from "react"; +import network from "../../lib/network"; +import { + Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from "@nextui-org/react"; + +export const AccountStanding = () => { + const [standingInfo, setStandingInfo] = useState(false); + const [standing, setStanding] = useState( + 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();