[wip] implement user & instance banning (related #17)
This commit is contained in:
parent
0ad4cd4f7e
commit
29feba063c
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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) => (
|
||||
<>
|
||||
|
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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;
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
Loading…
Reference in New Issue