diff --git a/.gitlab/ci/deploy.yml b/.gitlab/ci/deploy.yml index 9606b8e71c5f37b84642937ca8fb0f9087da55c8..9426685d345b2abbfe0686d7f173ddcdc240cc0c 100644 --- a/.gitlab/ci/deploy.yml +++ b/.gitlab/ci/deploy.yml @@ -12,6 +12,7 @@ publish: script: - | docker build --tag $REGISTRY/$IMAGE_NAME \ + --build-arg VERSION=$CI_COMMIT_SHA \ --build-arg SENTRY_URL=$SENTRY_URL \ --build-arg SENTRY_ORG=$SENTRY_ORG \ --build-arg CLIENT_SENTRY_PROJECT=$CLIENT_SENTRY_PROJECT \ diff --git a/Dockerfile b/Dockerfile index 3af81c595c97e0e3befadc24da63121956a18a4c..b6b62df49cf2ecc73ac0f74f5c2778327f749fb0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,5 @@ FROM node:23-alpine AS base -# to be able to read git hash -RUN apk -U upgrade && apk add --no-cache git openssh -RUN git config --global --add safe.directory /home/node/app - FROM base as dev_dep RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app WORKDIR /home/node/app @@ -50,6 +46,7 @@ ARG SERVER_SENTRY_PROJECT ARG SENTRY_URL ARG SENTRY_ORG ARG SENTRY_AUTH_TOKEN +ARG VERSION COPY --from=dev_dep --chown=node:node /home/node/app/ ./ COPY --chown=node:node . . @@ -87,7 +84,7 @@ RUN if [ -n "$SENTRY_AUTH_TOKEN" ]; then npm -w packages/server run sentry; fi FROM base as run WORKDIR /home/node/app COPY --from=dep /home/node/app/ ./ -COPY package*.json docker-start*.sh .git ./ +COPY package*.json docker-start*.sh ./ # --- prepare lib --- @@ -108,6 +105,11 @@ COPY --from=build /home/node/app/packages/admin/dist ./packages/admin/ # --- prepare server --- RUN mkdir -p packages/server + +ARG VERSION +ENV VERSION_PATH=/home/node/app/packages/server/.version +RUN echo "${VERSION}" > ./packages/server/.version + COPY --from=build /home/node/app/packages/server/package.json ./packages/server/ COPY --from=build /home/node/app/packages/server/prisma ./packages/server/prisma COPY --from=build /home/node/app/packages/server/tool.sh ./packages/server/ diff --git a/docker-compose.yml b/docker-compose.yml index 15e89a7615f7033fcafab79ba9eeb38652f978f6..3a4944ae58cbe2bd5d9226dffb6521fadaee5e2f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,27 +25,26 @@ services: image: sc07/canvas build: . environment: + - NODE_TYPE=worker - REDIS_HOST=redis://redis - DATABASE_URL=postgres://postgres@postgres/canvas env_file: - .env.local depends_on: - canvas - command: ./docker-start-worker.sh redis: restart: always image: redis:7-alpine healthcheck: - test: ['CMD', 'redis-cli', 'ping'] + test: ["CMD", "redis-cli", "ping"] volumes: - ./data/redis:/data postgres: restart: always image: postgres:14-alpine healthcheck: - test: ['CMD', 'pg_isready', '-U', 'postgres'] + test: ["CMD", "pg_isready", "-U", "postgres"] volumes: - ./data/postgres:/var/lib/postgresql/data environment: - - 'POSTGRES_HOST_AUTH_METHOD=trust' - + - "POSTGRES_HOST_AUTH_METHOD=trust" diff --git a/docker-start-worker.sh b/docker-start-worker.sh deleted file mode 100644 index 93ab26dc00982339d9e831ea952bdfd9dbea1f86..0000000000000000000000000000000000000000 --- a/docker-start-worker.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -./packages/server/tool.sh start_job_worker \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8e49ab1b5f84b853141e4d2a141ee741ea22a462..93f5bbafd40c6bd73bab171b9b693559c7ae61d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8193,6 +8193,43 @@ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" }, + "node_modules/@socket.io/redis-adapter": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@socket.io/redis-adapter/-/redis-adapter-8.3.0.tgz", + "integrity": "sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==", + "dependencies": { + "debug": "~4.3.1", + "notepack.io": "~3.0.1", + "uid2": "1.0.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "socket.io-adapter": "^2.5.4" + } + }, + "node_modules/@socket.io/redis-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@socket.io/redis-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/@svgdotjs/svg.draggable.js": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.4.tgz", @@ -13554,9 +13591,9 @@ } }, "node_modules/ioredis": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.5.0.tgz", - "integrity": "sha512-7CutT89g23FfSa8MDoIFs2GYYa0PaNiW/OrT+nRyjRXHDZd17HmIgy+reOQ/yhh72NznNjGuS8kbCAcA4Ro4mw==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", + "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==", "dependencies": { "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", @@ -15547,6 +15584,11 @@ "node": ">=0.10.0" } }, + "node_modules/notepack.io": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", + "integrity": "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==" + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -19051,6 +19093,14 @@ "node": ">= 0.8" } }, + "node_modules/uid2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-1.0.0.tgz", + "integrity": "sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -19941,6 +19991,7 @@ "@prisma/client": "^6.2.1", "@sc07-canvas/lib": "^1.0.0", "@sentry/node": "^8.47.0", + "@socket.io/redis-adapter": "^8.3.0", "body-parser": "^1.20.2", "bullmq": "^5.40.2", "connect-redis": "^8.0.1", @@ -19948,6 +19999,7 @@ "express": "^4.21.2", "express-rate-limit": "^7.5.0", "express-session": "^1.18.1", + "ioredis": "^5.6.1", "openid-client": "^6.1.7", "prom-client": "^15.1.3", "rate-limit-redis": "^4.2.0", diff --git a/packages/client/src/lib/network.ts b/packages/client/src/lib/network.ts index aef93e8a18632a39425f57123ac76ec9e41986f0..edff395ad0081e403b7bc0c18bda904ae36f7436 100644 --- a/packages/client/src/lib/network.ts +++ b/packages/client/src/lib/network.ts @@ -118,7 +118,10 @@ class Network extends EventEmitter { this.socket.on("config", (config) => { console.info("Server sent config", config); - if (config.version !== __COMMIT_HASH__) { + if ( + config.version !== __COMMIT_HASH__ && + config.version !== "development" + ) { toast.info("Client version does not match server, reloading..."); console.warn("Client version does not match server, reloading...", { clientVersion: __COMMIT_HASH__, diff --git a/packages/client/vite.config.js b/packages/client/vite.config.js index 67ce078a0ef2490c1ffaea45d4609aacda391d0e..a3c6e56b506bb8999362e018072bb757539696ce 100644 --- a/packages/client/vite.config.js +++ b/packages/client/vite.config.js @@ -1,16 +1,12 @@ import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; -import * as child from "child_process"; import { sentryVitePlugin } from "@sentry/vite-plugin"; -const commitHash = child - .execSync("git rev-parse --short HEAD") - .toString() - .trim(); - export default defineConfig(({ mode }) => { process.env = { ...process.env, ...loadEnv(mode, process.cwd(), "") }; + const commitHash = process.env.VERSION?.slice(0, 7) || "development"; + return { root: "src", envDir: "..", @@ -26,6 +22,7 @@ export default defineConfig(({ mode }) => { process.env.SENTRY_DSN ? sentryVitePlugin() : undefined, ], define: { + // short hash (7 characters) __COMMIT_HASH__: JSON.stringify(commitHash), __SENTRY_DSN__: JSON.stringify(process.env.SENTRY_DSN) || null, }, diff --git a/packages/server/package.json b/packages/server/package.json index 66ee95b9098768d3f3ac342ae3eb25e99d6af846..ec9f4cb7b4ef7dafaf30eb9e8b3eeb560592fba7 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -46,6 +46,7 @@ "@prisma/client": "^6.2.1", "@sc07-canvas/lib": "^1.0.0", "@sentry/node": "^8.47.0", + "@socket.io/redis-adapter": "^8.3.0", "body-parser": "^1.20.2", "bullmq": "^5.40.2", "connect-redis": "^8.0.1", @@ -53,6 +54,7 @@ "express": "^4.21.2", "express-rate-limit": "^7.5.0", "express-session": "^1.18.1", + "ioredis": "^5.6.1", "openid-client": "^6.1.7", "prom-client": "^15.1.3", "rate-limit-redis": "^4.2.0", diff --git a/packages/server/src/__mocks__/redis.ts b/packages/server/src/__mocks__/redis.ts index a37bfdfa6c3a638ec636dc713ae45ea54fc2bed6..f54e31e219033f3f7ce4535347d6a4f90fb28118 100644 --- a/packages/server/src/__mocks__/redis.ts +++ b/packages/server/src/__mocks__/redis.ts @@ -34,6 +34,7 @@ const v4Client = { return true; }, lLen: promisify(client.llen).bind(client), + publish: promisify(client.publish).bind(client), }; export default { ...redis, createClient: () => v4Client }; diff --git a/packages/server/src/__test__/api/client.test.ts b/packages/server/src/__test__/api/client.test.ts index ce4405f3e9624861a0107883bb7ed639b53cfafb..a984b9682303226c96e352d6cfe2fa6ad9126feb 100644 --- a/packages/server/src/__test__/api/client.test.ts +++ b/packages/server/src/__test__/api/client.test.ts @@ -108,15 +108,15 @@ describe("Client endpoints /api", () => { app = ExpressController.get().app; }); - it("should redirect to OpenID.getAuthorizationURL()", (done) => { + it("should redirect to OpenID.getAuthorizationURL()", async () => { jest .spyOn(OpenIDController.prototype, "getAuthorizationURL") .mockImplementationOnce(() => "http://localhost/auth"); - request(app) - .get("/api/login") - .expect(302) - .expect("Location", "http://localhost/auth", done); + const req = await request(app).get("/api/login").expect(200); + + expect(req.body.success).toBe(true); + expect(req.body.redirect).toBeTruthy(); }); }); diff --git a/packages/server/src/__test__/controllers/canvas.test.ts b/packages/server/src/__test__/controllers/canvas.test.ts index 25ece4c51bf4ab1f7dda3afdfbc6fa773af286fe..2923a26ded976eb10ff7cac02e90a1ae200d936e 100644 --- a/packages/server/src/__test__/controllers/canvas.test.ts +++ b/packages/server/src/__test__/controllers/canvas.test.ts @@ -17,6 +17,21 @@ jest.mock("../../controllers/SocketController", () => { }; }); +jest.mock("../../controllers/RedisController", () => { + const { Redis: original } = jest.requireActual( + "../../controllers/RedisController" + ); + const redis = jest.requireMock("redis"); + + original["getClient"] = async () => { + return redis.createClient(); + }; + + return { + Redis: original, + }; +}); + import { mockReset } from "jest-mock-extended"; import { type CanvasController as ICanvasController } from "../../controllers/CanvasController"; @@ -119,6 +134,7 @@ describe("Canvas", () => { beforeEach(async () => { jest.resetModules(); + jest.mock("../../services/ClientConfigService"); prismaMock = (await import("../_prisma.mock")).prismaMock; CanvasController = (await import("../../controllers/CanvasController")) @@ -189,6 +205,7 @@ describe("Canvas", () => { beforeEach(async () => { jest.resetModules(); + jest.mock("../../services/ClientConfigService"); prismaMock = (await import("../_prisma.mock")).prismaMock; Redis = (await import("../../controllers/RedisController")).Redis; @@ -966,6 +983,7 @@ describe("Canvas", () => { beforeEach(async () => { jest.resetModules(); jest.mock("../../workers/worker"); + jest.mock("../../services/ClientConfigService"); prismaMock = (await import("../_prisma.mock")).prismaMock; Worker = await import("../../workers/worker"); diff --git a/packages/server/src/__test__/lib/prometheus.test.ts b/packages/server/src/__test__/lib/prometheus.test.ts index b1f17a99cccfd66fe7d3d2e52aaa61d6568d033b..c5aac6be1a7faddc3729bacb33d86ff08af7bbf5 100644 --- a/packages/server/src/__test__/lib/prometheus.test.ts +++ b/packages/server/src/__test__/lib/prometheus.test.ts @@ -22,9 +22,7 @@ jest.mock("../../controllers/CanvasController", () => { return { CanvasController: { get: () => ({ - getCanvasConfig: () => ({ - size: [1, 1], - }), + getSize: () => [1, 1], }), }, }; diff --git a/packages/server/src/api/admin.ts b/packages/server/src/api/admin.ts index 458c260b94fbd5881d9a04cc23a76ffcbc4e74c1..29f804105112d4b33ced03ebefe624e22872a4ef 100644 --- a/packages/server/src/api/admin.ts +++ b/packages/server/src/api/admin.ts @@ -55,13 +55,13 @@ app.get("/check", (req, res) => { }); app.get("/canvas/size", async (req, res) => { - const config = CanvasController.get().getCanvasConfig(); + const [width, height] = CanvasController.get().getSize(); res.json({ success: true, size: { - width: config.size[0], - height: config.size[1], + width, + height, }, }); }); diff --git a/packages/server/src/const.ts b/packages/server/src/const.ts deleted file mode 100644 index f70b06237a1938831da4f8d7b1621487909fb2ac..0000000000000000000000000000000000000000 --- a/packages/server/src/const.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as child from "node:child_process"; - -export const SHORT_HASH = child - .execSync("git rev-parse --short HEAD") - .toString() - .trim(); - -export const LONG_HASH = child.execSync("git rev-parse HEAD").toString().trim(); diff --git a/packages/server/src/controllers/CanvasController.ts b/packages/server/src/controllers/CanvasController.ts index d21bad2942ab92fcf56e2a56bf1b5153cc129ade..8d90d57916ac16995c28295a17c72d4d69603da9 100644 --- a/packages/server/src/controllers/CanvasController.ts +++ b/packages/server/src/controllers/CanvasController.ts @@ -9,6 +9,7 @@ import { Socket } from "socket.io"; import { getLogger } from "../lib/Logger"; import { prisma } from "../lib/prisma"; import { Settings } from "../lib/Settings"; +import { ClientConfigService } from "../services/ClientConfigService"; import { callCacheWorker, getCacheWorkerIdForCoords } from "../workers/worker"; import { Redis } from "./RedisController"; import { SocketController } from "./SocketController"; @@ -51,6 +52,8 @@ export class CanvasController { // run sideeffects await instance.canvasToRedis(); + + ClientConfigService.set("canvas", instance.buildCanvasConfig()); } static get(): CanvasController { @@ -61,7 +64,7 @@ export class CanvasController { return CanvasController.instance!; } - getCanvasConfig(): CanvasConfig { + private buildCanvasConfig(): CanvasConfig { return { size: this.canvasSize, frozen: this.isFrozen, @@ -77,6 +80,10 @@ export class CanvasController { }; } + getSize() { + return this.canvasSize; + } + get frozen() { return this.isFrozen; } @@ -85,6 +92,7 @@ export class CanvasController { this.isFrozen = frozen; await Settings.set("canvas.frozen", frozen); + ClientConfigService.set("canvas", this.buildCanvasConfig()); SocketController.get().broadcastConfig(); } @@ -121,6 +129,7 @@ export class CanvasController { // if (SocketController.instance) { // announce the new config, which contains the canvas size SocketController.get().broadcastConfig(); + ClientConfigService.set("canvas", this.buildCanvasConfig()); // announce all canvas chunks SocketController.get().io.emit("clearCanvasChunks"); diff --git a/packages/server/src/controllers/RedisController.ts b/packages/server/src/controllers/RedisController.ts index fa3c06d2001c2e110226f098dd34544a9e49e389..5010f08b74ab6e9582d75306ee33d1894a39d1a2 100644 --- a/packages/server/src/controllers/RedisController.ts +++ b/packages/server/src/controllers/RedisController.ts @@ -3,6 +3,7 @@ import { RedisClientType } from "@redis/client"; import { createClient } from "redis"; import { getLogger } from "../lib/Logger"; +import { type ClientConfigService } from "../services/ClientConfigService"; const Logger = getLogger("REDIS"); @@ -27,6 +28,9 @@ interface IRedisKeys { // locks lock(id: string): string; + + // this is used as a single key & a channel + config_part(key: keyof ClientConfigService["parts"]): string; } /** @@ -41,6 +45,7 @@ const RedisKeys: IRedisKeys = { socketToSub: (socketId: string) => `CANVAS:SOCKET:${socketId}`, channel_heatmap: () => `CANVAS:HEATMAP`, lock: (id: string) => `CANVAS:LOCK:${id}`, + config_part: (key: string) => `CANVAS:CONFIG_PART:${key}`, }; export class Redis { diff --git a/packages/server/src/controllers/SocketController.ts b/packages/server/src/controllers/SocketController.ts index 482253bdd2d3d59c88c4eb8781c4976cab6c029b..59b1a4cee6205e61c27d7bf9c95c5355cdc3016a 100644 --- a/packages/server/src/controllers/SocketController.ts +++ b/packages/server/src/controllers/SocketController.ts @@ -1,82 +1,47 @@ import http from "node:http"; import { - ClientConfig, ClientToServerEvents, Pixel, ServerToClientEvents, } from "@sc07-canvas/lib/src/net"; +import { createAdapter } from "@socket.io/redis-adapter"; +import { Redis as IORedis } from "ioredis"; import { Server, Socket as RawSocket } from "socket.io"; -import { SHORT_HASH } from "../const"; import { getLogger } from "../lib/Logger"; import { prisma } from "../lib/prisma"; import { Recaptcha } from "../lib/Recaptcha"; import { User } from "../models/User"; -import { Palette } from "../utils/Palette"; +import { ClientConfigService } from "../services/ClientConfigService"; import { CanvasController } from "./CanvasController"; import { ExpressController, ISessionProvider } from "./ExpressController"; import { LockExists, Redis } from "./RedisController"; const Logger = getLogger("SOCKET"); -/** - * 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 -const PIXEL_TIMEOUT_MS = 1000; - -export const getClientConfig = (): ClientConfig => { - return { - version: SHORT_HASH, - pallete: { - colors: Palette.get(), - pixel_cooldown: PIXEL_TIMEOUT_MS, - }, - canvas: CanvasController.get().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; export class SocketController { private static instance: SocketController | undefined; io: Server; - private constructor(server: http.Server, session: ISessionProvider) { - this.io = new Server(server, getSocketConfig()); + private constructor(server?: http.Server, session?: ISessionProvider) { + const pubClient = new IORedis(process.env.REDIS_HOST); + const subClient = pubClient.duplicate(); + + if (server && session) { + this.io = new Server(server, { + adapter: createAdapter(pubClient, subClient), + }); - this.setupMasterShard(); + this.setupMasterShard(); - this.io.engine.use(session); - this.io.on("connection", this.handleConnection.bind(this)); + this.io.engine.use(session); + this.io.on("connection", this.handleConnection.bind(this)); + } else { + this.io = new Server({ adapter: createAdapter(pubClient, subClient) }); + } } /** @@ -84,17 +49,23 @@ export class SocketController { * * @requires ExpressController */ - static initialize() { + static initialize(lightweight: boolean = false) { if (typeof SocketController.instance !== "undefined") { throw new Error( "SocketController#initialize called when already initialized" ); } - SocketController.instance = new SocketController( - ExpressController.get().httpServer, - ExpressController.get().session - ); + Logger.info("Initializing as " + (lightweight ? "lightweight" : "full")); + + if (lightweight) { + SocketController.instance = new SocketController(); + } else { + SocketController.instance = new SocketController( + ExpressController.get().httpServer, + ExpressController.get().session + ); + } } static get(): SocketController { @@ -111,7 +82,7 @@ export class SocketController { * Used by canvas size updates */ broadcastConfig() { - this.io.emit("config", getClientConfig()); + this.io.emit("config", ClientConfigService.getConfig()); } async handleConnection(socket: Socket) { @@ -122,7 +93,7 @@ export class SocketController { `Socket ${socket.id} connection ` + (user ? "@" + user.sub : "No Auth") ); - user?.sockets.add(socket); + socket.join("user:" + (user ? user.sub : "unauthenticated")); let ip = socket.handshake.address; if (process.env.NODE_ENV === "production") { @@ -134,7 +105,6 @@ export class SocketController { } user?.trackIP(ip); - Logger.debug("handleConnection " + user?.sockets.size); socket.emit("clearCanvasChunks"); Redis.getClient().then((redis) => { @@ -166,7 +136,7 @@ export class SocketController { if (process.env.RECAPTCHA_SITE_KEY) socket.emit("recaptcha", process.env.RECAPTCHA_SITE_KEY); - socket.emit("config", getClientConfig()); + socket.emit("config", ClientConfigService.getConfig()); { CanvasController.get() .sendCanvasChunksToSocket(socket) @@ -184,15 +154,14 @@ export class SocketController { 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) => { - if (getClientConfig().canvas.frozen) { + const CONFIG = ClientConfigService.getConfig(); + if (CONFIG.canvas.frozen) { ack({ success: false, error: "canvas_frozen" }); return; } @@ -205,8 +174,8 @@ export class SocketController { if ( pixel.x < 0 || pixel.y < 0 || - pixel.x >= getClientConfig().canvas.size[0] || - pixel.y >= getClientConfig().canvas.size[1] + pixel.x >= CONFIG.canvas.size[0] || + pixel.y >= CONFIG.canvas.size[1] ) { ack({ success: false, error: "invalid_pixel" }); return; @@ -272,10 +241,7 @@ export class SocketController { ); // give undo capabilities await user.setUndo( - new Date( - Date.now() + - CanvasController.get().getCanvasConfig().undo.grace_period - ) + new Date(Date.now() + CONFIG.canvas.undo.grace_period) ); const newPixel: Pixel = { @@ -301,7 +267,7 @@ export class SocketController { }); socket.on("undo", async (ack) => { - if (getClientConfig().canvas.frozen) { + if (ClientConfigService.getConfig().canvas.frozen) { ack({ success: false, error: "canvas_frozen" }); return; } diff --git a/packages/server/src/index.main.ts b/packages/server/src/index.main.ts new file mode 100644 index 0000000000000000000000000000000000000000..332aa37382a8d1ef45df1d3fd5fa52fd876530f7 --- /dev/null +++ b/packages/server/src/index.main.ts @@ -0,0 +1,36 @@ +import { CanvasController } from "./controllers/CanvasController"; +import { ExpressController } from "./controllers/ExpressController"; +import { OpenIDController } from "./controllers/OpenIDController"; +import { Redis } from "./controllers/RedisController"; +import { SocketController } from "./controllers/SocketController"; +import { getLogger } from "./lib/Logger"; +import { Settings } from "./lib/Settings"; +import { ClientConfigService } from "./services/ClientConfigService"; +import { Palette } from "./utils/Palette"; +import { spawnCacheWorkers } from "./workers/worker"; + +const Logger = getLogger("MAIN"); + +export default () => { + // run startup tasks, all of these need to be completed to serve + Promise.all([ + Redis.get().getClient(), + OpenIDController.initialize().then(() => { + Logger.info("Setup OpenID"); + }), + spawnCacheWorkers(), + Palette.load().then(() => + Settings.loadAllSettings().then(async () => { + await ClientConfigService.initialize(); + await CanvasController.initialize(); + ExpressController.initialize(); + SocketController.initialize(); + }) + ), + ]).then(() => { + Logger.info("Startup tasks have completed, starting server"); + Logger.warn("Make sure the jobs process is running"); + + ExpressController.get().listen(); + }); +}; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4f872693027cfd13b819949bfdb80724143f9ff2..ea7953b32cfd4523196594abd3a732f7dbd5a55b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -3,39 +3,14 @@ import "./lib/sentry"; import "./types"; import "./workers/worker"; import "./jobs/bullmq"; - -import { CanvasController } from "./controllers/CanvasController"; -import { ExpressController } from "./controllers/ExpressController"; -import { OpenIDController } from "./controllers/OpenIDController"; -import { Redis } from "./controllers/RedisController"; -import { SocketController } from "./controllers/SocketController"; -import { getLogger } from "./lib/Logger"; -import { Settings } from "./lib/Settings"; -import { Palette } from "./utils/Palette"; -import { spawnCacheWorkers } from "./workers/worker"; - -const Logger = getLogger("MAIN"); - // Validate environment variables - import "./utils/validate_environment"; -// run startup tasks, all of these need to be completed to serve -Promise.all([ - Redis.get().getClient(), - OpenIDController.initialize().then(() => { - Logger.info("Setup OpenID"); - }), - spawnCacheWorkers(), - Palette.load(), - Settings.loadAllSettings().then(async () => { - await CanvasController.initialize(); - ExpressController.initialize(); - SocketController.initialize(); - }), -]).then(() => { - Logger.info("Startup tasks have completed, starting server"); - Logger.warn("Make sure the jobs process is running"); +import InitMain from "./index.main"; +import InitWorker from "./index.worker"; - ExpressController.get().listen(); -}); +if (process.env.NODE_TYPE === "worker") { + InitWorker(); +} else { + InitMain(); +} diff --git a/packages/server/src/index.worker.ts b/packages/server/src/index.worker.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f1be73f862c5190e8aea615c8c498372f3339e2 --- /dev/null +++ b/packages/server/src/index.worker.ts @@ -0,0 +1,22 @@ +import { SocketController } from "./controllers/SocketController"; +import { BullMQ_JobManager } from "./jobs/bullmq"; +import { getLogger } from "./lib/Logger"; +import { Settings } from "./lib/Settings"; +import { ClientConfigService } from "./services/ClientConfigService"; +import { Palette } from "./utils/Palette"; + +const Logger = getLogger("MAIN"); + +export default () => { + Promise.all([ + Palette.load(), + Settings.loadAllSettings().then(() => { + SocketController.initialize(true); + }), + ClientConfigService.initialize(), + ]).then(() => { + Logger.info("Startup tasks completed, starting workers..."); + + BullMQ_JobManager.startWorkers(); + }); +}; diff --git a/packages/server/src/jobs/networking.ts b/packages/server/src/jobs/networking.ts index 7eaca01cb7032cd45007f600cd42829ab9382cf4..debf7223732d2b0400ca016d64ef482ab4bee036 100644 --- a/packages/server/src/jobs/networking.ts +++ b/packages/server/src/jobs/networking.ts @@ -1,13 +1,13 @@ import { CanvasLib } from "@sc07-canvas/lib"; +import { createAdapter } from "@socket.io/redis-adapter"; import { Job, Queue, Worker } from "bullmq"; +import { Redis as IORedis } from "ioredis"; +import { Server } from "socket.io"; import { Redis } from "../controllers/RedisController"; -import { - getClientConfig, - SocketController, -} from "../controllers/SocketController"; import { getLogger } from "../lib/Logger"; import { User } from "../models/User"; +import { ClientConfigService } from "../services/ClientConfigService"; const Logger = getLogger(); @@ -68,10 +68,16 @@ export const Job_Networking = new (class Job_Networking { async processJob(job: Job) { switch (job.name) { case "pixel-stacking": - await runPixelStacking(); + await runPixelStacking().catch((e) => + // eslint-disable-next-line no-console + console.error("pixel-stacking", e) + ); break; case "online": - await runOnlineCount(); + await runOnlineCount().catch((e) => + // eslint-disable-next-line no-console + console.error("online", e) + ); break; } } @@ -81,13 +87,29 @@ export const Job_Networking = new (class Job_Networking { } })(); +let io: Server | undefined; +const getLightweightSocket = () => { + if (typeof io !== "undefined") return io; + + const pubClient = new IORedis(process.env.REDIS_HOST); + const subClient = pubClient.duplicate(); + + io = new Server({ + adapter: createAdapter(pubClient, subClient), + }); + + return io; +}; + const runPixelStacking = async () => { const DEBUG = (process.env.DEBUG || "").indexOf("canvas:worker:runPixelStacking") > -1; if (DEBUG) Logger.debug("Running pixel stacking..."); const redis = await Redis.getClient(); - const sockets = await SocketController.get().io.fetchSockets(); + const sockets = await getLightweightSocket().fetchSockets(); + + const clientConfig = ClientConfigService.getConfig(); for (const socket of sockets) { const sub = await redis.get(Redis.key("socketToSub", socket.id)); @@ -110,7 +132,7 @@ const runPixelStacking = async () => { (Date.now() - user.lastTimeGainStarted.getTime()) / 1000; const cooldown = CanvasLib.getPixelCooldown( user.pixelStack + 1, - getClientConfig() + clientConfig ); await user.update(true); @@ -118,7 +140,7 @@ const runPixelStacking = async () => { // 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 + user.pixelStack < clientConfig.canvas.pixel.maxStack ) { await user.modifyStack(1); @@ -128,7 +150,7 @@ const runPixelStacking = async () => { }; const runOnlineCount = async () => { - const sockets = await SocketController.get().io.sockets.fetchSockets(); + const sockets = await getLightweightSocket().sockets.fetchSockets(); for (const socket of sockets) { socket.emit("online", { count: sockets.length }); } diff --git a/packages/server/src/lib/Prometheus.ts b/packages/server/src/lib/Prometheus.ts index a2eb73152c440e8e2e286ec13e81d0c73bae3b72..ec487a555fa4ab576eb906185cefee7a7d5f9b0b 100644 --- a/packages/server/src/lib/Prometheus.ts +++ b/packages/server/src/lib/Prometheus.ts @@ -60,7 +60,7 @@ export const FilledPixels = new client.Gauge({ help: "total number of filled pixels", async collect() { - const [width, height] = CanvasController.get().getCanvasConfig().size; + const [width, height] = CanvasController.get().getSize(); const filledPixels = await prisma.pixel.findMany({ where: { x: { @@ -84,7 +84,7 @@ export const TotalPixels = new client.Gauge({ help: "total number of pixels the canvas allows", async collect() { - const [width, height] = CanvasController.get().getCanvasConfig().size; + const [width, height] = CanvasController.get().getSize(); this.set(width * height); }, diff --git a/packages/server/src/lib/sentry.ts b/packages/server/src/lib/sentry.ts index b61992051d1b7aad99cf6372bfe829d5a5e5d276..4a2c398fc6f98b7dc2800e176c8ca9dfdc9c0a60 100644 --- a/packages/server/src/lib/sentry.ts +++ b/packages/server/src/lib/sentry.ts @@ -1,13 +1,13 @@ import * as Sentry from "@sentry/node"; -import { LONG_HASH } from "../const"; +import { VersionService } from "../services/VersionService"; if (process.env.SENTRY_DSN) { // only initialize sentry if environment variable is set Sentry.init({ dsn: process.env.SENTRY_DSN, - release: LONG_HASH, + release: VersionService.getVersion(), environment: process.env.SENTRY_ENVIRONMENT ?? "development", tracesSampleRate: 1.0, diff --git a/packages/server/src/models/User.ts b/packages/server/src/models/User.ts index 72580071b13456511a0123b7ade9837a61a0130e..637dfbcd25210ad9dd9b5514a017685e224b2639 100644 --- a/packages/server/src/models/User.ts +++ b/packages/server/src/models/User.ts @@ -8,11 +8,13 @@ import { } from "@sc07-canvas/lib/src/net"; import { Socket } from "socket.io"; -import { getClientConfig } from "../controllers/SocketController"; +import { SocketController } from "../controllers/SocketController"; import { getLogger } from "../lib/Logger"; import { prisma } from "../lib/prisma"; import { ConditionalPromise } from "../lib/utils"; +import { ClientConfigService } from "../services/ClientConfigService"; import { Instance } from "./Instance"; + const Logger = getLogger(); /** @@ -48,8 +50,6 @@ export class User { isAdmin: boolean; isModerator: boolean; - sockets: Set> = new Set(); - private _updatedAt: number; private constructor(data: UserDB & { Ban: Ban | null }) { @@ -122,13 +122,15 @@ export class User { } async modifyStack(modifyBy: number): Promise { + const CONFIG = ClientConfigService.getConfig(); + let new_date = new Date(); if (modifyBy > 0) { let cooldown_to_add = 0.0; for (let i = 0; i < modifyBy; i++) { cooldown_to_add += CanvasLib.getPixelCooldown( this.pixelStack + i + 1, - getClientConfig() + CONFIG ); } @@ -138,11 +140,11 @@ export class User { } else if (modifyBy < 0) { const cooldown_before_change_s = CanvasLib.getPixelCooldown( this.pixelStack + 1, - getClientConfig() + CONFIG ); const cooldown_after_change_s = CanvasLib.getPixelCooldown( this.pixelStack + 1 + modifyBy, - getClientConfig() + CONFIG ); const would_gain_next_at_timestamp_ms = this.lastTimeGainStarted.valueOf() + cooldown_before_change_s * 1000; @@ -166,15 +168,23 @@ export class User { }, }); - for (const socket of this.sockets) { - socket.emit("availablePixels", updatedUser.pixelStack); - socket.emit("pixelLastPlaced", updatedUser.lastTimeGainStarted.getTime()); - } + this.sendEvent("availablePixels", updatedUser.pixelStack); + this.sendEvent( + "pixelLastPlaced", + updatedUser.lastTimeGainStarted.getTime() + ); // we just modified the user data, so we should force an update await this.update(true); } + sendEvent: ReturnType< + Socket["to"] + >["emit"] = (...args) => + SocketController.get() + .io.to("user:" + this.sub) + .emit(...args); + /** * Set undoExpires in database and notify all user's sockets of undo ttl */ @@ -189,9 +199,7 @@ export class User { }, }); - for (const socket of this.sockets) { - socket.emit("undo", { available: true, expireAt: expires.getTime() }); - } + this.sendEvent("undo", { available: true, expireAt: expires.getTime() }); } else { // clear undo capability @@ -202,9 +210,7 @@ export class User { }, }); - for (const socket of this.sockets) { - socket.emit("undo", { available: false }); - } + this.sendEvent("undo", { available: false }); } await this.update(true); @@ -217,17 +223,13 @@ export class User { const ban = this.getBan(); if (ban) { - for (const socket of this.sockets) { - socket.emit("standing", { - banned: true, - until: ban.expires.toISOString(), - reason: ban.publicNote || undefined, - }); - } + this.sendEvent("standing", { + banned: true, + until: ban.expires.toISOString(), + reason: ban.publicNote || undefined, + }); } else { - for (const socket of this.sockets) { - socket.emit("standing", { banned: false }); - } + this.sendEvent("standing", { banned: false }); } } @@ -316,9 +318,7 @@ export class User { * @param alert */ notify(alert: IAlert) { - for (const socket of this.sockets) { - socket.emit("alert", alert); - } + this.sendEvent("alert", alert); } async trackIP(ip: string) { diff --git a/packages/server/src/services/ClientConfigService.ts b/packages/server/src/services/ClientConfigService.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d7541ff0d979cc41420c3724f31a82328b10202 --- /dev/null +++ b/packages/server/src/services/ClientConfigService.ts @@ -0,0 +1,123 @@ +import { PaletteColor } from "@prisma/client"; +import { CanvasConfig, ClientConfig } from "@sc07-canvas/lib/src/net"; + +import { Redis } from "../controllers/RedisController"; +import { getLogger } from "../lib/Logger"; +import { Palette } from "../utils/Palette"; +import { VersionService } from "./VersionService"; + +const VERSION = VersionService.getVersion(); +const PIXEL_TIMEOUT_MS = 1000; + +type PaletteConfig = { colors: PaletteColor[]; pixel_cooldown: number }; +type ChatConfig = { + enabled: boolean; + matrix_homeserver: string; + element_host: string; + general_alias: string; +}; + +interface ConfigParts { + canvas: CanvasConfig | undefined; + palette: PaletteConfig | undefined; + chat: ChatConfig | undefined; +} + +const Logger = getLogger(); + +export class ClientConfigService { + private static instance: ClientConfigService; + + private parts: ConfigParts = { + canvas: undefined, + palette: undefined, + chat: { + enabled: true, + element_host: process.env.ELEMENT_HOST, + general_alias: process.env.MATRIX_GENERAL_ALIAS, + matrix_homeserver: process.env.MATRIX_HOMESERVER, + }, + }; + + private constructor() {} + + static get() { + if (!this.instance) this.instance = new ClientConfigService(); + return this.instance; + } + + static set(key: T, value: ConfigParts[T]) { + const instance = this.get(); + instance.parts[key] = value; + void Redis.getClient().then((client) => { + const redisKey = Redis.key("config_part", key); + client.set(redisKey, JSON.stringify(value)); + client.publish(redisKey, JSON.stringify(value)); + }); + } + + static async initialize() { + const instance = this.get(); + + instance.parts.palette = { + colors: Palette.get(), + pixel_cooldown: PIXEL_TIMEOUT_MS, + }; + + const ALL_KEYS: (keyof ConfigParts)[] = ["canvas", "palette", "chat"]; + await Promise.allSettled( + ALL_KEYS.map((key) => instance.loadFromRedis(key)) + ); + + const sub = await Redis.getClient("SUB"); + for (const key of ALL_KEYS) { + sub.subscribe(Redis.key("config_part", key), (message) => { + try { + const data = JSON.parse(message); + instance.rawInsert(key, data); + } catch (_e) { + // + } + }); + } + } + + private rawInsert( + key: T, + value: ConfigParts[T] + ) { + Logger.debug(`rawInsert ${key} ${JSON.stringify(value)}`); + this.parts[key] = value; + } + + async loadFromRedis(key: keyof ConfigParts) { + const client = await Redis.getClient(); + if (await client.exists(Redis.key("config_part", key))) { + try { + const value = JSON.parse( + (await client.get(Redis.key("config_part", key)))! + ); + this.parts[key] = value; + } catch (_e) { + // not JSON + } + } + } + + static getConfig(): ClientConfig { + const { parts } = this.get(); + + for (const [key, value] of Object.entries(parts)) { + if (value === undefined) { + throw new Error("getConfig() keys were not all initialized: " + key); + } + } + + return { + version: VERSION, + pallete: parts.palette!, + canvas: parts.canvas!, + chat: parts.chat!, + }; + } +} diff --git a/packages/server/src/services/VersionService.ts b/packages/server/src/services/VersionService.ts new file mode 100644 index 0000000000000000000000000000000000000000..27d71f40cb8f97479d4253818d24d30f09baa22d --- /dev/null +++ b/packages/server/src/services/VersionService.ts @@ -0,0 +1,25 @@ +import fs from "node:fs"; + +const FULL_VERSION = + process.env.VERSION || + (process.env.VERSION_PATH && + fs.readFileSync(process.env.VERSION_PATH, "utf8").trim()) || + "development"; + +export class VersionService { + private static instance: VersionService; + + private constructor() {} + + static get() { + if (!this.instance) this.instance = new VersionService(); + + return this.instance; + } + + static getVersion(full?: boolean) { + if (full || FULL_VERSION === "development") return FULL_VERSION; + + return FULL_VERSION.slice(0, 7); + } +} diff --git a/packages/server/src/tools/start_job_worker.ts b/packages/server/src/tools/start_job_worker.ts deleted file mode 100644 index c8590b0bc31b700d00662d493e67a1cf90e3c6ed..0000000000000000000000000000000000000000 --- a/packages/server/src/tools/start_job_worker.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { BullMQ_JobManager } from "../jobs/bullmq"; -import { Settings } from "../lib/Settings"; - -Settings.loadAllSettings().then(() => { - BullMQ_JobManager.startWorkers(); -}); diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 923877298d058da1f553214f72b8802583699832..e79e894ece36842333650fbd0fe9a01cdf1352d5 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -19,10 +19,13 @@ declare global { namespace NodeJS { interface ProcessEnv { NODE_ENV: "development" | "production" | "test"; + NODE_TYPE?: "worker"; NODE_APP_INSTANCE?: string; PORT: string; LOG_LEVEL?: string; SESSION_SECRET: string; + VERSION?: string; + VERSION_PATH?: string; PROMETHEUS_TOKEN?: string; diff --git a/scripts/build-docker.sh b/scripts/build-docker.sh new file mode 100755 index 0000000000000000000000000000000000000000..d00d78271d407135540dff9aa115c22ff961de64 --- /dev/null +++ b/scripts/build-docker.sh @@ -0,0 +1,5 @@ +IMAGE_NAME=${1:-sc07/canvas} + +GIT_HASH=$(git rev-parse HEAD) + +docker build --build-arg VERSION=$GIT_HASH . -t "$IMAGE_NAME" \ No newline at end of file