Skip to content
SocketServer.ts 11 KiB
Newer Older
import http from "node:http";
import * as child from "node:child_process";
import {
  ClientConfig,
  ClientToServerEvents,
  Pixel,
  ServerToClientEvents,
} from "@sc07-canvas/lib/src/net";
import { CanvasLib } from "@sc07-canvas/lib";
import { Server, Socket as RawSocket } from "socket.io";
import { session } from "./Express";
import Canvas from "./Canvas";
import { PaletteColor } from "@prisma/client";
import { prisma } from "./prisma";
Grant's avatar
Grant committed
import { getLogger } from "./Logger";
Grant's avatar
Grant committed
import { Redis } from "./redis";
import { User } from "../models/User";
Grant's avatar
Grant committed
const Logger = getLogger("SOCKET");

// maybe move to a constants file?
const commitHash = child
  .execSync("git rev-parse --short HEAD")
  .toString()
  .trim();

/**
 * get socket.io server config, generated from environment vars
 */
const getSocketConfig = () => {
  // origins that should be permitted
  // origins need to be specifically defined if we want to allow CORS credential usage (cookies)
  const origins: string[] = [];

  if (process.env.CLIENT_ORIGIN) {
    origins.push(process.env.CLIENT_ORIGIN);
  }

  if (origins.length === 0) {
    return undefined;
  }

  return {
    cors: {
      origin: origins,
      credentials: true,
    },
  };
};

// this is terrible, another way to get the client config needs to be found
let PALLETE: PaletteColor[] = [];
const PIXEL_TIMEOUT_MS = 1000;

prisma.paletteColor
  .findMany()
  .then((paletteColors) => {
    PALLETE = paletteColors;
    Logger.info(`Loaded ${paletteColors.length} pallete colors`);
  })
  .catch((e) => {
    Logger.error("Failed to get pallete colors", e);
  });

