From 1151ec45dcbcf9d52b886e11241c2d2b5a65c71f Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Fri, 21 Feb 2025 11:55:34 +0100 Subject: [PATCH] socketio server and base events --- package-lock.json | 49 ++++++------- package.json | 2 +- .../admin/operation/missionController.ts | 3 +- src/index.ts | 6 +- src/storage/missionMap.ts | 27 ++++++++ src/storage/socketMap.ts | 33 +++++++++ src/websocket/endpoints/base.ts | 35 ++++++++++ src/websocket/endpoints/roomManagement.ts | 36 ++++++++++ src/websocket/handleEvent.ts | 69 +++++++++++++++++++ src/websocket/index.ts | 38 ++++++++++ .../middleware/authenticateSocket.ts | 48 +++++++++++++ src/websocket/middleware/perRequestCheck.ts | 11 +++ 12 files changed, 327 insertions(+), 30 deletions(-) create mode 100644 src/storage/missionMap.ts create mode 100644 src/storage/socketMap.ts create mode 100644 src/websocket/endpoints/base.ts create mode 100644 src/websocket/endpoints/roomManagement.ts create mode 100644 src/websocket/handleEvent.ts create mode 100644 src/websocket/index.ts create mode 100644 src/websocket/middleware/authenticateSocket.ts create mode 100644 src/websocket/middleware/perRequestCheck.ts diff --git a/package-lock.json b/package-lock.json index 217280a..7d91d4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rss-parser": "^3.13.0", - "socket.io": "^4.7.5", + "socket.io": "^4.8.1", "speakeasy": "^2.0.0", "sqlite3": "^5.1.7", "typeorm": "^0.3.20", @@ -285,11 +285,6 @@ "@types/node": "*" } }, - "node_modules/@types/cookie": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", - "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" - }, "node_modules/@types/cors": { "version": "2.8.17", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", @@ -682,6 +677,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", "engines": { "node": "^4.5.0 || >= 5.9" } @@ -1465,16 +1461,16 @@ } }, "node_modules/engine.io": { - "version": "6.5.5", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", - "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "license": "MIT", "dependencies": { - "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", - "cookie": "~0.4.1", + "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", @@ -1488,24 +1484,27 @@ "version": "5.2.3", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", "engines": { "node": ">=10.0.0" } }, "node_modules/engine.io/node_modules/cookie": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", - "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/engine.io/node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -1516,11 +1515,6 @@ } } }, - "node_modules/engine.io/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, "node_modules/entities": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", @@ -3810,15 +3804,16 @@ } }, "node_modules/socket.io": { - "version": "4.7.5", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", - "integrity": "sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "license": "MIT", "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.3.2", - "engine.io": "~6.5.2", + "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" }, diff --git a/package.json b/package.json index 900635a..2fc96b3 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rss-parser": "^3.13.0", - "socket.io": "^4.7.5", + "socket.io": "^4.8.1", "speakeasy": "^2.0.0", "sqlite3": "^5.1.7", "typeorm": "^0.3.20", diff --git a/src/controller/admin/operation/missionController.ts b/src/controller/admin/operation/missionController.ts index e89d7f2..78e2ba3 100644 --- a/src/controller/admin/operation/missionController.ts +++ b/src/controller/admin/operation/missionController.ts @@ -3,6 +3,7 @@ import MissionService from "../../../service/operation/missionService"; import MissionFactory from "../../../factory/admin/operation/mission"; import { DeleteMissionCommand, UpdateMissionCommand } from "../../../command/operation/mission/missionCommand"; import MissionCommandHandler from "../../../command/operation/mission/missionCommandHandler"; +import SocketServer from "../../../websocket"; /** * @description get all missions @@ -47,7 +48,7 @@ export async function getMissionById(req: Request, res: Response): Promise export async function createMission(req: Request, res: Response): Promise { let missionId = await MissionCommandHandler.create(); - // TODO: push notification to clients that new mission was created + SocketServer.broadcastNewMission(missionId); res.status(200).send(missionId); } diff --git a/src/index.ts b/src/index.ts index 3517a6b..fb2c50d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import "dotenv/config"; import express from "express"; +import { createServer } from "http"; import { BACKUP_AUTO_RESTORE, configCheck, SERVER_PORT } from "./env.defaults"; configCheck(); @@ -31,7 +32,10 @@ dataSource.initialize().then(async () => { const app = express(); import router from "./routes/index"; router(app); -app.listen(process.env.NODE_ENV ? SERVER_PORT : 5000, () => { +const httpServer = createServer(app); +import SocketServer from "./websocket"; +SocketServer.init(httpServer); +httpServer.listen(process.env.NODE_ENV ? SERVER_PORT : 5000, () => { console.log(`${new Date().toISOString()}: listening on port ${process.env.NODE_ENV ? SERVER_PORT : 5000}`); }); diff --git a/src/storage/missionMap.ts b/src/storage/missionMap.ts new file mode 100644 index 0000000..67d2793 --- /dev/null +++ b/src/storage/missionMap.ts @@ -0,0 +1,27 @@ +export interface missionStoreModel { + missionId: string; + doc: any; +} + +/** + * @description store credentials to socket to prevent auth data change while connected + */ +export abstract class MissionMap { + private static store = new Map(); + + public static write(identifier: string, data: missionStoreModel, overwrite: boolean = false): void { + if (!this.exists(identifier) || overwrite) this.store.set(identifier, data); + } + + public static read(identifier: string): missionStoreModel { + return this.store.get(identifier); + } + + public static exists(identifier: string): boolean { + return this.store.has(identifier); + } + + public static delete(identifier: string): void { + this.store.delete(identifier); + } +} diff --git a/src/storage/socketMap.ts b/src/storage/socketMap.ts new file mode 100644 index 0000000..87ea0b1 --- /dev/null +++ b/src/storage/socketMap.ts @@ -0,0 +1,33 @@ +import { PermissionObject } from "../type/permissionTypes"; + +export interface socketStoreModel { + socketId: string; + userId: string; + username: string; + isOwner: string; + permissions: PermissionObject; + isWebApiRequest: boolean; +} + +/** + * @description store credentials to socket to prevent auth data change while connected + */ +export abstract class SocketMap { + private static store = new Map(); + + public static write(identifier: string, data: socketStoreModel, overwrite: boolean = false): void { + if (!this.exists(identifier) || overwrite) this.store.set(identifier, data); + } + + public static read(identifier: string): socketStoreModel { + return this.store.get(identifier); + } + + public static exists(identifier: string): boolean { + return this.store.has(identifier); + } + + public static delete(identifier: string): void { + this.store.delete(identifier); + } +} diff --git a/src/websocket/endpoints/base.ts b/src/websocket/endpoints/base.ts new file mode 100644 index 0000000..f53921d --- /dev/null +++ b/src/websocket/endpoints/base.ts @@ -0,0 +1,35 @@ +import { Server, Socket } from "socket.io"; +import { SocketMap } from "../../storage/socketMap"; +import CustomRequestException from "../../exceptions/customRequestException"; + +export default (io: Server, socket: Socket) => { + socket.on("ping", (callback: Function) => { + callback(); + }); + + socket.on("error", (err) => { + let status = 500; + let msg = "Internal Server Error"; + + if (err instanceof CustomRequestException) { + status = err.statusCode; + msg = err.message; + } + + if (err instanceof CustomRequestException) { + console.log("WS Custom Handler", status, msg); + } else { + console.log("WS Error Handler", err); + } + + socket.emit("error", msg); + socket.leave("home"); + }); + + socket.on("disconnecting", () => { + console.log("socket disconnection: ", socket.id); + SocketMap.delete(socket.id); + }); + + socket.on("disconnect", () => {}); +}; diff --git a/src/websocket/endpoints/roomManagement.ts b/src/websocket/endpoints/roomManagement.ts new file mode 100644 index 0000000..c3890ac --- /dev/null +++ b/src/websocket/endpoints/roomManagement.ts @@ -0,0 +1,36 @@ +import { Server, Socket } from "socket.io"; +import { SocketMap } from "../../storage/socketMap"; +import CustomRequestException from "../../exceptions/customRequestException"; +import { handleEvent } from "../handleEvent"; + +export default (io: Server, socket: Socket) => { + socket.on( + "join:mission", + + handleEvent( + { type: "read", section: "operation", module: "mission" }, + async (data: any) => { + socket.rooms.forEach((room) => { + if (room !== socket.id && room != "home") { + socket.leave(room); + } + }); + + try { + socket.join(data); + socket.emit("status-mission:room", { status: "success" }); + // get mission data + // create yjs sync doc + // provide yjs sync doc to client + return { + type: "package-mission", + answer: "mission-data by yjs", + }; + } catch (error) { + return { type: "status-join:room", answer: { status: "failed", msg: error.message } }; + } + }, + socket + ) + ); +}; diff --git a/src/websocket/handleEvent.ts b/src/websocket/handleEvent.ts new file mode 100644 index 0000000..ab9f209 --- /dev/null +++ b/src/websocket/handleEvent.ts @@ -0,0 +1,69 @@ +import { Server, Socket } from "socket.io"; +import { PermissionObject, PermissionType, PermissionSection, PermissionModule } from "../type/permissionTypes"; +import { SocketMap } from "../storage/socketMap"; +import PermissionHelper from "../helpers/permissionHelper"; +import ForbiddenRequestException from "../exceptions/forbiddenRequestException"; + +export type EventResponseType = { + answer: any; + type: + | `package-${string}` + | `status-${string}:${string}` + | "display" + | "warning" + | "reload" + | "deleted" + | "action required"; + room?: string; +}; + +type PermissionPass = + | { + type: PermissionType; + section: PermissionSection; + module?: PermissionModule; + } + | "admin"; + +export let handleEvent = ( + permissions: PermissionPass, + handler: (params: any) => Promise, + socket: Socket +) => { + return async (args: any) => { + try { + const socketData = SocketMap.read(socket.id); + if (permissions == "admin") { + if (!socketData.isOwner && !socketData.permissions.admin) { + throw new ForbiddenRequestException(`missing admin permission`); + } + } else { + if (!PermissionHelper.can(socketData.permissions, permissions.type, permissions.section, permissions.module)) { + throw new ForbiddenRequestException(`missing required permission`); + } + } + + const { answer, type, room } = await handler(args); + if (room === undefined || room == "") { + socket.emit(type, answer); + } else { + socket.to(room).emit(type, answer); + } + } catch (e) { + socket.emit("error", e.message); + } + }; +}; + +/** + socket.on( + "event", + { permissions } + handleResponse( + async (data:any) => { + throw new Error("failed") + }, + socket, + ) + ); + */ diff --git a/src/websocket/index.ts b/src/websocket/index.ts new file mode 100644 index 0000000..629067b --- /dev/null +++ b/src/websocket/index.ts @@ -0,0 +1,38 @@ +import helmet from "helmet"; +import { Server as httpServerType } from "http"; +import { Server } from "socket.io"; +import authenticateSocket from "./middleware/authenticateSocket"; +import base from "./endpoints/base"; +import perRequestCheck from "./middleware/perRequestCheck"; +import roomManagement from "./endpoints/roomManagement"; + +export default abstract class SocketServer { + private static io: Server; + + public static init(httpServer: httpServerType) { + this.io = new Server(httpServer, { + cors: { + origin: "*", + methods: ["GET", "POST"], + credentials: true, + }, + }); + + this.io.engine.use(helmet()); + + this.io + .of("/") + .use(authenticateSocket) + .on("connection", (socket) => { + socket.use((packet, next) => authenticateSocket(socket, next)); + socket.use((packet, next) => perRequestCheck(socket, packet, next)); + + base(this.io, socket); + roomManagement(this.io, socket); + }); + } + + public static broadcastNewMission(id: string) { + this.io.emit("created-new-mission", id); + } +} diff --git a/src/websocket/middleware/authenticateSocket.ts b/src/websocket/middleware/authenticateSocket.ts new file mode 100644 index 0000000..adf4441 --- /dev/null +++ b/src/websocket/middleware/authenticateSocket.ts @@ -0,0 +1,48 @@ +import jwt from "jsonwebtoken"; +import BadRequestException from "../../exceptions/badRequestException"; +import UnauthorizedRequestException from "../../exceptions/unauthorizedRequestException"; +import InternalException from "../../exceptions/internalException"; +import { JWTHelper } from "../../helpers/jwtHelper"; +import { Socket } from "socket.io"; +import { SocketMap } from "../../storage/socketMap"; + +export default async function authenticateSocket(socket: Socket, next: Function) { + const token = socket.handshake.auth.token; + + if (!token) { + throw new BadRequestException("Provide valid Authorization Header"); + } + + let decoded: string | jwt.JwtPayload; + await JWTHelper.validate(token) + .then((result) => { + decoded = result; + }) + .catch((err) => { + if (err == "jwt expired") { + throw new UnauthorizedRequestException("Token expired", err); + } else { + throw new BadRequestException("Failed Authorization Header decoding", err); + } + }); + + if (typeof decoded == "string" || !decoded) { + throw new InternalException("process failed"); + } + + if (decoded?.sub == "api_token_retrieve") { + throw new BadRequestException("This token is only authorized to get temporary access tokens via GET /api/webapi"); + } + + SocketMap.write(socket.id, { + socketId: socket.id, + userId: decoded.userId, + username: decoded.username, + isOwner: decoded.isOwner, + permissions: decoded.permissions, + isWebApiRequest: decoded?.sub == "webapi_access_token", + }); + socket.join("home"); + + next(); +} diff --git a/src/websocket/middleware/perRequestCheck.ts b/src/websocket/middleware/perRequestCheck.ts new file mode 100644 index 0000000..f51bbda --- /dev/null +++ b/src/websocket/middleware/perRequestCheck.ts @@ -0,0 +1,11 @@ +import { Event, Socket } from "socket.io"; +import { SocketMap } from "../../storage/socketMap"; +import UnauthorizedRequestException from "../../exceptions/unauthorizedRequestException"; + +export default async (socket: Socket, [event, ...args]: Event, next: any) => { + if (SocketMap.exists(socket.id)) { + next(); + } else { + next(new UnauthorizedRequestException("not authorized for connection")); + } +};