diff --git a/package-lock.json b/package-lock.json
index 2d349bb81faabd5a632d3b99d4f122aa38d3832e..8e49ab1b5f84b853141e4d2a141ee741ea22a462 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11001,6 +11001,14 @@
"node": ">= 0.8"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
@@ -18042,6 +18050,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/swr": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.3.tgz",
+ "integrity": "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==",
+ "dependencies": {
+ "dequal": "^2.0.3",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/tailwind-merge": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
@@ -19201,6 +19221,14 @@
}
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
+ "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -19779,6 +19807,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/zod": {
+ "version": "3.25.30",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.30.tgz",
+ "integrity": "sha512-VolhdEtu6TJr/fzGuHA/SZ5ixvXqA6ADOG9VRcQ3rdOKmF5hkmcJbyaQjUH5BgmpA9gej++zYRX7zjSmdReIwA==",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"packages/admin": {
"name": "@sc07-canvas/admin",
"version": "0.0.0",
@@ -19789,7 +19825,8 @@
"localforage": "^1.10.0",
"match-sorter": "^8.0.0",
"react-apexcharts": "^1.7.0",
- "react-router-dom": "^7.1.1"
+ "react-router-dom": "^7.1.1",
+ "swr": "^2.3.3"
},
"devDependencies": {
"eslint-plugin-react-refresh": "^0.4.16",
@@ -19838,7 +19875,8 @@
"lodash.throttle": "^4.1.1",
"prop-types": "^15.8.1",
"react-zoom-pan-pinch": "^3.4.1",
- "socket.io-client": "^4.8.1"
+ "socket.io-client": "^4.8.1",
+ "swr": "^2.3.3"
},
"devDependencies": {
"@types/grecaptcha": "^3.0.9",
@@ -19915,7 +19953,8 @@
"rate-limit-redis": "^4.2.0",
"redis": "^4.7.0",
"socket.io": "^4.8.1",
- "winston": "^3.17.0"
+ "winston": "^3.17.0",
+ "zod": "^3.25.30"
},
"devDependencies": {
"@tsconfig/recommended": "^1.0.8",
diff --git a/packages/admin/package.json b/packages/admin/package.json
index 17cf2196a8c078e47bca69fedcb1df6091dba20e..4b1339c932d0351c7830e1ac060ddb935143dd04 100644
--- a/packages/admin/package.json
+++ b/packages/admin/package.json
@@ -16,7 +16,8 @@
"localforage": "^1.10.0",
"match-sorter": "^8.0.0",
"react-apexcharts": "^1.7.0",
- "react-router-dom": "^7.1.1"
+ "react-router-dom": "^7.1.1",
+ "swr": "^2.3.3"
},
"devDependencies": {
"eslint-plugin-react-refresh": "^0.4.16",
diff --git a/packages/admin/src/components/sidebar/Sidebar.tsx b/packages/admin/src/components/sidebar/Sidebar.tsx
index 15a6eb570147b0769e9c39540565de781ce99282..0e4b1fa401fe44696d1fc3b9d4ea72315bf1b906 100644
--- a/packages/admin/src/components/sidebar/Sidebar.tsx
+++ b/packages/admin/src/components/sidebar/Sidebar.tsx
@@ -14,6 +14,7 @@ import {
faShieldHalved,
faSquare,
faUsers,
+ faWarning,
} from "@fortawesome/free-solid-svg-icons";
import { useLocation } from "react-router-dom";
import { CollapseItems } from "./collapse-items";
@@ -55,6 +56,12 @@ export const SidebarWrapper = () => {
isActive={pathname === "/"}
href="/"
/>
+ }
+ isActive={pathname === "/reports"}
+ href="/reports"
+ />
}
diff --git a/packages/admin/src/main.tsx b/packages/admin/src/main.tsx
index db5f1d49d11cf60f7cb82d2f326b2d27fd1ea722..a70f62c7159635e139836df3811486605c46ab06 100644
--- a/packages/admin/src/main.tsx
+++ b/packages/admin/src/main.tsx
@@ -9,6 +9,8 @@ import { AccountsPage } from "./pages/Accounts/Accounts/page.tsx";
import { ServiceSettingsPage } from "./pages/Service/settings.tsx";
import { AuditLog } from "./pages/AuditLog/auditlog.tsx";
import { ToastWrapper } from "./components/ToastWrapper.tsx";
+import { ReportsPage } from "./pages/Reports/page.tsx";
+import { SWRConfig } from "swr";
const router = createBrowserRouter(
[
@@ -32,6 +34,10 @@ const router = createBrowserRouter(
path: "/audit",
element: ,
},
+ {
+ path: "/reports",
+ element: ,
+ },
],
},
],
@@ -43,11 +49,18 @@ const router = createBrowserRouter(
ReactDOM.createRoot(document.getElementById("root")!).render(
-
-
+
+ fetch(resource, init).then((res) => res.json()),
+ }}
+ >
+
+
-
-
+
+
+
);
diff --git a/packages/admin/src/pages/Reports/ReportTag.tsx b/packages/admin/src/pages/Reports/ReportTag.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0e0a7a3140fb4051a64e9bb16ff5d8e7c26936b5
--- /dev/null
+++ b/packages/admin/src/pages/Reports/ReportTag.tsx
@@ -0,0 +1,24 @@
+import { Chip } from "@nextui-org/react";
+
+export const ReportTag = ({
+ status,
+}: {
+ status: "NEW" | "RESOLVED" | "REJECTED";
+}) => {
+ switch (status) {
+ case "NEW":
+ return NEW ;
+ case "RESOLVED":
+ return (
+
+ Resolved
+
+ );
+ case "REJECTED":
+ return (
+
+ Rejected
+
+ );
+ }
+};
diff --git a/packages/admin/src/pages/Reports/ReportsTable.tsx b/packages/admin/src/pages/Reports/ReportsTable.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b0131357c73083a567e2aec8f702e75da04d4775
--- /dev/null
+++ b/packages/admin/src/pages/Reports/ReportsTable.tsx
@@ -0,0 +1,88 @@
+import {
+ CircularProgress,
+ Link,
+ Table,
+ TableBody,
+ TableCell,
+ TableColumn,
+ TableHeader,
+ TableRow,
+ User,
+} from "@nextui-org/react";
+import useSWR from "swr";
+import { ReportTag } from "./ReportTag";
+
+interface IPixelReport {
+ status: "NEW" | "RESOLVED" | "REJECTED";
+ id: number;
+ pixelId: number | null;
+ reporterSub: string | null;
+ rules: unknown;
+ comment: string | null;
+ createdAt: string;
+ updatedAt: string;
+
+ pixel: {
+ id: string;
+ x: number;
+ y: number;
+ };
+
+ reporter: {
+ sub: string;
+ username: string;
+ picture_url?: string;
+ profile_url?: string;
+ };
+}
+
+export const ReportsTable = () => {
+ const reports = useSWR<{ reports: IPixelReport[] }>("/api/admin/reports");
+
+ return (
+
+ {reports.isLoading &&
}
+
+
+ ID
+ Status
+ Reporter
+ Pixel
+ Created At
+ Updated At
+ Actions
+
+
+ {(report) => (
+
+ {report.id}
+
+
+
+
+
+
+
+
+ {report.pixelId} @ ({report.pixel.x}, {report.pixel.y})
+
+
+ {report.createdAt}
+ {report.updatedAt}
+ action
+
+ )}
+
+
+
+ );
+};
diff --git a/packages/admin/src/pages/Reports/page.tsx b/packages/admin/src/pages/Reports/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..701ade2075e72a285e2fc5640ecd22e6fb8decd0
--- /dev/null
+++ b/packages/admin/src/pages/Reports/page.tsx
@@ -0,0 +1,44 @@
+import { faFile } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { BreadcrumbItem, Breadcrumbs, Button, Input } from "@nextui-org/react";
+import { ReportsTable } from "./ReportsTable";
+
+export const ReportsPage = () => {
+ return (
+
+
+ Home
+ Accounts
+
+
+
All Accounts
+
+
+
+ {/*
+
+
+ */}
+
+
+ {/*
*/}
+
}
+ >
+ Export to CSV
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/client/package.json b/packages/client/package.json
index c034012d4aa2427a15859fb9a2a571639212f458..92d7308c91b07dae2b2a0430c45c1e52b1244ed0 100644
--- a/packages/client/package.json
+++ b/packages/client/package.json
@@ -24,7 +24,8 @@
"lodash.throttle": "^4.1.1",
"prop-types": "^15.8.1",
"react-zoom-pan-pinch": "^3.4.1",
- "socket.io-client": "^4.8.1"
+ "socket.io-client": "^4.8.1",
+ "swr": "^2.3.3"
},
"devDependencies": {
"@types/grecaptcha": "^3.0.9",
diff --git a/packages/client/src/components/App.tsx b/packages/client/src/components/App.tsx
index f4782a88cd0213c369d149c5d21ac141545bf6e1..e3e188e136c59aefc0f9fdea3c8685c0a4a83890 100644
--- a/packages/client/src/components/App.tsx
+++ b/packages/client/src/components/App.tsx
@@ -6,12 +6,13 @@ import { SettingsSidebar } from "./Settings/SettingsSidebar";
import { DebugModal } from "./Debug/DebugModal";
import { ToolbarWrapper } from "./Toolbar/ToolbarWrapper";
import { useEffect } from "react";
+import { SWRConfig } from "swr";
import { ChatContext } from "../contexts/ChatContext";
import "react-toastify/dist/ReactToastify.css";
import { AuthErrors } from "./AuthErrors";
import "../lib/keybinds";
-import { PixelWhoisSidebar } from "./PixelWhoisSidebar";
+import { PixelWhoisSidebar } from "./PixelSidebar/PixelWhoisSidebar";
import { KeybindModal } from "./KeybindModal";
import { ProfileModal } from "./Profile/ProfileModal";
import { WelcomeModal } from "./Welcome/WelcomeModal";
@@ -160,13 +161,20 @@ const AppInner = () => {
const App = () => {
return (
-
-
-
-
-
-
-
+
+ fetch(resource, init).then((res) => res.json()),
+ }}
+ >
+
+
+
+
+
+
+
+
);
};
diff --git a/packages/client/src/components/Info/InfoRules.tsx b/packages/client/src/components/Info/InfoRules.tsx
index af1fc0e15436b7da7b8906c23fcd8d22306521d6..43f40c28f67375bf6735dbd9abd318585a8dcf23 100644
--- a/packages/client/src/components/Info/InfoRules.tsx
+++ b/packages/client/src/components/Info/InfoRules.tsx
@@ -1,37 +1,44 @@
import { faGavel } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { IRule } from "./InfoText";
+import useSWR from "swr";
export const InfoRules = () => {
+ const {
+ data: meta,
+ error,
+ isLoading,
+ } = useSWR<{ rules: IRule[] }>("/api/info");
+
return (
-
-
-
- No alternate accounts
- We want to keep it fair and not require people to create more
- accounts to defend their art
-
-
- No bots/automated placements
- We're land of the humans, not bots
-
-
- No hate speech or adjacent
-
-
- No gore or nudity (NSFW/NSFL)
-
-
-
- This canvas is built upon good faith rules, therefore moderators have
- complete discretion on the rules. If you have any questions, ask in
- the Matrix space or the Discord
-
-
+ {isLoading
+ ? "Loading"
+ : error || (
+
+
+ {meta?.rules.map((rule) => (
+
+ {rule.name}
+ {rule.description && (
+
+ {rule.description}
+
+ )}
+
+ ))}
+
+
+ This canvas is built upon good faith rules, therefore moderators
+ have complete discretion on the rules. If you have any
+ questions, ask in the Matrix space or the Discord
+
+
+ )}
- )
-};
\ No newline at end of file
+ );
+};
diff --git a/packages/client/src/components/Info/InfoText.tsx b/packages/client/src/components/Info/InfoText.tsx
index d92ecaf58228b1509dfcc9da6bbf6ab560b7968f..503bbedafdc4476393829f00b493803d2112fb3e 100644
--- a/packages/client/src/components/Info/InfoText.tsx
+++ b/packages/client/src/components/Info/InfoText.tsx
@@ -2,6 +2,13 @@ import { InfoPrivacy } from "./InfoPrivacy";
import { InfoRules } from "./InfoRules";
import { InfoWelcome } from "./InfoWelcome";
+export interface IRule {
+ id: string;
+ order: number;
+ name: string;
+ description: string | null;
+}
+
export const InfoText = () => {
return (
@@ -9,6 +16,5 @@ export const InfoText = () => {
- )
+ );
};
-
diff --git a/packages/client/src/components/PixelWhoisSidebar.tsx b/packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx
similarity index 63%
rename from packages/client/src/components/PixelWhoisSidebar.tsx
rename to packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx
index 64560b827fb87f2ced5a08327d61289383104d46..473644c9aa445feeccbb56a1e7a2d6334319bfa1 100644
--- a/packages/client/src/components/PixelWhoisSidebar.tsx
+++ b/packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx
@@ -1,12 +1,15 @@
import { Button, Spinner } from "@nextui-org/react";
-import { useAppContext } from "../contexts/AppContext";
+import { useAppContext } from "../../contexts/AppContext";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
-import { ComponentPropsWithoutRef, useEffect, useRef, useState } from "react";
-import { api } from "../lib/utils";
-import { UserCard } from "./Profile/UserCard";
+import { useEffect, useState } from "react";
+import { api } from "../../lib/utils";
+import { UserCard } from "../Profile/UserCard";
+import { SmallCanvas } from "./SmallCanvas";
+import { ReportPixelModal } from "./ReportPixelModal";
interface IPixel {
+ id: number;
userId: string;
x: number;
y: number;
@@ -40,6 +43,7 @@ export const PixelWhoisSidebar = () => {
user: IUser | null;
instance: IInstance | null;
}>();
+ const [report, setReport] = useState(false);
useEffect(() => {
if (!pixelWhois) return;
@@ -118,67 +122,16 @@ export const PixelWhoisSidebar = () => {
+ setReport(true)}>
+ Report Pixel
+
+
+ setReport(v)}
+ pixel={whois?.pixel}
+ />
);
};
-
-const SmallCanvas = ({
- surrounding,
- ...props
-}: {
- surrounding: string[][] | undefined;
-} & ComponentPropsWithoutRef<"canvas">) => {
- const canvasRef = useRef(null);
-
- useEffect(() => {
- if (!canvasRef.current) {
- console.warn("[SmallCanvas] canvasRef unavailable");
- return;
- }
-
- const ctx = canvasRef.current.getContext("2d");
- if (!ctx) {
- console.warn("[SmallCanvas] canvas context unavailable");
- return;
- }
-
- ctx.fillStyle = "#fff";
- ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height);
-
- ctx.fillStyle = "rgba(0,0,0,0.2)";
- ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height);
-
- if (surrounding) {
- const PIXEL_WIDTH = canvasRef.current.width / surrounding[0].length;
- const middle: [x: number, y: number] = [
- Math.floor(surrounding[0].length / 2),
- Math.floor(surrounding.length / 2),
- ];
-
- for (let y = 0; y < surrounding.length; y++) {
- for (let x = 0; x < surrounding[y].length; x++) {
- let color = surrounding[y][x];
- ctx.beginPath();
- ctx.rect(x * PIXEL_WIDTH, y * PIXEL_WIDTH, PIXEL_WIDTH, PIXEL_WIDTH);
-
- ctx.fillStyle = color;
- ctx.fill();
- }
- }
-
- ctx.beginPath();
- ctx.rect(
- middle[0] * PIXEL_WIDTH,
- middle[1] * PIXEL_WIDTH,
- PIXEL_WIDTH,
- PIXEL_WIDTH
- );
- ctx.strokeStyle = "#f00";
- ctx.lineWidth = 4;
- ctx.stroke();
- }
- }, [surrounding]);
-
- return ;
-};
diff --git a/packages/client/src/components/PixelSidebar/ReportPixelModal.tsx b/packages/client/src/components/PixelSidebar/ReportPixelModal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5f3aa699818bdf04652cdf5a1c9e565305a65831
--- /dev/null
+++ b/packages/client/src/components/PixelSidebar/ReportPixelModal.tsx
@@ -0,0 +1,160 @@
+import {
+ Alert,
+ Button,
+ Checkbox,
+ CheckboxGroup,
+ Input,
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+} from "@nextui-org/react";
+import useSWR from "swr";
+import { IRule } from "../Info/InfoText";
+import { useCallback, useEffect, useState } from "react";
+import { Canvas } from "../../lib/canvas";
+import { SmallCanvas } from "./SmallCanvas";
+import { api } from "../../lib/utils";
+import { toast } from "react-toastify";
+
+interface IPixel {
+ id: number;
+ userId: string;
+ x: number;
+ y: number;
+ color: string;
+ createdAt: Date;
+}
+
+export const ReportPixelModal = ({
+ open,
+ onOpen,
+ pixel,
+}: {
+ open: boolean;
+ onOpen: (v: boolean) => any;
+ pixel?: IPixel;
+}) => {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState();
+ const [surrounding, setSurrounding] = useState();
+ const meta = useSWR<{ rules: IRule[] }>("/api/info");
+
+ const [rules, setRules] = useState([]);
+ const [comment, setComment] = useState("");
+
+ useEffect(() => {
+ setError(undefined);
+ }, [open]);
+
+ useEffect(() => {
+ if (pixel && Canvas.instance) {
+ const surrounding = Canvas.instance.getSurroundingPixels(
+ pixel.x,
+ pixel.y,
+ 3
+ );
+
+ setSurrounding(surrounding);
+ } else {
+ setSurrounding(undefined);
+ }
+ }, [open, pixel]);
+
+ const doSubmit = useCallback(() => {
+ setLoading(true);
+ if (!pixel) {
+ alert("pixel is not defined");
+ return;
+ }
+
+ api("/api/reports", "POST", {
+ pixelId: pixel.id,
+ rules,
+ comment,
+ })
+ .then(({ status, data }) => {
+ if (status === 201 && data.success) {
+ toast.success("Report has been created, thank you");
+ onOpen(false);
+ } else {
+ if (data.success) {
+ } else {
+ setError(data.error_message || data.error);
+ }
+ }
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }, [comment, onOpen, pixel, rules]);
+
+ return (
+
+
+ {(onClose) => (
+ <>
+
+ Report Pixel
+
+
+ {error && (
+
+ )}
+ {surrounding && (
+
+ )}
+ {pixel && (
+
+ )}
+ setRules(v)}
+ >
+ {meta.data?.rules.map((rule) => (
+
+ {rule.name}
+
+ ))}
+ Other (describe below)
+
+ -1}
+ value={comment}
+ onValueChange={(v) => setComment(v)}
+ />
+
+
+ Close
+ -1 && comment.length < 5)
+ }
+ >
+ Submit
+
+
+ >
+ )}
+
+
+ );
+};
diff --git a/packages/client/src/components/PixelSidebar/SmallCanvas.tsx b/packages/client/src/components/PixelSidebar/SmallCanvas.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b7bae570020469631798905c78a4996ce989c4b9
--- /dev/null
+++ b/packages/client/src/components/PixelSidebar/SmallCanvas.tsx
@@ -0,0 +1,68 @@
+import { ComponentPropsWithoutRef, useRef, useEffect } from "react";
+
+export const SmallCanvas = ({
+ surrounding,
+ ...props
+}: {
+ surrounding: string[][] | undefined;
+} & ComponentPropsWithoutRef<"canvas">) => {
+ const canvasRef = useRef(null);
+
+ useEffect(() => {
+ if (!canvasRef.current) {
+ console.warn("[SmallCanvas] canvasRef unavailable");
+ return;
+ }
+
+ const ctx = canvasRef.current.getContext("2d");
+ if (!ctx) {
+ console.warn("[SmallCanvas] canvas context unavailable");
+ return;
+ }
+
+ ctx.fillStyle = "#fff";
+ ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height);
+
+ ctx.fillStyle = "rgba(0,0,0,0.2)";
+ ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height);
+
+ if (surrounding) {
+ const PIXEL_WIDTH = canvasRef.current.width / surrounding[0].length;
+ const middle: [x: number, y: number] = [
+ Math.floor(surrounding[0].length / 2),
+ Math.floor(surrounding.length / 2),
+ ];
+
+ for (let y = 0; y < surrounding.length; y++) {
+ for (let x = 0; x < surrounding[y].length; x++) {
+ let color = surrounding[y][x];
+ ctx.beginPath();
+ ctx.rect(x * PIXEL_WIDTH, y * PIXEL_WIDTH, PIXEL_WIDTH, PIXEL_WIDTH);
+
+ ctx.fillStyle = color;
+ ctx.fill();
+ }
+ }
+
+ ctx.beginPath();
+ ctx.rect(
+ middle[0] * PIXEL_WIDTH,
+ middle[1] * PIXEL_WIDTH,
+ PIXEL_WIDTH,
+ PIXEL_WIDTH
+ );
+ ctx.strokeStyle = "#f00";
+ ctx.lineWidth = 4;
+ ctx.stroke();
+ }
+ }, [surrounding]);
+
+ return (
+ (canvasRef.current = r)}
+ {...props}
+ />
+ );
+};
diff --git a/packages/client/src/lib/utils.ts b/packages/client/src/lib/utils.ts
index dabb4461f4d6b35eca44ed2dbcd42c891ab83e4b..21dee1342d4413f05ff6547535471ad80e75615a 100644
--- a/packages/client/src/lib/utils.ts
+++ b/packages/client/src/lib/utils.ts
@@ -38,7 +38,9 @@ export const api = async (
body?: unknown
): Promise<{
status: number;
- data: ({ success: true } & T) | { success: false; error: Error };
+ data:
+ | ({ success: true } & T)
+ | { success: false; error: Error; error_message?: string };
}> => {
const req = await fetch(endpoint, {
method,
diff --git a/packages/client/src/style.scss b/packages/client/src/style.scss
index a70de21b8d9645655fcbd8946b0317adcdd4d8ac..7d3341078dd9f9f0354218a244131d2a1047283b 100644
--- a/packages/client/src/style.scss
+++ b/packages/client/src/style.scss
@@ -147,7 +147,7 @@ main {
.sidebar {
position: fixed;
top: 0;
- z-index: 9998;
+ z-index: 40;
height: 100%;
min-width: 20rem;
diff --git a/packages/server/package.json b/packages/server/package.json
index fe2ec60d1cf78e0548facf48c0e1c79bf52e99db..66ee95b9098768d3f3ac342ae3eb25e99d6af846 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -3,6 +3,7 @@
"version": "1.0.0",
"scripts": {
"dev": "DOTENV_CONFIG_PATH=.env.local tsx watch -r dotenv/config src/index.ts",
+ "dev:prisma:generate": "dotenv -e .env.local -- prisma generate",
"start": "node --enable-source-maps dist/index.js",
"profiler": "node --inspect=0.0.0.0:9229 --enable-source-maps dist/index.js",
"build": "tsc",
@@ -57,6 +58,7 @@
"rate-limit-redis": "^4.2.0",
"redis": "^4.7.0",
"socket.io": "^4.8.1",
- "winston": "^3.17.0"
+ "winston": "^3.17.0",
+ "zod": "^3.25.30"
}
}
diff --git a/packages/server/prisma/migrations/20250518194850_add_rules/migration.sql b/packages/server/prisma/migrations/20250518194850_add_rules/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..797c99e8aaf72c2d3ffacbf54229081a8cae5a10
--- /dev/null
+++ b/packages/server/prisma/migrations/20250518194850_add_rules/migration.sql
@@ -0,0 +1,26 @@
+-- CreateTable
+CREATE TABLE "PixelReport" (
+ "id" SERIAL NOT NULL,
+ "pixelId" INTEGER,
+ "reporterSub" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "PixelReport_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Rule" (
+ "id" TEXT NOT NULL,
+ "order" INTEGER NOT NULL,
+ "name" TEXT NOT NULL,
+ "description" TEXT,
+
+ CONSTRAINT "Rule_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "PixelReport" ADD CONSTRAINT "PixelReport_pixelId_fkey" FOREIGN KEY ("pixelId") REFERENCES "Pixel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PixelReport" ADD CONSTRAINT "PixelReport_reporterSub_fkey" FOREIGN KEY ("reporterSub") REFERENCES "User"("sub") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/server/prisma/migrations/20250527011102_pixelreport_extra_props/migration.sql b/packages/server/prisma/migrations/20250527011102_pixelreport_extra_props/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..cef18ffdf6e655586ad33366d14988d35e40ba3b
--- /dev/null
+++ b/packages/server/prisma/migrations/20250527011102_pixelreport_extra_props/migration.sql
@@ -0,0 +1,9 @@
+/*
+ Warnings:
+
+ - Added the required column `rules` to the `PixelReport` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- AlterTable
+ALTER TABLE "PixelReport" ADD COLUMN "comment" TEXT,
+ADD COLUMN "rules" JSONB NOT NULL;
diff --git a/packages/server/prisma/migrations/20250529030709_add_report_status/migration.sql b/packages/server/prisma/migrations/20250529030709_add_report_status/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..87f917b4f2fd98d0f47011334ffde53223631ef5
--- /dev/null
+++ b/packages/server/prisma/migrations/20250529030709_add_report_status/migration.sql
@@ -0,0 +1,5 @@
+-- CreateEnum
+CREATE TYPE "ReportStatus" AS ENUM ('NEW', 'RESOLVED', 'REJECTED');
+
+-- AlterTable
+ALTER TABLE "PixelReport" ADD COLUMN "status" "ReportStatus" NOT NULL DEFAULT 'NEW';
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index 309b404fae0f167214ead09b64367710db3b2a6f..fe172f1a486e6904cb9182dbe96bbf4a1a1c2c25 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -34,6 +34,7 @@ model User {
Ban Ban?
AuditLog AuditLog[]
IPAddress IPAddress[]
+ PixelReport PixelReport[]
}
model Instance {
@@ -75,9 +76,10 @@ model Pixel {
createdAt DateTime @default(now())
deletedAt DateTime?
- user User @relation(fields: [userId], references: [sub])
+ user User @relation(fields: [userId], references: [sub])
// do not add a relation to PaletteColor, in the case the palette gets changed
// https://github.com/prisma/prisma/issues/18058
+ PixelReport PixelReport[]
}
model Faction {
@@ -188,3 +190,31 @@ model AuditLog {
user User? @relation(fields: [userId], references: [sub])
ban Ban? @relation(fields: [banId], references: [id])
}
+
+enum ReportStatus {
+ NEW
+ RESOLVED
+ REJECTED
+}
+
+model PixelReport {
+ id Int @id @default(autoincrement())
+ pixelId Int?
+ reporterSub String?
+ rules Json // Array of Rule IDs
+ comment String?
+ status ReportStatus @default(NEW)
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now()) @updatedAt
+
+ pixel Pixel? @relation(fields: [pixelId], references: [id], onDelete: SetNull)
+ reporter User? @relation(fields: [reporterSub], references: [sub], onDelete: SetNull)
+}
+
+model Rule {
+ id String @id @default(uuid())
+ order Int
+ name String
+ description String?
+}
diff --git a/packages/server/src/api/admin.ts b/packages/server/src/api/admin.ts
index 45e76cc31994ae1c2f15bcd741779f0fe06070a2..458c260b94fbd5881d9a04cc23a76ffcbc4e74c1 100644
--- a/packages/server/src/api/admin.ts
+++ b/packages/server/src/api/admin.ts
@@ -12,12 +12,15 @@ import {
InstanceNotFound,
} from "../models/Instance";
import { User, UserNotBanned, UserNotFound } from "../models/User";
+import { AdminEndpoints } from "./admin/index";
const app = Router();
const Logger = getLogger("HTTP/ADMIN");
app.use(RateLimiter.ADMIN);
+app.use(new AdminEndpoints().router);
+
app.use(async (req, res, next) => {
if (!req.session.user) {
res.status(401).json({
diff --git a/packages/server/src/api/admin/index.ts b/packages/server/src/api/admin/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a8ac669693a47a403edf4487f2d72620d61d7945
--- /dev/null
+++ b/packages/server/src/api/admin/index.ts
@@ -0,0 +1,13 @@
+import { Router } from "../lib/router";
+import { ReportEndpoints } from "./reports";
+import { RulesEndpoints } from "./rules";
+
+@Router.requireAuth("ADMIN", true)
+export class AdminEndpoints extends Router {
+ constructor(..._args: any[]) {
+ super(..._args);
+
+ this.use("/reports", new ReportEndpoints());
+ this.use("/rules", new RulesEndpoints());
+ }
+}
diff --git a/packages/server/src/api/admin/reports.ts b/packages/server/src/api/admin/reports.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e97be6d406c2175a31b75963af128d14228ccdb6
--- /dev/null
+++ b/packages/server/src/api/admin/reports.ts
@@ -0,0 +1,105 @@
+import { z } from "zod/v4";
+
+import { prisma } from "../../lib/prisma";
+import { Router } from "../lib/router";
+
+const UpdateReport = z.object({
+ status: z.enum(["NEW", "RESOLVED", "REJECTED"]),
+});
+type UpdateReport = z.infer;
+
+export class ReportEndpoints extends Router {
+ @Router.handler("get", "/")
+ async getAll(req: Router.Request, res: Router.Response) {
+ // TODO: pagination
+ // TODO: filtering
+
+ const reports = await prisma.pixelReport.findMany({
+ where: {
+ status: "NEW",
+ },
+ orderBy: {
+ createdAt: "asc",
+ },
+ include: {
+ pixel: true,
+ reporter: true,
+ },
+ });
+
+ res.json({
+ success: true,
+ reports,
+ });
+ }
+
+ @Router.handler("get", "/:id")
+ async get(req: Router.Request, res: Router.Response) {
+ const id = parseInt(req.params.id);
+ if (isNaN(id)) {
+ res.status(400).json({
+ success: false,
+ error: "ID is invalid",
+ });
+ return;
+ }
+
+ const report = await prisma.pixelReport.findFirst({
+ where: {
+ id,
+ },
+ });
+
+ if (!report) {
+ res.status(404).json({
+ success: false,
+ error: `Report ${id} is not found`,
+ });
+ return;
+ }
+
+ res.json({
+ success: true,
+ report,
+ });
+ }
+
+ @Router.handler("put", "/:id")
+ @Router.body(UpdateReport)
+ async put(req: Router.Request, res: Router.Response) {
+ const id = parseInt(req.params.id);
+ if (isNaN(id)) {
+ res.status(400).json({
+ success: false,
+ error: "ID is invalid",
+ });
+ return;
+ }
+
+ const report = await prisma.pixelReport.findFirst({
+ where: {
+ id,
+ },
+ });
+
+ if (!report) {
+ res.status(404).json({
+ success: false,
+ error: `Report ${req.params.id} is not found`,
+ });
+ return;
+ }
+
+ const reportUpdate = await prisma.pixelReport.update({
+ where: {
+ id,
+ },
+ data: req.body,
+ });
+
+ res.json({
+ success: true,
+ report: reportUpdate,
+ });
+ }
+}
diff --git a/packages/server/src/api/admin/rules.ts b/packages/server/src/api/admin/rules.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b4d119af5dac233fb4aae9442821b600620fa97a
--- /dev/null
+++ b/packages/server/src/api/admin/rules.ts
@@ -0,0 +1,127 @@
+import { z } from "zod/v4";
+
+import { prisma } from "../../lib/prisma";
+import { Router } from "../lib/router";
+
+const NewRule = z.object({
+ order: z.number(),
+ name: z.string().min(1),
+ description: z.string().optional(),
+});
+type NewRule = z.infer;
+
+const UpdateRule = z.object({
+ order: z.number().optional(),
+ name: z.string().min(1).optional(),
+ description: z.string().optional().nullable(),
+});
+type UpdateRule = z.infer;
+
+export class RulesEndpoints extends Router {
+ @Router.handler("get", "/")
+ async getAll(req: Router.Request, res: Router.Response) {
+ const rules = await prisma.rule.findMany({
+ orderBy: {
+ order: "desc",
+ },
+ });
+ res.json({
+ success: true,
+ rules,
+ });
+ }
+
+ @Router.handler("post", "/")
+ @Router.body(NewRule)
+ async create(req: Router.Request, res: Router.Response) {
+ const rule = await prisma.rule.create({
+ data: {
+ order: req.body.order,
+ name: req.body.name,
+ description: req.body.description,
+ },
+ });
+
+ res.status(201).json({
+ success: true,
+ ruleId: rule.id,
+ });
+ }
+
+ @Router.handler("get", "/:id")
+ async get(req: Router.Request, res: Router.Response) {
+ const rule = await prisma.rule.findFirst({
+ where: {
+ id: req.params.id,
+ },
+ });
+
+ if (!rule) {
+ res.status(404).json({
+ success: false,
+ error: `Rule ${req.params.id} is not found`,
+ });
+ return;
+ }
+
+ res.json({
+ success: true,
+ rule,
+ });
+ }
+
+ @Router.handler("put", "/:id")
+ @Router.body(UpdateRule)
+ async put(req: Router.Request, res: Router.Response) {
+ const rule = await prisma.rule.findFirst({
+ where: {
+ id: req.params.id,
+ },
+ });
+
+ if (!rule) {
+ res.status(404).json({
+ success: false,
+ error: `Rule ${req.params.id} is not found`,
+ });
+ return;
+ }
+
+ const ruleUpdate = await prisma.rule.update({
+ where: { id: rule.id },
+ data: req.body,
+ });
+
+ res.json({
+ success: true,
+ rule: ruleUpdate,
+ });
+ }
+
+ @Router.handler("delete", "/:id")
+ async delete(req: Router.Request, res: Router.Response) {
+ const rule = await prisma.rule.findFirst({
+ where: {
+ id: req.params.id,
+ },
+ });
+
+ if (!rule) {
+ res.status(404).json({
+ success: false,
+ error: `Rule ${req.params.id} is not found`,
+ });
+ return;
+ }
+
+ await prisma.rule.delete({
+ where: {
+ id: rule.id,
+ },
+ });
+
+ res.json({
+ success: true,
+ });
+ }
+}
diff --git a/packages/server/src/api/client.ts b/packages/server/src/api/client.ts
index 0893f9e2cc3284807a60d3878692f0a76c661435..872a35235a82edd8970d99ca577572cd08c5fe03 100644
--- a/packages/server/src/api/client.ts
+++ b/packages/server/src/api/client.ts
@@ -4,6 +4,7 @@ import { CanvasController } from "../controllers/CanvasController";
import { prisma } from "../lib/prisma";
import { RateLimiter } from "../lib/RateLimiter";
import { AuthEndpoints } from "./auth";
+import { ReportEndpoints } from "./client/reports";
import SentryRouter from "./sentry";
const app = Router();
@@ -15,6 +16,8 @@ app.use(SentryRouter);
// register auth endpoints
app.use(AuthEndpoints);
+app.use("/reports", new ReportEndpoints().router);
+
app.get("/canvas/pixel/:x/:y", RateLimiter.HIGH, async (req, res) => {
const x = parseInt(req.params.x);
const y = parseInt(req.params.y);
@@ -102,4 +105,17 @@ app.get("/user/:sub", RateLimiter.HIGH, async (req, res) => {
});
});
+/**
+ * Get info sidebar data
+ * TODO: Caching
+ */
+app.get("/info", async (req, res) => {
+ const rules = await prisma.rule.findMany({ orderBy: { order: "asc" } });
+
+ res.json({
+ success: true,
+ rules,
+ });
+});
+
export default app;
diff --git a/packages/server/src/api/client/reports.ts b/packages/server/src/api/client/reports.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cfb3d0746a0eccd699e8f3d66d9596b65eaaecea
--- /dev/null
+++ b/packages/server/src/api/client/reports.ts
@@ -0,0 +1,67 @@
+import { z } from "zod/v4";
+
+import { prisma } from "../../lib/prisma";
+import { WebhookManager } from "../../managers/WebhookManager";
+import { Router } from "../lib/router";
+
+const NewReport = z.object({
+ pixelId: z.number(),
+ rules: z.string().array().min(1).max(10),
+ comment: z.string().optional(),
+});
+
+type NewReport = z.infer;
+
+export class ReportEndpoints extends Router {
+ @Router.handler("post", "/")
+ @Router.requireAuth("USER")
+ @Router.body(NewReport)
+ async createReport(
+ req: Router.Request,
+ res: Router.Response<{ reportId: number }>
+ ) {
+ // TODO: handle awaited error
+ const pixel = await prisma.pixel.findFirst({
+ where: {
+ id: req.body.pixelId,
+ },
+ });
+
+ if (!pixel) {
+ res.status(400).json({
+ success: false,
+ error: "Pixel not found",
+ });
+ return;
+ }
+
+ for (const ruleId of req.body.rules) {
+ // TODO: handle awaited error
+ const data = await prisma.rule.findFirst({ where: { id: ruleId } });
+ if (!data) {
+ res.status(400).json({
+ success: false,
+ error: `Rule ${ruleId} does not exist`,
+ });
+ return;
+ }
+ }
+
+ // TODO: handle awaited error
+ const report = await prisma.pixelReport.create({
+ data: {
+ pixelId: req.body.pixelId,
+ reporterSub: req.session.user!.user.sub,
+ rules: req.body.rules,
+ comment: req.body.comment,
+ },
+ });
+
+ void WebhookManager.execute("REPORT_CREATE", { report });
+
+ res.status(201).json({
+ success: true,
+ reportId: report.id,
+ });
+ }
+}
diff --git a/packages/server/src/api/lib/router.ts b/packages/server/src/api/lib/router.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0316902f91ce47615c719475bbaac9fbad211981
--- /dev/null
+++ b/packages/server/src/api/lib/router.ts
@@ -0,0 +1,234 @@
+import Express from "express";
+import { PathParams } from "express-serve-static-core";
+import { z } from "zod/v4";
+
+import { User } from "../../models/User";
+
+type HTTPMethod =
+ | "all"
+ | "get"
+ | "post"
+ | "put"
+ | "delete"
+ | "patch"
+ | "options"
+ | "head";
+
+type RouterDecorator = ClassMethodDecoratorContext<
+ Router,
+ (this: Router, req: Express.Request, res: Express.Response) => any
+>;
+
+type RouterHandler = (req: Express.Request, res: Express.Response) => any;
+
+export namespace Router {
+ export type Request = Omit & {
+ body: Body;
+ };
+
+ export type Response<
+ Body = any,
+ Errors extends { error: string; details?: any; error_message?: string } = {
+ error: string;
+ error_message?: string;
+ details?: any;
+ },
+ > = Express.Response<
+ | ({ success: true } & Body)
+ | ({ success: false } & (
+ | {
+ error: "ValidationError";
+ error_message: string;
+ details: z.core.$ZodIssue[];
+ }
+ | Errors
+ ))
+ >;
+}
+
+export class Router {
+ protected _router: Express.Router;
+ protected _premiddleware: Express.RequestHandler[] = [];
+
+ constructor(..._args: any[]) {
+ this._router = Express.Router();
+ // allows for decorators to add middlewares at the beginning
+ this._router.use(async (req, res, next) => {
+ for (const handler of this._premiddleware) {
+ await new Promise((go) => handler(req, res, go));
+ }
+ next();
+ });
+ }
+
+ get router() {
+ return this._router;
+ }
+
+ use(path: PathParams, router: Router) {
+ this._router.use(path, router.router);
+ }
+
+ static handler = (
+ method: Method,
+ route: PathParams
+ ) => {
+ return function (target: RouterHandler, context: RouterDecorator) {
+ context.addInitializer(function () {
+ this._router[method](route, target.bind(this));
+ });
+ return () => {
+ throw new Error("RouteHandler called directly");
+ };
+ };
+ };
+
+ /**
+ * Require auth on specific route or entire class
+ * @param permission
+ * @param onClass If applied to an entire class
+ * @returns
+ */
+ static requireAuth = (
+ permission: Permission = "USER",
+ onClass?: OnClass
+ ): Decorator => {
+ if (onClass) {
+ // @ts-expect-error Ternary type
+ return function (
+ Target: T,
+ _context: ClassDecoratorContext
+ ) {
+ return class extends Target {
+ constructor(...rest: any[]) {
+ super(...rest);
+ this._premiddleware.push(async (req, res, next) => {
+ const result = await rawPermissionCheck(permission, req, res);
+
+ if (result === "OK") next();
+ });
+ }
+ };
+ };
+ }
+
+ // @ts-expect-error Ternary type
+ return function (target: RouterHandler, _context: RouterDecorator) {
+ return async function (
+ this: Router,
+ req: Express.Request,
+ res: Express.Response
+ ) {
+ const result = await rawPermissionCheck(permission, req, res);
+
+ if (result === "OK") return target.bind(this)(req, res);
+ };
+ };
+ };
+
+ /**
+ * Validates req.body by specified Zod schema
+ *
+ * Enforces that `req.body` is the Schema
+ */
+ static body = (
+ schema: Schema,
+ handleError?: RouterHandler
+ ) => {
+ return function (target: RouterHandler, _context: RouterDecorator) {
+ return function (
+ this: Router,
+ req: Express.Request & { body: z.infer },
+ res: Express.Response
+ ) {
+ try {
+ schema.parse(req.body);
+ } catch (err) {
+ if (err instanceof z.ZodError) {
+ if (handleError) {
+ handleError(req, res);
+ } else {
+ res.json({
+ success: false,
+ error: "ValidationError",
+ error_message: z.prettifyError(err),
+ details: err.issues,
+ });
+ }
+ }
+
+ return;
+ }
+
+ return target.bind(this)(req, res);
+ };
+ };
+ };
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
+type ClassMethodDecorator = (
+ target: T,
+ context: ClassMethodDecoratorContext
+) => any;
+type ClassDecorator = (
+ target: T,
+ context: ClassDecoratorContext
+) => any;
+type Decorator = OnClass extends true
+ ? ClassDecorator
+ : ClassMethodDecorator;
+
+type Permission = "USER" | "ADMIN";
+
+const rawPermissionCheck = async (
+ permission: Permission,
+ req: Express.Request,
+ res: Express.Response
+): Promise<"OK" | "END"> => {
+ if (
+ process.env.DEV_ADMIN_TOKEN &&
+ req.headers.authorization === "Bearer " + process.env.DEV_ADMIN_TOKEN
+ ) {
+ req.session.user = {
+ service: {
+ instance: {
+ hostname: "admin.token",
+ },
+ software: {
+ name: "Admin Token",
+ version: "0.0.1",
+ },
+ },
+ user: {
+ sub: "https://admin.token/users/admin",
+ username: "admin@admin.token",
+ },
+ };
+ return "OK";
+ }
+
+ if (!req.session.user) {
+ res.status(401).json({ success: false, error: "Not logged in" });
+ return "END";
+ }
+
+ const user = await User.fromAuthSession(req.session.user);
+ if (!user) {
+ res.status(400).json({
+ success: false,
+ error: "User data does not exist?",
+ });
+ return "END";
+ }
+
+ if (permission === "ADMIN" && !user.isAdmin) {
+ res.status(403).json({
+ success: false,
+ error: "User is not admin",
+ });
+ return "END";
+ }
+
+ return "OK";
+};
diff --git a/packages/server/src/managers/WebhookManager.ts b/packages/server/src/managers/WebhookManager.ts
new file mode 100644
index 0000000000000000000000000000000000000000..24130cde4a6a4bce56a399462e28d27160207006
--- /dev/null
+++ b/packages/server/src/managers/WebhookManager.ts
@@ -0,0 +1,41 @@
+import { PixelReport } from "@prisma/client";
+
+interface Webhooks {
+ REPORT_CREATE: (data: { report: PixelReport }) => void;
+}
+
+export class WebhookManager {
+ private static instance: WebhookManager;
+
+ private constructor() {}
+
+ static get() {
+ if (!this.instance) this.instance = new WebhookManager();
+
+ return this.instance;
+ }
+
+ static execute: WebhookManager["execute"] = (...args: any[]) =>
+ (this.get().execute as any)(...args);
+
+ execute(
+ webhook: Webhook,
+ ...parameters: Parameters
+ ) {
+ const hook = this.getHook(webhook);
+ if (!hook) return Promise.resolve();
+
+ return fetch(hook, {
+ method: "POST",
+ headers: {
+ // TODO: add webhook token/shared secret
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(parameters[0]),
+ });
+ }
+
+ private getHook(webhook: keyof Webhooks): string | undefined {
+ return process.env[`WEBHOOK__${webhook}`];
+ }
+}
diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts
index 3acfecd60f332215d3ad74400d0c905cabdfb162..923877298d058da1f553214f72b8802583699832 100644
--- a/packages/server/src/types.ts
+++ b/packages/server/src/types.ts
@@ -63,6 +63,7 @@ declare global {
AUTH_ENDPOINT: string;
AUTH_CLIENT: string;
AUTH_SECRET: string;
+ DEV_ADMIN_TOKEN?: string;
MATRIX_HOMESERVER: string;
ELEMENT_HOST: string;
@@ -81,6 +82,8 @@ declare global {
SENTRY_DSN?: string;
SENTRY_ENVIRONMENT?: string;
SENTRY_TUNNEL_PROJECT_IDS?: string;
+
+ WEBHOOK__REPORT_CREATE?: string;
}
}
}
diff --git a/packages/server/src/utils/validate_environment.ts b/packages/server/src/utils/validate_environment.ts
index 1046353f8f8cb02b3a8a0f047b597044acad152e..54510709743c924b60a091b1718bfadbeab2b037 100644
--- a/packages/server/src/utils/validate_environment.ts
+++ b/packages/server/src/utils/validate_environment.ts
@@ -118,4 +118,12 @@ if (
process.exit(1);
}
+if (process.env.NODE_ENV !== "development" && process.env.DEV_ADMIN_TOKEN) {
+ // eslint-disable-next-line no-console
+ console.error(
+ "FATAL: DEV_ADMIN_TOKEN is set while not in development environment"
+ );
+ process.exit(1);
+}
+
// #endregion