const getClientConfig = (): ClientConfig => {
  return {
    pallete: {
      colors: PALLETE,
      pixel_cooldown: PIXEL_TIMEOUT_MS,
    },
    canvas: Canvas.getCanvasConfig(),
    chat: {
      enabled: true,
      matrix_homeserver: process.env.MATRIX_HOMESERVER,
      element_host: process.env.ELEMENT_HOST,
      general_alias: process.env.MATRIX_GENERAL_ALIAS,
  };
};

type Socket = RawSocket<ClientToServerEvents, ServerToClientEvents>;

export class SocketServer {
Grant's avatar
Grant committed
  static instance: SocketServer;
  io: Server<ClientToServerEvents, ServerToClientEvents>;

  constructor(server: http.Server) {
Grant's avatar
Grant committed
    SocketServer.instance = this;

    this.io = new Server(server, getSocketConfig());

Grant's avatar
Grant committed
    this.setupMasterShard();

    this.io.engine.use(session);
    this.io.on("connection", this.handleConnection.bind(this));
Grant's avatar
Grant committed

    // pixel stacking
    // - needs to be exponential (takes longer to aquire more pixels stacked)
    // - convert to config options instead of hard-coded
    setInterval(async () => {
Grant's avatar
Grant committed
      const DEBUG = false;

      if (DEBUG) Logger.debug("Running pixel stacking...");
Grant's avatar
Grant committed
      const redis = await Redis.getClient();
      const sockets = await this.io.local.fetchSockets();

      for (const socket of sockets) {
        const sub = await redis.get(Redis.key("socketToSub", socket.id));
        if (!sub) {
Grant's avatar
Grant committed
          if (DEBUG) Logger.warn(`Socket ${socket.id} has no user`);
Grant's avatar
Grant committed
          continue;
        }

        const user = await User.fromSub(sub);
        if (!user) {
Grant's avatar
Grant committed
          if (DEBUG)
            Logger.warn(
              `Socket ${socket.id}'s user (${sub}) does not exist in the database`
            );
Grant's avatar
Grant committed
          continue;
        }

        // time in seconds since last pixel placement
        // TODO: this causes a mismatch between placement times
        //       - going from 0 stack to 6 stack has a steady increase between each
        //       - going from 3 stack to 6 stack takes longer
        const timeSinceLastPlace =
          (Date.now() - user.lastPixelTime.getTime()) / 1000;
        const cooldown = CanvasLib.getPixelCooldown(
          user.pixelStack + 1,
          getClientConfig()
        );

        // this impl has the side affect of giving previously offline users all the stack upon reconnecting
        if (
          timeSinceLastPlace >= cooldown &&
          user.pixelStack < getClientConfig().canvas.pixel.maxStack
        ) {
          await user.modifyStack(1);
Grant's avatar
Grant committed

          if (DEBUG)
            Logger.debug(sub + " has gained another pixel in their stack");
Grant's avatar
Grant committed
        }
      }
    }, 1000);
Grant's avatar
Grant committed
  /**
   * Broadcast config to all connected clients
   *
   * Used by canvas size updates
   */
  broadcastConfig() {
    this.io.emit("config", getClientConfig());
  }

Grant's avatar
Grant committed
  async handleConnection(socket: Socket) {
    const user =
      socket.request.session.user &&
      (await User.fromAuthSession(socket.request.session.user));
    Logger.debug(
      `Socket ${socket.id} connection ` + (user ? "@" + user.sub : "No Auth")
    );

    user?.sockets.add(socket);
    Logger.debug("handleConnection " + user?.sockets.size);

    Redis.getClient().then((redis) => {
      if (user) redis.set(Redis.key("socketToSub", socket.id), user.sub);
    });

    if (socket.request.session.user) {
      // inform the client of their session if it exists
      socket.emit("user", socket.request.session.user);
Grant's avatar
Grant committed
    }

    if (user) {
      socket.emit("availablePixels", user.pixelStack);
      socket.emit("pixelLastPlaced", user.lastPixelTime.getTime());
Grant's avatar
Grant committed
      const ban = user.getBan();
      socket.emit(
        "standing",
Grant's avatar
Grant committed
        ban
Grant's avatar
Grant committed
              until: ban.expires.toISOString(),
              reason: ban.publicNote || undefined,
    }

    socket.emit("config", getClientConfig());
    {
      let _clientNotifiedAboutCache = false;
      Canvas.isPixelArrayCached().then((cached) => {
        if (!cached) {
          _clientNotifiedAboutCache = true;
          socket.emit("alert", {
            id: "canvas_cache_pending",
            is: "toast",
            action: "system",
            severity: "info",
            title: "Canvas loading",
            body: "Canvas not cached, this may take a couple seconds",
            autoDismiss: true,
          });
        }
      });
      Canvas.getPixelsArray().then((pixels) => {
        socket.emit("canvas", pixels);
        socket.emit("alert_dismiss", "canvas_cache_pending");
        socket.emit("alert", {
          is: "toast",
          action: "system",
          severity: "success",
          title: "Canvas loaded!",
          autoDismiss: true,
        });
      });
    }
Grant's avatar
Grant committed
    socket.on("disconnect", () => {
      Logger.debug(`Socket ${socket.id} disconnected`);

      user?.sockets.delete(socket);

      Redis.getClient().then((redis) => {
        if (user) redis.del(Redis.key("socketToSub", socket.id));
      });
    });

    socket.on("place", async (pixel, bypassCooldown, ack) => {
Grant's avatar
Grant committed
      if (getClientConfig().canvas.frozen) {
        ack({ success: false, error: "canvas_frozen" });
        return;
      }

      if (!user) {
        ack({ success: false, error: "no_user" });
        return;
      }

Grant's avatar
Grant committed
      if (
        pixel.x < 0 ||
        pixel.y < 0 ||
        pixel.x >= getClientConfig().canvas.size[0] ||
        pixel.y >= getClientConfig().canvas.size[1]
      ) {
        ack({ success: false, error: "invalid_pixel" });
        return;
Grant's avatar
Grant committed
      // force a user data update
      await user.update(true);

      if (bypassCooldown && !user.isModerator) {
        // only moderators can do this
        ack({ success: false, error: "invalid_pixel" });
        return;
      }

      if (!bypassCooldown && user.pixelStack < 1) {
Grant's avatar
Grant committed
        ack({ success: false, error: "pixel_cooldown" });
        return;
      }

Grant's avatar
Grant committed
      if ((user.getBan()?.expires || 0) > new Date()) {
        ack({ success: false, error: "banned" });
        return;
      }

      const paletteColor = await prisma.paletteColor.findFirst({
        where: {
          id: pixel.color,
        },
      });
Grant's avatar
Grant committed
      if (!paletteColor) {
        ack({
          success: false,
Grant's avatar
Grant committed
          error: "palette_color_invalid",
      const pixelAtTheSameLocation = await Canvas.getPixel(pixel.x, pixel.y);

      if (
        pixelAtTheSameLocation &&
        pixelAtTheSameLocation.userId === user.sub &&
        pixelAtTheSameLocation.color === paletteColor.hex
      ) {
        ack({ success: false, error: "you_already_placed_that" });
        return;
      }

      await user.modifyStack(-1);
Grant's avatar
Grant committed
      await Canvas.setPixel(
        user,
        pixel.x,
        pixel.y,
        paletteColor.hex,
        bypassCooldown
      );
Grant's avatar
Grant committed
      // give undo capabilities
      await user.setUndo(
        new Date(Date.now() + Canvas.getCanvasConfig().undo.grace_period)
      );

      const newPixel: Pixel = {
        x: pixel.x,
        y: pixel.y,
        color: pixel.color,
      };
      ack({
        success: true,
        data: newPixel,
      });
      socket.broadcast.emit("pixel", newPixel);
    });
Grant's avatar
Grant committed

    socket.on("undo", async (ack) => {
Grant's avatar
Grant committed
      if (getClientConfig().canvas.frozen) {
        ack({ success: false, error: "canvas_frozen" });
        return;
      }

Grant's avatar
Grant committed
      if (!user) {
        ack({ success: false, error: "no_user" });
        return;
      }

      await user.update(true);

      if (!user.undoExpires) {
        // user has no undo available
        ack({ success: false, error: "unavailable" });
        return;
      }

      const isExpired = user.undoExpires.getTime() - Date.now() < 0;

      if (isExpired) {
        // expiration date is in the past, so no undo is available
        ack({ success: false, error: "unavailable" });
        return;
      }

      // find most recent pixel
      const pixel = await prisma.pixel.findFirst({
        where: { userId: user.sub },
        orderBy: { createdAt: "desc" },
      });

      if (!pixel) {
        // user doesn't have a pixel, idk how we got here, but they can't do anything
        ack({ success: false, error: "unavailable" });
        return;
      }

Grant's avatar
Grant committed
      // delete most recent pixel
      try {
        await Canvas.undoPixel(pixel);
      } catch (e) {
        ack({ success: false, error: "pixel_covered" });
        return;
      }

Grant's avatar
Grant committed
      // mark the undo as used
      await user.setUndo();

      // trigger re-cache on redis
      await Canvas.refreshPixel(pixel.x, pixel.y);

      ack({ success: true, data: {} });
    });

    socket.on("subscribe", (topic) => {
      socket.join("sub:" + topic);
    });

    socket.on("unsubscribe", (topic) => {
      socket.leave("sub:" + topic);
    });
Grant's avatar
Grant committed
   * Master Shard (need better name)
   * This shard should be in charge of all user management, allowing for syncronized events
   *
   * Events:
   * - online people announcement
   *
   * this does work with multiple socket.io instances, so this needs to only be executed by one shard
   */
Grant's avatar
Grant committed
  async setupMasterShard() {
Grant's avatar
Grant committed
    // online announcement event
    setInterval(async () => {
Grant's avatar
Grant committed
      // possible issue: this includes every connected socket, not user count
      const sockets = await this.io.sockets.fetchSockets();
      for (const socket of sockets) {
        socket.emit("online", { count: sockets.length });
      }
    }, 5000);
Grant's avatar
Grant committed

    const redis = await Redis.getClient("SUB");
    redis.subscribe(Redis.key("channel_heatmap"), (message, channel) => {
      this.io.to("sub:heatmap").emit("heatmap", message);
    });