add basic socketio to server

This commit is contained in:
Julian Krauser 2025-07-15 11:52:58 +02:00
parent 45ec6b856a
commit b29cdae088
8 changed files with 252 additions and 1 deletions

View file

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

View file

@ -0,0 +1,54 @@
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",
});
socket.join("home");
next();
} catch (err) {
next(err);
}
}

View file

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

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

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

35
src/websocket/base.ts Normal file
View file

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

View file

@ -0,0 +1,72 @@
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";
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: (...args: 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 (
!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);
}
};
};
/**
socket.on(
"event",
{ permissions }
handleResponse(
async (data:any) => {
throw new Error("failed")
},
socket,
)
);
*/

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

@ -0,0 +1,33 @@
import helmet from "helmet";
import { Server as httpServerType } from "http";
import { Server } from "socket.io";
import authenticateSocket from "../middleware/authenticateSocket";
import checkSocketExists from "../middleware/checkSocketExists";
import base from "./base";
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("/scanner")
.use(authenticateSocket)
.on("connection", (socket) => {
console.log("socket connection: ", socket.id);
socket.use((packet, next) => authenticateSocket(socket, next));
socket.use((packet, next) => checkSocketExists(socket, packet, next));
base(this.io, socket);
});
}
}

View file

@ -0,0 +1,7 @@
import { Server, Socket } from "socket.io";
export default (io: Server, socket: Socket) => {
socket.on("disconnecting", () => {
// tell public client, that host left - connection will be closed
});
};