Commit 5da1a13c authored by Grant's avatar Grant
Browse files

Captcha/anti-bot: Altcha

parent b7e49f33
Loading
Loading
Loading
Loading
+543 −886

File changed.

Preview size limit exceeded, changes collapsed.

+1 −1
Original line number Diff line number Diff line
@@ -17,6 +17,7 @@
    "@nextui-org/react": "^2.6.11",
    "@sc07-canvas/lib": "^1.0.0",
    "@theme-toggles/react": "^4.1.0",
    "altcha-lib": "^1.2.0",
    "eventemitter3": "^5.0.1",
    "framer-motion": "^11.3.2",
    "lodash.throttle": "^4.1.1",
@@ -27,7 +28,6 @@
    "vite-plugin-banner": "^0.8.1"
  },
  "devDependencies": {
    "@types/grecaptcha": "^3.0.9",
    "@types/lodash.throttle": "^4.1.9",
    "@types/socket.io-client": "^3.0.0",
    "eslint-plugin-react": "^7.33.2",
+9 −0
Original line number Diff line number Diff line
@@ -3,6 +3,9 @@ import { UserModSidebar } from "./UserModSidebar";
import { KeybindManager } from "../lib/keybinds";
import { ModSidebar } from "./ModSidebar";
import { useHasRole } from "../hooks/useHasRole";
import { ModeratorModule } from "./ModeratorModule";

ModeratorModule.get(); // initialize

const context = createContext<{
  state: IModeratorContext;
@@ -103,6 +106,12 @@ export const ModeratorContext = ({ children }: React.PropsWithChildren) => {
  );

  useEffect(() => {
    if (isMod) {
      ModeratorModule.get().connect();
    } else {
      ModeratorModule.get().socket.disconnect();
    }

    const handleKeybind = () => {
      if (!isMod) {
        console.warn("TOGGLE_MOD_MENU canceled, user is not a moderator");
+47 −0
Original line number Diff line number Diff line
import { Debug } from "@sc07-canvas/lib/src/debug";
import {
  ModClientToServerEvents,
  ModServerToClientEvents,
} from "@sc07-canvas/lib/src/net";
import { io, Socket } from "socket.io-client";

export class ModeratorModule {
  private static instance: ModeratorModule;
  socket: Socket<ModServerToClientEvents, ModClientToServerEvents> = io(
    "/mod",
    { autoConnect: false }
  );

  private constructor() {
    Debug.controllers.set("Moderator", this);

    this.socket.on("connect", () => {
      console.log("connected to mod socket");
    });

    this.socket.on("connect_error", (err) => {
      if (this.socket.active) {
        console.log("disconnected temporarily");
      } else {
        console.log("failed to connect", err);
      }
    });

    this.socket.on("disconnect", () => {
      console.log("disconnected");
    });

    this.socket.on("captcha", (...data) => {
      console.log(...data);
    });
  }

  static get() {
    if (!this.instance) this.instance = new ModeratorModule();
    return this.instance;
  }

  connect() {
    this.socket.connect();
  }
}
+154 −0
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@ import {
import { CalendarDateTime, parseDateTime } from "@internationalized/date";
import { toast } from "react-toastify";
import { EError } from "@sc07-canvas/lib";
import { ModeratorModule } from "./ModeratorModule";

export const UserModSidebar = () => {
  const { state, dispatch } = useModerator();
@@ -81,11 +82,164 @@ const Inner = ({ sub }: { sub: string }) => {
        <AccordionItem title="Send Notice">
          <Notice sub={sub} />
        </AccordionItem>
        <AccordionItem title="Sockets & Captcha">
          <Captcha sub={sub} />
        </AccordionItem>
      </Accordion>
    </>
  );
};

type CaptchaStatus = "WAITING" | "INVALID" | "PASSED";
const CaptchaStatusColors: {
  [k in CaptchaStatus]:
    | "default"
    | "success"
    | "warning"
    | "primary"
    | "secondary"
    | "danger";
} = {
  WAITING: "default",
  INVALID: "danger",
  PASSED: "success",
};

const Captcha = ({ sub }: { sub: string }) => {
  const [status, setStatus] = useState<{
    [k: string]: CaptchaStatus;
  }>({});
  const [socketStatus, setSocketStatus] = useState<{
    [k: string]: "NEW" | "GONE";
  }>({});

  const sockets = useQuery("/mod/user/{sub}/sockets", {
    params: {
      path: {
        sub,
      },
    },
  });

  const sendCaptcha = useCallback(
    (socketId?: string) => {
      oapi.POST(
        socketId
          ? "/mod/user/{sub}/captcha/{socket}"
          : "/mod/user/{sub}/captcha",
        {
          params: {
            path: {
              sub,
              socket: socketId,
            },
          },
        }
      );
    },
    [sub]
  );

  useEffect(() => {
    console.log("captcha & sockets loaded");

    setStatus({});
    const handleStatus = (
      id: number,
      userId: string,
      socketId: string,
      status: CaptchaStatus
    ) => {
      setStatus((v) => ({
        ...v,
        [socketId]: status,
      }));
    };

    const handleConnect = (id: string) => {
      setSocketStatus((v) => ({
        ...v,
        [id]: "NEW",
      }));
    };

    const handleDisconnect = (id: string) => {
      setSocketStatus((v) => ({
        ...v,
        [id]: "GONE",
      }));
    };

    const mod = ModeratorModule.get();

    mod.socket.emit("join", "captcha");
    mod.socket.emit("join", "user:" + sub);
    mod.socket.on("captcha", handleStatus);
    mod.socket.on("socketConnect", handleConnect);
    mod.socket.on("socketDisconnect", handleDisconnect);

    return () => {
      console.log("captcha & sockets unloaded");
      mod.socket.emit("leave", "captcha");
      mod.socket.emit("leave", "user:" + sub);
      mod.socket.off("captcha", handleStatus);
      mod.socket.off("socketConnect", handleConnect);
      mod.socket.off("socketDisconnect", handleDisconnect);
    };
  }, [sub]);

  return (
    <>
      <Button onPress={() => sendCaptcha()}>Send All</Button>
      <Accordion>
        {sockets.data?.sockets.map((socket) => (
          <AccordionItem
            key={socket.id}
            title={
              <>
                {socket.id} <CaptchaChip status={status[socket.id]} />
                <SocketChip status={socketStatus[socket.id]} />
              </>
            }
          >
            <Button size="sm" onPress={() => sendCaptcha(socket.id)}>
              Send Captcha
            </Button>
          </AccordionItem>
        )) || null}
      </Accordion>
    </>
  );
};

const SocketChip = ({ status }: { status?: "NEW" | "GONE" }) => {
  switch (status) {
    case "NEW":
      return (
        <Chip size="sm" color="warning">
          New
        </Chip>
      );
    case "GONE":
      return (
        <Chip size="sm" color="danger">
          Gone
        </Chip>
      );
    default:
      <></>;
  }
};

const CaptchaChip = ({ status }: { status?: CaptchaStatus }) => {
  if (!status) return <></>;
  return (
    <Chip size="sm" color={CaptchaStatusColors[status]}>
      {status}
    </Chip>
  );
};

const Notice = ({ sub }: { sub: string }) => {
  const [loading, setLoading] = useState(false);
  const [title, setTitle] = useState("");
Loading