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

+
+
+ + {/* + + + */} +
+
+ {/* */} + +
+
+
+ +
+
+ ); +}; 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 (

Rules

-
-
    -
  1. -

    No alternate accounts

    -

    We want to keep it fair and not require people to create more - accounts to defend their art

    -
  2. -
  3. -

    No bots/automated placements

    -

    We're land of the humans, not bots

    -
  4. -
  5. -

    No hate speech or adjacent

    -
  6. -
  7. -

    No gore or nudity (NSFW/NSFL)

    -
  8. -
-

- 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) => ( +
  1. +

    {rule.name}

    + {rule.description && ( +

    + {rule.description} +

    + )} +
  2. + ))} +
+

+ 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(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)} + /> +
+ + + + + + )} +
+
+ ); +}; 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