diff --git a/package-lock.json b/package-lock.json index cfbc2a5..e0c92d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "validator": "^13.15.15" }, "devDependencies": { + "@socket.io/admin-ui": "^0.5.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/ip": "^1.1.3", @@ -764,6 +765,39 @@ "node": ">=18" } }, + "node_modules/@socket.io/admin-ui": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@socket.io/admin-ui/-/admin-ui-0.5.1.tgz", + "integrity": "sha512-1dlGL2FGm6T+uL1e6iDvbo2eCINwvW7iVbjIblwh5kPPRM1SP8lmZrbFZf4QNJ/cqQ+JLcx49eXGM9WAB4TK7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/bcryptjs": "^2.4.2", + "bcryptjs": "^2.4.3", + "debug": "~4.3.1" + }, + "peerDependencies": { + "socket.io": ">=3.1.0" + } + }, + "node_modules/@socket.io/admin-ui/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -821,6 +855,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -1421,6 +1462,13 @@ "node": ">=10.0.0" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", diff --git a/package.json b/package.json index cf7f32c..695a152 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "validator": "^13.15.15" }, "devDependencies": { + "@socket.io/admin-ui": "^0.5.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/ip": "^1.1.3", diff --git a/src/controller/publicController.ts b/src/controller/publicController.ts index e48c008..943aa73 100644 --- a/src/controller/publicController.ts +++ b/src/controller/publicController.ts @@ -9,6 +9,9 @@ import SettingHelper from "../helpers/settingsHelper"; import sharp from "sharp"; import ico from "sharp-ico"; import { FileSystemHelper } from "../helpers/fileSystemHelper"; +import { SocketConnectionTypes } from "../enums/socketEnum"; +import SocketServer from "../websocket"; +import BadRequestException from "../exceptions/badRequestException"; /** * @description get all calendar items by types or nscdr @@ -54,6 +57,26 @@ export async function getCalendarItemsByTypes(req: Request, res: Response): Prom } } +/** + * @description get all calendar items by types or nscdr + * @summary passphrase is passed as value pair like `type:passphrase` + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function checkScannerRoomExists(req: Request, res: Response): Promise { + let roomId = req.body.roomId; + + const socketsInOtherRoom = await SocketServer.server.of(SocketConnectionTypes.scanner).in(roomId).fetchSockets(); + const count = socketsInOtherRoom.length; + + if (count != 0) { + res.sendStatus(204); + } else { + throw new BadRequestException("room does not exists"); + } +} + /** * @description get configuration of UI * @param req {Request} Express req object diff --git a/src/enums/socketEnum.ts b/src/enums/socketEnum.ts new file mode 100644 index 0000000..4aed946 --- /dev/null +++ b/src/enums/socketEnum.ts @@ -0,0 +1,4 @@ +export enum SocketConnectionTypes { + scanner = "/scanner", + pscanner = "/public_scanner", +} diff --git a/src/index.ts b/src/index.ts index 1e28a8c..250ef17 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import "dotenv/config"; import "./handlebars.config"; import express from "express"; +import { createServer } from "http"; import { configCheck } from "./env.defaults"; configCheck(); @@ -37,7 +38,10 @@ dataSource.initialize().then(async () => { const app = express(); import router from "./routes/index"; router(app); -app.listen(process.env.NODE_ENV ? process.env.SERVER_PORT ?? 5000 : 5000, () => { +const httpServer = createServer(app); +import SocketServer from "./websocket"; +SocketServer.init(httpServer); +httpServer.listen(process.env.NODE_ENV ? process.env.SERVER_PORT ?? 5000 : 5000, () => { console.log( `${new Date().toISOString()}: listening on port ${process.env.NODE_ENV ? process.env.SERVER_PORT ?? 5000 : 5000}` ); diff --git a/src/middleware/authenticateSocket.ts b/src/middleware/authenticateSocket.ts new file mode 100644 index 0000000..d8718c4 --- /dev/null +++ b/src/middleware/authenticateSocket.ts @@ -0,0 +1,53 @@ +import jwt from "jsonwebtoken"; +import BadRequestException from "../exceptions/badRequestException"; +import InternalException from "../exceptions/internalException"; +import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException"; +import { JWTHelper } from "../helpers/jwtHelper"; +import { SocketMap } from "../storage/socketMap"; +import { Socket } from "socket.io"; + +export default async function authenticateSocket(socket: Socket, next: Function) { + try { + 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, + firstname: decoded.firstname, + lastname: decoded.lastname, + isOwner: decoded.isOwner, + permissions: decoded.permissions, + isWebApiRequest: decoded?.sub == "webapi_access_token", + }); + + next(); + } catch (err) { + next(err); + } +} diff --git a/src/middleware/checkSocketExists.ts b/src/middleware/checkSocketExists.ts new file mode 100644 index 0000000..db43554 --- /dev/null +++ b/src/middleware/checkSocketExists.ts @@ -0,0 +1,11 @@ +import { Event, Socket } from "socket.io"; +import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException"; +import { SocketMap } from "../storage/socketMap"; + +export default async (socket: Socket, [event, ...args]: Event, next: any) => { + if (SocketMap.exists(socket.id)) { + next(); + } else { + next(new UnauthorizedRequestException("not authorized for connection")); + } +}; diff --git a/src/routes/public.ts b/src/routes/public.ts index 360f962..30750a0 100644 --- a/src/routes/public.ts +++ b/src/routes/public.ts @@ -1,5 +1,6 @@ import express from "express"; import { + checkScannerRoomExists, getApplicationConfig, getApplicationFavicon, getApplicationIcon, @@ -18,6 +19,10 @@ router.post("/reportdamage", async (req, res) => { res.send("TODO"); }); +router.post("/checkscannerroom", async (req, res) => { + await checkScannerRoomExists(req, res); +}); + router.get("/configuration", async (req, res) => { await getApplicationConfig(req, res); }); diff --git a/src/storage/socketMap.ts b/src/storage/socketMap.ts new file mode 100644 index 0000000..e5bfeec --- /dev/null +++ b/src/storage/socketMap.ts @@ -0,0 +1,35 @@ +import { PermissionObject } from "../type/permissionTypes"; + +export interface socketStoreModel { + socketId: string; + userId: string; + username: string; + firstname: string; + lastname: 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/base.ts b/src/websocket/base.ts new file mode 100644 index 0000000..8243717 --- /dev/null +++ b/src/websocket/base.ts @@ -0,0 +1,35 @@ +import { Server, Socket } from "socket.io"; +import CustomRequestException from "../exceptions/customRequestException"; +import { SocketMap } from "../storage/socketMap"; + +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.on("disconnecting", () => { + console.log("socket disconnection: ", socket.id); + + SocketMap.delete(socket.id); + }); + + socket.on("disconnect", () => {}); +}; diff --git a/src/websocket/handleEvent.ts b/src/websocket/handleEvent.ts new file mode 100644 index 0000000..c36c601 --- /dev/null +++ b/src/websocket/handleEvent.ts @@ -0,0 +1,94 @@ +import { Server, Socket } from "socket.io"; +import { PermissionObject, PermissionType, PermissionSection, PermissionModule } from "../type/permissionTypes"; +import PermissionHelper from "../helpers/permissionHelper"; +import ForbiddenRequestException from "../exceptions/forbiddenRequestException"; +import { SocketMap } from "../storage/socketMap"; +import { SocketConnectionTypes } from "../enums/socketEnum"; +import SocketServer from "."; + +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" + | "noPermissionsRequired"; + +export let handleEvent = ( + permissions: PermissionPass, + handler: (...args: 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 (permissions != "noPermissionsRequired") { + if ( + !socketData.isOwner && + !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); + } + }; +}; + +export let emitEvent = ( + event: EventResponseType & { namespace?: SocketConnectionTypes }, + socket: Socket, + io: Server +) => { + try { + const { answer, type, room, namespace } = event; + if (room === undefined || room == "") { + socket.emit(type, answer); + } else if (namespace === undefined) { + socket.to(room).emit(type, answer); + } else { + io.of(namespace).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..82c7a8f --- /dev/null +++ b/src/websocket/index.ts @@ -0,0 +1,57 @@ +import helmet from "helmet"; +import { Server as httpServerType } from "http"; +import { Server } from "socket.io"; +import { instrument } from "@socket.io/admin-ui"; + +import authenticateSocket from "../middleware/authenticateSocket"; +import checkSocketExists from "../middleware/checkSocketExists"; +import { SocketConnectionTypes } from "../enums/socketEnum"; + +import base from "./base"; +import scanner from "./scanner"; +import pScanner from "./pScanner"; + +export default abstract class SocketServer { + private static io: Server; + + static get server() { + return this.io; + } + + public static init(httpServer: httpServerType) { + this.io = new Server(httpServer, { + cors: { + origin: "*", + methods: ["GET", "POST"], + credentials: true, + }, + }); + + if (process.env.NODE_ENV) { + instrument(this.io, { + auth: false, + mode: "development", + }); + } + + this.io.engine.use(helmet()); + + this.io + .of(SocketConnectionTypes.scanner) + .use(authenticateSocket) + .on("connection", (socket) => { + console.log("socket connection: ", socket.id); + socket.use((packet, next) => checkSocketExists(socket, packet, next)); + + base(this.io, socket); + scanner(this.io, socket); + }); + + this.io.of(SocketConnectionTypes.pscanner).on("connection", (socket) => { + console.log("socket connection: ", socket.id); + + base(this.io, socket); + pScanner(this.io, socket); + }); + } +} diff --git a/src/websocket/pScanner/index.ts b/src/websocket/pScanner/index.ts new file mode 100644 index 0000000..19526c4 --- /dev/null +++ b/src/websocket/pScanner/index.ts @@ -0,0 +1,116 @@ +import { Server, Socket } from "socket.io"; +import { emitEvent, handleEvent } from "../handleEvent"; +import { SocketConnectionTypes } from "../../enums/socketEnum"; + +export default (io: Server, socket: Socket) => { + socket.on( + "session:join", + handleEvent( + "noPermissionsRequired", + async (room: string) => { + const socketsInOtherRoom = await io.of(SocketConnectionTypes.scanner).in(room).fetchSockets(); + const count = socketsInOtherRoom.length; + + if (count == 0) { + return { + type: "status-session:join", + answer: { status: "failed" }, + }; + } else { + socket.join(room); + emitEvent( + { + type: "package-scanner_join", + answer: socket.id, + room: room, + namespace: SocketConnectionTypes.scanner, + }, + socket, + io + ); + + return { + type: "status-session:join", + answer: { status: "success" }, + }; + } + }, + socket + ) + ); + + socket.on( + "session:leave", + handleEvent( + "noPermissionsRequired", + async () => { + console.log("called leave"); + const rooms = Array.from(socket.rooms).filter((r) => r !== socket.id); + const room = rooms[0]; + + socket.leave(room); + emitEvent( + { + type: "package-scanner_leave", + answer: socket.id, + room: room, + namespace: SocketConnectionTypes.scanner, + }, + socket, + io + ); + + return { + type: "status-session:leave", + answer: { status: "success" }, + }; + }, + socket + ) + ); + + socket.on( + "scan:send", + handleEvent( + "noPermissionsRequired", + async (result: string) => { + const rooms = Array.from(socket.rooms).filter((r) => r !== socket.id); + const room = rooms[0]; + + emitEvent( + { + type: "package-scan_receive", + answer: result, + room: room, + namespace: SocketConnectionTypes.scanner, + }, + socket, + io + ); + + return { + type: "status-scan:send", + answer: { status: "success" }, + }; + }, + socket + ) + ); + + socket.on("disconnecting", () => { + const rooms = Array.from(socket.rooms).filter((r) => r !== socket.id); + const room = rooms[0]; + + socket.leave(room); + emitEvent( + { + type: "package-scanner_leave", + answer: socket.id, + room: room, + namespace: SocketConnectionTypes.scanner, + }, + socket, + io + ); + }); +}; diff --git a/src/websocket/scanner/index.ts b/src/websocket/scanner/index.ts new file mode 100644 index 0000000..2c8aada --- /dev/null +++ b/src/websocket/scanner/index.ts @@ -0,0 +1,63 @@ +import { Server, Socket } from "socket.io"; +import { emitEvent, handleEvent } from "../handleEvent"; +import { SocketConnectionTypes } from "../../enums/socketEnum"; + +export default (io: Server, socket: Socket) => { + socket.on( + "session:create", + handleEvent( + "noPermissionsRequired", + async (room: string) => { + socket.join(room); + return { + type: "status-session:create", + answer: { status: "success" }, + }; + }, + socket + ) + ); + + socket.on( + "session:close", + handleEvent( + "noPermissionsRequired", + async () => { + const rooms = Array.from(socket.rooms).filter((r) => r !== socket.id); + const room = rooms[0]; + socket.leave(room); + emitEvent( + { + type: "package-host_leave", + answer: "host_leave", + room: room, + namespace: SocketConnectionTypes.pscanner, + }, + socket, + io + ); + return { + type: "status-session:close", + answer: { status: "success" }, + }; + }, + socket + ) + ); + + socket.on("disconnecting", () => { + const rooms = Array.from(socket.rooms).filter((r) => r !== socket.id); + const room = rooms[0]; + // io.of(SocketConnectionTypes.pscanner).in(room).disconnectSockets(); + emitEvent( + { + type: "package-host_leave", + answer: "host_leave", + room: room, + namespace: SocketConnectionTypes.pscanner, + }, + socket, + io + ); + }); +};