[wip] implement user & instance banning (related #17)

This commit is contained in:
Grant 2024-07-05 21:46:40 -06:00
parent 0ad4cd4f7e
commit 29feba063c
19 changed files with 1112 additions and 62 deletions

View File

@ -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 = () => {
<ModModal />
<ToastContainer position="top-left" />
<DynamicModals />
</>
);
};

View File

@ -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}
/>
<BannedError
isOpen={params.get(Params.TYPE) === "banned"}
onClose={onClose}
params={params}
/>
</>
);
};
const BannedError = ({
isOpen,
onClose,
params,
}: {
isOpen: boolean;
onClose: () => void;
params: URLSearchParams;
}) => {
return (
<Modal isOpen={isOpen} onClose={onClose} isDismissable={false}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Login Error</ModalHeader>
<ModalBody>
<b>Your instance is banned.</b> You cannot proceed.
<br />
<br />
{params.has(Params.ERROR_DESC) ? (
<>Reason: {params.get(Params.ERROR_DESC)}</>
) : (
<>No reason provided</>
)}
</ModalBody>
</>
)}
</ModalContent>
</Modal>
);
};
/**
* This is for RP errors, which can be triggered by modifying data sent in callbacks
*
@ -67,7 +104,7 @@ const RPError = ({
params: URLSearchParams;
}) => {
return (
<Modal isOpen={isOpen} onOpenChange={onClose} isDismissable={false}>
<Modal isOpen={isOpen} onClose={onClose} isDismissable={false}>
<ModalContent>
{(onClose) => (
<>
@ -117,7 +154,7 @@ const OPError = ({
}, [params]);
return (
<Modal isOpen={isOpen} onOpenChange={onClose} isDismissable={false}>
<Modal isOpen={isOpen} onClose={onClose} isDismissable={false}>
<ModalContent>
{(onClose) => (
<>

View File

@ -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<IModal[]>([]);
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 }) => (
<Modal key={id} isOpen={open} onClose={() => handleHideModal(id)}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>{modal.title}</ModalHeader>
<ModalBody>{modal.body}</ModalBody>
<ModalFooter>
<Button onClick={onClose}>Close</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
))}
</>
);
};

View File

@ -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<IAccountStanding | undefined>(
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 && (
<div className="bg-red-500 bg-opacity-85 border-red-700 border-1 rounded-md p-1 flex items-center gap-2">
You are banned
<br />
<Button size="sm" onPress={() => setStandingInfo(true)}>
Details
</Button>
</div>
)}
<Modal isOpen={standingInfo} onClose={() => setStandingInfo(false)}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Account Standing</ModalHeader>
<ModalBody>
{standing?.banned ? (
<>
You are banned until {standing.until}
<br />
{standing.reason ? (
<>Public reason given: {standing.reason}</>
) : (
<>No reason given</>
)}
</>
) : (
<>Your account is in good standing</>
)}
</ModalBody>
<ModalFooter>
<Button onPress={onClose}>Close</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
);
};

View File

@ -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 (
<div className="box">
<AccountStanding />
<Button onPress={() => setInfoSidebar(true)}>Info</Button>
<Button onPress={() => Debug.openDebugTools()}>Debug Tools</Button>
</div>

View File

@ -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 () => {

View File

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

View File

@ -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<IDynamicModalEvents> {}
export const DynamicModal = new DynamicModalClass();
const getMessage = <T extends keyof IAlertKeyedMessages>(
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 = (
<>
<b>{alert.title}</b>
{alert.body && <> {alert.body}</>}
</>
);
let Body: JSX.Element;
if ("title" in alert) {
Body = (
<>
<b>{alert.title}</b>
{alert.body && <> {alert.body}</>}
</>
);
} else {
const message = getMessage(alert.message_key, alert.metadata);
Body = (
<>
<b>{message.title}</b>
{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);
};

View File

@ -53,7 +53,7 @@ export class Canvas extends EventEmitter<CanvasEvents> {
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<CanvasEvents> {
// 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);
});

View File

@ -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<INetworkEvents> {
}
);
private online_count = 0;
private sentEvents: {
private stateEvents: {
[key in keyof INetworkEvents]?: SentEventValue<key>;
} = {};
@ -89,10 +91,14 @@ class Network extends EventEmitter<INetworkEvents> {
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<INetworkEvents> {
});
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<INetworkEvents> {
* @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<INetworkEvents> {
* Discard the existing state-like event, if it exists in cache
* @param ev
*/
clearPrevious<Ev extends keyof INetworkEvents & (string | symbol)>(ev: Ev) {
delete this.sentEvents[ev];
clearPreviousState<Ev extends keyof INetworkEvents & (string | symbol)>(
ev: Ev
) {
delete this.stateEvents[ev];
}
/**
@ -182,11 +190,11 @@ class Network extends EventEmitter<INetworkEvents> {
* @param ev
* @returns
*/
waitFor<Ev extends keyof INetworkEvents & (string | symbol)>(
waitForState<Ev extends keyof INetworkEvents & (string | symbol)>(
ev: Ev
): Promise<SentEventValue<Ev>> {
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<INetworkEvents> {
});
}
/**
* Get current value of state event
* @param event
* @returns
*/
getState<Ev extends keyof INetworkEvents>(
event: Ev
): SentEventValue<Ev> | undefined {
return this.stateEvents[event];
}
/**
* Get online user count
* @returns online users count

View File

@ -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 extends "toast" | "modal" = "toast" | "modal"> = {
is: Is;
action: "system" | "moderation";
id?: string;
title: string;
body?: string;
} & (
| {
is: "toast";
@ -76,7 +105,22 @@ export type IAlert<Is extends "toast" | "modal" = "toast" | "modal"> = {
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

View File

@ -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
Ref: FactionSetting.factionId > Faction.id
Ref: Ban.userId - User.sub
Ref: Ban.instanceId - Instance.id

View File

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

View File

@ -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])
}

View File

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

View File

@ -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(
`<form method="post"><input type="submit" value="Confirm Logout" /></form>`
);
});
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,

View File

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

View File

@ -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<any> => {
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<Instance> {
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<Instance> {
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<Instance[]> {
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";
}
}

View File

@ -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<string, User> = 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<Instance> {
const [local, hostname] = this.sub.split("@");
return await Instance.fromDomain(hostname);
}
async modifyStack(modifyBy: number): Promise<any> {
@ -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();