From b38af35b67a862c2e24786688dc5fe732d0ef20f Mon Sep 17 00:00:00 2001 From: Grant Date: Sat, 10 May 2025 02:02:18 -0600 Subject: [PATCH 1/8] initial UI --- packages/client/src/components/App.tsx | 2 +- .../{ => PixelSidebar}/PixelWhoisSidebar.tsx | 81 +++---------------- .../PixelSidebar/ReportPixelModal.tsx | 34 ++++++++ .../components/PixelSidebar/SmallCanvas.tsx | 68 ++++++++++++++++ packages/client/src/style.scss | 2 +- 5 files changed, 114 insertions(+), 73 deletions(-) rename packages/client/src/components/{ => PixelSidebar}/PixelWhoisSidebar.tsx (62%) create mode 100644 packages/client/src/components/PixelSidebar/ReportPixelModal.tsx create mode 100644 packages/client/src/components/PixelSidebar/SmallCanvas.tsx diff --git a/packages/client/src/components/App.tsx b/packages/client/src/components/App.tsx index f4782a8..fb5cb97 100644 --- a/packages/client/src/components/App.tsx +++ b/packages/client/src/components/App.tsx @@ -11,7 +11,7 @@ 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"; diff --git a/packages/client/src/components/PixelWhoisSidebar.tsx b/packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx similarity index 62% rename from packages/client/src/components/PixelWhoisSidebar.tsx rename to packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx index faa5465..f796e21 100644 --- a/packages/client/src/components/PixelWhoisSidebar.tsx +++ b/packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx @@ -1,10 +1,12 @@ 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 { userId: string; @@ -39,6 +41,7 @@ export const PixelWhoisSidebar = () => { user: IUser | null; instance: IInstance | null; }>(); + const [report, setReport] = useState(false); useEffect(() => { if (!pixelWhois) return; @@ -104,6 +107,7 @@ export const PixelWhoisSidebar = () => {
{whois?.user && }
+
@@ -118,73 +122,8 @@ export const PixelWhoisSidebar = () => {
- - ); -}; - -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} - /> + setReport(v)} /> + ); }; diff --git a/packages/client/src/components/PixelSidebar/ReportPixelModal.tsx b/packages/client/src/components/PixelSidebar/ReportPixelModal.tsx new file mode 100644 index 0000000..7ba1e1c --- /dev/null +++ b/packages/client/src/components/PixelSidebar/ReportPixelModal.tsx @@ -0,0 +1,34 @@ +import { + Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from "@nextui-org/react"; + +export const ReportPixelModal = ({ + open, + onOpen, +}: { + open: boolean; + onOpen: (v: boolean) => any; +}) => { + return ( + + + {(onClose) => ( + <> + + Report Pixel + + report pixel + + + + + )} + + + ); +}; diff --git a/packages/client/src/components/PixelSidebar/SmallCanvas.tsx b/packages/client/src/components/PixelSidebar/SmallCanvas.tsx new file mode 100644 index 0000000..b7bae57 --- /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/style.scss b/packages/client/src/style.scss index a70de21..7d33410 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; -- GitLab From f5fb71d7f3606fbb5bda7fc60022355622bd239c Mon Sep 17 00:00:00 2001 From: Grant Date: Sat, 10 May 2025 02:02:23 -0600 Subject: [PATCH 2/8] [wip] schema --- packages/server/prisma/schema.prisma | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index 731a85a..1dad2b3 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -33,6 +33,7 @@ model User { Ban Ban? AuditLog AuditLog[] IPAddress IPAddress[] + PixelReport PixelReport[] } model Instance { @@ -74,9 +75,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 { @@ -187,3 +189,15 @@ model AuditLog { user User? @relation(fields: [userId], references: [sub]) ban Ban? @relation(fields: [banId], references: [id]) } + +model PixelReport { + id Int @id @default(autoincrement()) + pixelId Int? + reporterSub String? + + 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) +} -- GitLab From 6474318bff171a60559ebfa93564f294c9aae79a Mon Sep 17 00:00:00 2001 From: Grant Date: Sun, 18 May 2025 14:18:50 -0600 Subject: [PATCH 3/8] [wip] rules api methods & dynamic rules listing --- .../client/src/components/Info/InfoRules.tsx | 30 +++++++--------- .../client/src/components/Info/InfoText.tsx | 35 ++++++++++++++++--- .../20250518194850_add_rules/migration.sql | 26 ++++++++++++++ .../prisma/migrations/migration_lock.toml | 2 +- packages/server/prisma/schema.prisma | 7 ++++ packages/server/src/api/admin.ts | 20 +++++++++++ packages/server/src/api/client.ts | 13 +++++++ 7 files changed, 109 insertions(+), 24 deletions(-) create mode 100644 packages/server/prisma/migrations/20250518194850_add_rules/migration.sql diff --git a/packages/client/src/components/Info/InfoRules.tsx b/packages/client/src/components/Info/InfoRules.tsx index af1fc0e..91b02fb 100644 --- a/packages/client/src/components/Info/InfoRules.tsx +++ b/packages/client/src/components/Info/InfoRules.tsx @@ -1,7 +1,8 @@ import { faGavel } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { IRule } from "./InfoText"; -export const InfoRules = () => { +export const InfoRules = ({ rules }: { rules: IRule[] }) => { return (
@@ -10,21 +11,14 @@ export const InfoRules = () => {
    -
  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. + {rules.map((rule) => ( +
  9. +

    {rule.name}

    + {rule.description && ( +

    {rule.description}

    + )} +
  10. + ))}

This canvas is built upon good faith rules, therefore moderators have @@ -33,5 +27,5 @@ export const InfoRules = () => {

- ) -}; \ 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 d92ecaf..dd97b56 100644 --- a/packages/client/src/components/Info/InfoText.tsx +++ b/packages/client/src/components/Info/InfoText.tsx @@ -1,14 +1,39 @@ +import { useEffect, useState } from "react"; 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 = () => { + const [meta, setMeta] = useState<{ + rules: IRule[]; + }>(); + + useEffect(() => { + fetch("/api/info") + .then((a) => a.json()) + .then((data) => { + setMeta(data); + }); + }, []); + return (
- - - + {meta ? ( + <> + + + + + ) : ( + "loading" + )}
- ) + ); }; - 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 0000000..797c99e --- /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/migration_lock.toml b/packages/server/prisma/migrations/migration_lock.toml index fbffa92..648c57f 100644 --- a/packages/server/prisma/migrations/migration_lock.toml +++ b/packages/server/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) +# It should be added in your version-control system (e.g., Git) provider = "postgresql" \ No newline at end of file diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index 1dad2b3..2cb6e40 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -201,3 +201,10 @@ model PixelReport { 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 45e76cc..f9305bb 100644 --- a/packages/server/src/api/admin.ts +++ b/packages/server/src/api/admin.ts @@ -1152,4 +1152,24 @@ app.put("/audit/:id/reason", async (req, res) => { }); }); +/** + * Get all rules + */ +app.get("/rules", async (req, res) => {}); + +/** + * Get specific rule + */ +app.get("/rule/:id", async (req, res) => {}); + +/** + * Update a specific rule + */ +app.put("/rule/:id", async (req, res) => {}); + +/** + * Create a rule + */ +app.post("/rule", async (req, res) => {}); + export default app; diff --git a/packages/server/src/api/client.ts b/packages/server/src/api/client.ts index ea10e6e..02f49bc 100644 --- a/packages/server/src/api/client.ts +++ b/packages/server/src/api/client.ts @@ -287,6 +287,19 @@ 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; export let __TESTING: { -- GitLab From ffd1d9385421f2d87e3066167121dbdd7afbdb5c Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 26 May 2025 20:20:35 -0600 Subject: [PATCH 4/8] Add report modal & report endpoint --- package-lock.json | 42 ++++- packages/client/package.json | 3 +- packages/client/src/components/App.tsx | 22 ++- .../client/src/components/Info/InfoRules.tsx | 49 +++--- .../client/src/components/Info/InfoText.tsx | 25 +-- .../PixelSidebar/PixelWhoisSidebar.tsx | 71 ++------- .../PixelSidebar/ReportPixelModal.tsx | 128 ++++++++++++++- packages/client/src/lib/utils.ts | 4 +- packages/server/package.json | 3 +- .../migration.sql | 9 ++ packages/server/prisma/schema.prisma | 2 + packages/server/src/api/client.ts | 3 + packages/server/src/api/client/reports.ts | 64 ++++++++ packages/server/src/api/lib/router.ts | 146 ++++++++++++++++++ 14 files changed, 456 insertions(+), 115 deletions(-) create mode 100644 packages/server/prisma/migrations/20250527011102_pixelreport_extra_props/migration.sql create mode 100644 packages/server/src/api/client/reports.ts create mode 100644 packages/server/src/api/lib/router.ts diff --git a/package-lock.json b/package-lock.json index 2d349bb..8c81e3e 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", @@ -19838,7 +19874,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 +19952,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/client/package.json b/packages/client/package.json index c034012..92d7308 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 fb5cb97..e3e188e 100644 --- a/packages/client/src/components/App.tsx +++ b/packages/client/src/components/App.tsx @@ -6,6 +6,7 @@ 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"; @@ -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 91b02fb..43f40c2 100644 --- a/packages/client/src/components/Info/InfoRules.tsx +++ b/packages/client/src/components/Info/InfoRules.tsx @@ -1,31 +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"); -export const InfoRules = ({ rules }: { rules: IRule[] }) => { return (

Rules

-
-
    - {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 -

-
+ {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 +

+
+ )}
); }; diff --git a/packages/client/src/components/Info/InfoText.tsx b/packages/client/src/components/Info/InfoText.tsx index dd97b56..503bbed 100644 --- a/packages/client/src/components/Info/InfoText.tsx +++ b/packages/client/src/components/Info/InfoText.tsx @@ -1,4 +1,3 @@ -import { useEffect, useState } from "react"; import { InfoPrivacy } from "./InfoPrivacy"; import { InfoRules } from "./InfoRules"; import { InfoWelcome } from "./InfoWelcome"; @@ -11,29 +10,11 @@ export interface IRule { } export const InfoText = () => { - const [meta, setMeta] = useState<{ - rules: IRule[]; - }>(); - - useEffect(() => { - fetch("/api/info") - .then((a) => a.json()) - .then((data) => { - setMeta(data); - }); - }, []); - return (
- {meta ? ( - <> - - - - - ) : ( - "loading" - )} + + +
); }; diff --git a/packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx b/packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx index 63cfbdc..473644c 100644 --- a/packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx +++ b/packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx @@ -9,6 +9,7 @@ import { SmallCanvas } from "./SmallCanvas"; import { ReportPixelModal } from "./ReportPixelModal"; interface IPixel { + id: number; userId: string; x: number; y: number; @@ -108,7 +109,6 @@ export const PixelWhoisSidebar = () => {
{whois?.user && }
-
@@ -122,69 +122,16 @@ export const PixelWhoisSidebar = () => {
+
- setReport(v)} /> + 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 index 7ba1e1c..5f3aa69 100644 --- a/packages/client/src/components/PixelSidebar/ReportPixelModal.tsx +++ b/packages/client/src/components/PixelSidebar/ReportPixelModal.tsx @@ -1,19 +1,95 @@ 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 ( @@ -22,9 +98,59 @@ export const ReportPixelModal = ({ Report Pixel - 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/lib/utils.ts b/packages/client/src/lib/utils.ts index dabb446..21dee13 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/server/package.json b/packages/server/package.json index fe2ec60..15646be 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -57,6 +57,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/20250527011102_pixelreport_extra_props/migration.sql b/packages/server/prisma/migrations/20250527011102_pixelreport_extra_props/migration.sql new file mode 100644 index 0000000..cef18ff --- /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/schema.prisma b/packages/server/prisma/schema.prisma index df9c3a9..a4a405a 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -195,6 +195,8 @@ model PixelReport { id Int @id @default(autoincrement()) pixelId Int? reporterSub String? + rules Json // Array of Rule IDs + comment String? createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt diff --git a/packages/server/src/api/client.ts b/packages/server/src/api/client.ts index fa2328e..872a352 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); diff --git a/packages/server/src/api/client/reports.ts b/packages/server/src/api/client/reports.ts new file mode 100644 index 0000000..d27d111 --- /dev/null +++ b/packages/server/src/api/client/reports.ts @@ -0,0 +1,64 @@ +import { z } from "zod/v4"; + +import { prisma } from "../../lib/prisma"; +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, + }, + }); + + 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 0000000..a9fc7b8 --- /dev/null +++ b/packages/server/src/api/lib/router.ts @@ -0,0 +1,146 @@ +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; + + constructor() { + this._router = Express.Router(); + } + + get router() { + return this._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"); + }; + }; + }; + + static requireAuth = (permission: "USER" | "ADMIN" = "USER") => { + return function (target: RouterHandler, _context: RouterDecorator) { + return async function ( + this: Router, + req: Express.Request, + res: Express.Response + ) { + if (!req.session.user) { + res.status(401).json({ success: false, error: "Not logged in" }); + return; + } + + const user = await User.fromAuthSession(req.session.user); + if (!user) { + res.status(400).json({ + success: false, + error: "User data does not exist?", + }); + return; + } + + if (permission === "ADMIN" && !user.isAdmin) { + res.status(403).json({ + success: false, + error: "User is not admin", + }); + return; + } + + 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); + }; + }; + }; +} -- GitLab From b02c48d96bcd97131735542bf16e93ea082b44e8 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 26 May 2025 22:11:54 -0600 Subject: [PATCH 5/8] add REPORT_CREATE webhook & WebhookManager implementation --- packages/server/src/api/client/reports.ts | 3 ++ .../server/src/managers/WebhookManager.ts | 41 +++++++++++++++++++ packages/server/src/types.ts | 2 + 3 files changed, 46 insertions(+) create mode 100644 packages/server/src/managers/WebhookManager.ts diff --git a/packages/server/src/api/client/reports.ts b/packages/server/src/api/client/reports.ts index d27d111..cfb3d07 100644 --- a/packages/server/src/api/client/reports.ts +++ b/packages/server/src/api/client/reports.ts @@ -1,6 +1,7 @@ import { z } from "zod/v4"; import { prisma } from "../../lib/prisma"; +import { WebhookManager } from "../../managers/WebhookManager"; import { Router } from "../lib/router"; const NewReport = z.object({ @@ -56,6 +57,8 @@ export class ReportEndpoints extends Router { }, }); + void WebhookManager.execute("REPORT_CREATE", { report }); + res.status(201).json({ success: true, reportId: report.id, diff --git a/packages/server/src/managers/WebhookManager.ts b/packages/server/src/managers/WebhookManager.ts new file mode 100644 index 0000000..24130cd --- /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 3acfecd..6b5a9f4 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -81,6 +81,8 @@ declare global { SENTRY_DSN?: string; SENTRY_ENVIRONMENT?: string; SENTRY_TUNNEL_PROJECT_IDS?: string; + + WEBHOOK__REPORT_CREATE?: string; } } } -- GitLab From 0a03000a8696578c2f5c07284e2a9393644da6eb Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 26 May 2025 22:22:20 -0600 Subject: [PATCH 6/8] Move admin Rules endpoint mockups to new Router implementation mockups --- packages/server/src/api/admin.ts | 21 ++------------------- packages/server/src/api/admin/rules.ts | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 19 deletions(-) create mode 100644 packages/server/src/api/admin/rules.ts diff --git a/packages/server/src/api/admin.ts b/packages/server/src/api/admin.ts index f9305bb..491ced6 100644 --- a/packages/server/src/api/admin.ts +++ b/packages/server/src/api/admin.ts @@ -12,6 +12,7 @@ import { InstanceNotFound, } from "../models/Instance"; import { User, UserNotBanned, UserNotFound } from "../models/User"; +import { RulesEndpoints } from "./admin/rules"; const app = Router(); const Logger = getLogger("HTTP/ADMIN"); @@ -1152,24 +1153,6 @@ app.put("/audit/:id/reason", async (req, res) => { }); }); -/** - * Get all rules - */ -app.get("/rules", async (req, res) => {}); - -/** - * Get specific rule - */ -app.get("/rule/:id", async (req, res) => {}); - -/** - * Update a specific rule - */ -app.put("/rule/:id", async (req, res) => {}); - -/** - * Create a rule - */ -app.post("/rule", async (req, res) => {}); +app.use("/rules", new RulesEndpoints().router); export default app; diff --git a/packages/server/src/api/admin/rules.ts b/packages/server/src/api/admin/rules.ts new file mode 100644 index 0000000..6be013a --- /dev/null +++ b/packages/server/src/api/admin/rules.ts @@ -0,0 +1,23 @@ +import { Router } from "../lib/router"; + +export class RulesEndpoints extends Router { + @Router.handler("get", "/") + @Router.requireAuth("ADMIN") + async getAll(req: Router.Request, res: Router.Response) {} + + @Router.handler("post", "/") + @Router.requireAuth("ADMIN") + async create(req: Router.Request, res: Router.Response) {} + + @Router.handler("get", "/:id") + @Router.requireAuth("ADMIN") + async get(req: Router.Request, res: Router.Response) {} + + @Router.handler("put", "/:id") + @Router.requireAuth("ADMIN") + async put(req: Router.Request, res: Router.Response) {} + + @Router.handler("delete", "/:id") + @Router.requireAuth("ADMIN") + async delete(req: Router.Request, res: Router.Response) {} +} -- GitLab From 9c57c7baa3a7e0d4a6aad39a2463a863cbbf9261 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 29 May 2025 18:33:08 -0600 Subject: [PATCH 7/8] add admin api for reports & rules --- .../migration.sql | 5 + packages/server/prisma/schema.prisma | 9 +- packages/server/src/api/admin.ts | 6 +- packages/server/src/api/admin/index.ts | 13 ++ packages/server/src/api/admin/reports.ts | 98 ++++++++++++ packages/server/src/api/admin/rules.ts | 124 ++++++++++++++-- packages/server/src/api/lib/router.ts | 140 ++++++++++++++---- packages/server/src/types.ts | 1 + .../server/src/utils/validate_environment.ts | 8 + 9 files changed, 364 insertions(+), 40 deletions(-) create mode 100644 packages/server/prisma/migrations/20250529030709_add_report_status/migration.sql create mode 100644 packages/server/src/api/admin/index.ts create mode 100644 packages/server/src/api/admin/reports.ts 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 0000000..87f917b --- /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 a4a405a..fe172f1 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -191,12 +191,19 @@ model AuditLog { ban Ban? @relation(fields: [banId], references: [id]) } +enum ReportStatus { + NEW + RESOLVED + REJECTED +} + model PixelReport { - id Int @id @default(autoincrement()) + 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 diff --git a/packages/server/src/api/admin.ts b/packages/server/src/api/admin.ts index 491ced6..458c260 100644 --- a/packages/server/src/api/admin.ts +++ b/packages/server/src/api/admin.ts @@ -12,13 +12,15 @@ import { InstanceNotFound, } from "../models/Instance"; import { User, UserNotBanned, UserNotFound } from "../models/User"; -import { RulesEndpoints } from "./admin/rules"; +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({ @@ -1153,6 +1155,4 @@ app.put("/audit/:id/reason", async (req, res) => { }); }); -app.use("/rules", new RulesEndpoints().router); - export default app; diff --git a/packages/server/src/api/admin/index.ts b/packages/server/src/api/admin/index.ts new file mode 100644 index 0000000..a8ac669 --- /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 0000000..644b88e --- /dev/null +++ b/packages/server/src/api/admin/reports.ts @@ -0,0 +1,98 @@ +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({ + orderBy: { + createdAt: "asc", + }, + }); + + 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 index 6be013a..b4d119a 100644 --- a/packages/server/src/api/admin/rules.ts +++ b/packages/server/src/api/admin/rules.ts @@ -1,23 +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", "/") - @Router.requireAuth("ADMIN") - async getAll(req: Router.Request, res: Router.Response) {} + 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.requireAuth("ADMIN") - async create(req: Router.Request, res: Router.Response) {} + @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") - @Router.requireAuth("ADMIN") - async get(req: Router.Request, res: Router.Response) {} + 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.requireAuth("ADMIN") - async put(req: Router.Request, res: Router.Response) {} + @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") - @Router.requireAuth("ADMIN") - async delete(req: Router.Request, res: Router.Response) {} + 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/lib/router.ts b/packages/server/src/api/lib/router.ts index a9fc7b8..0316902 100644 --- a/packages/server/src/api/lib/router.ts +++ b/packages/server/src/api/lib/router.ts @@ -22,7 +22,7 @@ type RouterDecorator = ClassMethodDecoratorContext< type RouterHandler = (req: Express.Request, res: Express.Response) => any; export namespace Router { - export type Request = Omit & { + export type Request = Omit & { body: Body; }; @@ -48,15 +48,27 @@ export namespace Router { export class Router { protected _router: Express.Router; + protected _premiddleware: Express.RequestHandler[] = []; - constructor() { + 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 @@ -71,36 +83,45 @@ export class Router { }; }; - static requireAuth = (permission: "USER" | "ADMIN" = "USER") => { + /** + * 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 ) { - if (!req.session.user) { - res.status(401).json({ success: false, error: "Not logged in" }); - return; - } + const result = await rawPermissionCheck(permission, req, res); - const user = await User.fromAuthSession(req.session.user); - if (!user) { - res.status(400).json({ - success: false, - error: "User data does not exist?", - }); - return; - } - - if (permission === "ADMIN" && !user.isAdmin) { - res.status(403).json({ - success: false, - error: "User is not admin", - }); - return; - } - - return target.bind(this)(req, res); + if (result === "OK") return target.bind(this)(req, res); }; }; }; @@ -110,7 +131,7 @@ export class Router { * * Enforces that `req.body` is the Schema */ - static body = ( + static body = ( schema: Schema, handleError?: RouterHandler ) => { @@ -144,3 +165,70 @@ export class Router { }; }; } + +// 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/types.ts b/packages/server/src/types.ts index 6b5a9f4..9238772 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; diff --git a/packages/server/src/utils/validate_environment.ts b/packages/server/src/utils/validate_environment.ts index 1046353..5451070 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 -- GitLab From 5bbfb823dc192b5fbac37fa295146f44ebb7d530 Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 30 May 2025 17:35:21 -0600 Subject: [PATCH 8/8] [wip] admin ui --- package-lock.json | 3 +- packages/admin/package.json | 3 +- .../admin/src/components/sidebar/Sidebar.tsx | 7 ++ packages/admin/src/main.tsx | 21 ++++- .../admin/src/pages/Reports/ReportTag.tsx | 24 +++++ .../admin/src/pages/Reports/ReportsTable.tsx | 88 +++++++++++++++++++ packages/admin/src/pages/Reports/page.tsx | 44 ++++++++++ packages/server/package.json | 1 + packages/server/src/api/admin/reports.ts | 7 ++ 9 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 packages/admin/src/pages/Reports/ReportTag.tsx create mode 100644 packages/admin/src/pages/Reports/ReportsTable.tsx create mode 100644 packages/admin/src/pages/Reports/page.tsx diff --git a/package-lock.json b/package-lock.json index 8c81e3e..8e49ab1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19825,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", diff --git a/packages/admin/package.json b/packages/admin/package.json index 17cf219..4b1339c 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 15a6eb5..0e4b1fa 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 db5f1d4..a70f62c 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 0000000..0e0a7a3 --- /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 0000000..b013135 --- /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 0000000..701ade2 --- /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/server/package.json b/packages/server/package.json index 15646be..66ee95b 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", diff --git a/packages/server/src/api/admin/reports.ts b/packages/server/src/api/admin/reports.ts index 644b88e..e97be6d 100644 --- a/packages/server/src/api/admin/reports.ts +++ b/packages/server/src/api/admin/reports.ts @@ -15,9 +15,16 @@ export class ReportEndpoints extends Router { // TODO: filtering const reports = await prisma.pixelReport.findMany({ + where: { + status: "NEW", + }, orderBy: { createdAt: "asc", }, + include: { + pixel: true, + reporter: true, + }, }); res.json({ -- GitLab