socketio server and base events

This commit is contained in:
Julian Krauser 2025-02-21 11:55:34 +01:00
parent 7e96b6ca0c
commit 1151ec45dc
12 changed files with 327 additions and 30 deletions

49
package-lock.json generated
View file

@ -31,7 +31,7 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rss-parser": "^3.13.0", "rss-parser": "^3.13.0",
"socket.io": "^4.7.5", "socket.io": "^4.8.1",
"speakeasy": "^2.0.0", "speakeasy": "^2.0.0",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"typeorm": "^0.3.20", "typeorm": "^0.3.20",
@ -285,11 +285,6 @@
"@types/node": "*" "@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": { "node_modules/@types/cors": {
"version": "2.8.17", "version": "2.8.17",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz",
@ -682,6 +677,7 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": { "engines": {
"node": "^4.5.0 || >= 5.9" "node": "^4.5.0 || >= 5.9"
} }
@ -1465,16 +1461,16 @@
} }
}, },
"node_modules/engine.io": { "node_modules/engine.io": {
"version": "6.5.5", "version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
"integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==",
"license": "MIT",
"dependencies": { "dependencies": {
"@types/cookie": "^0.4.1",
"@types/cors": "^2.8.12", "@types/cors": "^2.8.12",
"@types/node": ">=10.0.0", "@types/node": ">=10.0.0",
"accepts": "~1.3.4", "accepts": "~1.3.4",
"base64id": "2.0.0", "base64id": "2.0.0",
"cookie": "~0.4.1", "cookie": "~0.7.2",
"cors": "~2.8.5", "cors": "~2.8.5",
"debug": "~4.3.1", "debug": "~4.3.1",
"engine.io-parser": "~5.2.1", "engine.io-parser": "~5.2.1",
@ -1488,24 +1484,27 @@
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/engine.io/node_modules/cookie": { "node_modules/engine.io/node_modules/cookie": {
"version": "0.4.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/engine.io/node_modules/debug": { "node_modules/engine.io/node_modules/debug": {
"version": "4.3.6", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"ms": "2.1.2" "ms": "^2.1.3"
}, },
"engines": { "engines": {
"node": ">=6.0" "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": { "node_modules/entities": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
@ -3810,15 +3804,16 @@
} }
}, },
"node_modules/socket.io": { "node_modules/socket.io": {
"version": "4.7.5", "version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
"integrity": "sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==", "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
"license": "MIT",
"dependencies": { "dependencies": {
"accepts": "~1.3.4", "accepts": "~1.3.4",
"base64id": "~2.0.0", "base64id": "~2.0.0",
"cors": "~2.8.5", "cors": "~2.8.5",
"debug": "~4.3.2", "debug": "~4.3.2",
"engine.io": "~6.5.2", "engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2", "socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4" "socket.io-parser": "~4.2.4"
}, },

View file

@ -46,7 +46,7 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rss-parser": "^3.13.0", "rss-parser": "^3.13.0",
"socket.io": "^4.7.5", "socket.io": "^4.8.1",
"speakeasy": "^2.0.0", "speakeasy": "^2.0.0",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"typeorm": "^0.3.20", "typeorm": "^0.3.20",

View file

@ -3,6 +3,7 @@ import MissionService from "../../../service/operation/missionService";
import MissionFactory from "../../../factory/admin/operation/mission"; import MissionFactory from "../../../factory/admin/operation/mission";
import { DeleteMissionCommand, UpdateMissionCommand } from "../../../command/operation/mission/missionCommand"; import { DeleteMissionCommand, UpdateMissionCommand } from "../../../command/operation/mission/missionCommand";
import MissionCommandHandler from "../../../command/operation/mission/missionCommandHandler"; import MissionCommandHandler from "../../../command/operation/mission/missionCommandHandler";
import SocketServer from "../../../websocket";
/** /**
* @description get all missions * @description get all missions
@ -47,7 +48,7 @@ export async function getMissionById(req: Request, res: Response): Promise<any>
export async function createMission(req: Request, res: Response): Promise<any> { export async function createMission(req: Request, res: Response): Promise<any> {
let missionId = await MissionCommandHandler.create(); let missionId = await MissionCommandHandler.create();
// TODO: push notification to clients that new mission was created SocketServer.broadcastNewMission(missionId);
res.status(200).send(missionId); res.status(200).send(missionId);
} }

View file

@ -1,5 +1,6 @@
import "dotenv/config"; import "dotenv/config";
import express from "express"; import express from "express";
import { createServer } from "http";
import { BACKUP_AUTO_RESTORE, configCheck, SERVER_PORT } from "./env.defaults"; import { BACKUP_AUTO_RESTORE, configCheck, SERVER_PORT } from "./env.defaults";
configCheck(); configCheck();
@ -31,7 +32,10 @@ dataSource.initialize().then(async () => {
const app = express(); const app = express();
import router from "./routes/index"; import router from "./routes/index";
router(app); 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}`); console.log(`${new Date().toISOString()}: listening on port ${process.env.NODE_ENV ? SERVER_PORT : 5000}`);
}); });

27
src/storage/missionMap.ts Normal file
View file

@ -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<string, missionStoreModel>();
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);
}
}

33
src/storage/socketMap.ts Normal file
View file

@ -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<string, socketStoreModel>();
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);
}
}

View file

@ -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", () => {});
};

View file

@ -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
)
);
};

View file

@ -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<EventResponseType>,
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,
)
);
*/

38
src/websocket/index.ts Normal file
View file

@ -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);
}
}

View file

@ -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();
}

View file

@ -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"));
}
};