const { createClient } = require('redis'); const WebSocket = require("ws"); const url = require("url"); const dotenv = require('dotenv'); const path = require('path'); const envPath = path.resolve(__dirname, '..', 'config', '.env.redis'); dotenv.config({ path: envPath }); const redisClient = createClient({ password: process.env.REDIS_PASSWORD, socket: { host: process.env.REDIS_HOST || 'redis', port: process.env.REDIS_PORT || 6379 } }); redisClient.on('error', (err) => console.error('Redis Client Error', err)); async function startRedis() { await redisClient.connect(); console.log('Connected and authenticated with Redis!'); } startRedis(); const PORT = 8082; const wss = new WebSocket.Server({ port: PORT }); const groups = new Map(); const authenticatedGroups = new Map(); const clients = new Set(); const HEARTBEAT_INTERVAL = 30000; const MAX_MESSAGE_SIZE = 10 * 1024; // 10KB function addToGroup(ws, wkId, accesstype, access, isAuthenticated = false) { const targetMap = isAuthenticated ? authenticatedGroups : groups; if (!targetMap.has(wkId)) { targetMap.set(wkId, new Map()); } const workspaceMap = targetMap.get(wkId); if (!workspaceMap.has(accesstype)) { workspaceMap.set(accesstype, new Map()); } const accesstypeMap = workspaceMap.get(accesstype); if (!accesstypeMap.has(access)) { accesstypeMap.set(access, new Set()); } const accessSet = accesstypeMap.get(access); accessSet.add(ws); if (isAuthenticated) { ws.send(`Dieser Benutzer wurde einer Gruppe hinzugefügt: Wettkampf-Id: ${wkId}, Zugriffstyp: ${accesstype}, Zugriff: ${access}`); } } function removeFromGroup(ws, wkId, accesstype, access, authenticted = false) { let group; const targetMap = authenticted ? authenticatedGroups : groups; const workspaceMap = targetMap.get(wkId); const accesstypeMap = workspaceMap.get(accesstype); const accessSet = accesstypeMap.get(access); if (accessSet) { accessSet.delete(ws); if (accessSet.size === 0) { accessSet.delete(access); } if (accesstypeMap.size === 0) { accesstypeMap.delete(accesstype); } if (workspaceMap.size === 0) { workspaceMap.delete(wkId); } } } function sendToAccess(wkId, accesstype, access, messageObj, authenticted = false, excludeWs = null) { const targetMap = authenticted ? authenticatedGroups : groups; const group = targetMap.get(wkId)?.get(accesstype)?.get(access); if (!group) return; const message = JSON.stringify(messageObj); for (const ws of group) { if (ws.readyState === WebSocket.OPEN && ws !== excludeWs) { ws.send(message); } } } function sendToAccessType(wkId, accessType, messageObj, authenticated = false, excludeWs = null) { const targetMap = authenticated ? authenticatedGroups : groups; const accessTypeMap = targetMap.get(wkId)?.get(accessType); if (!accessTypeMap) return; const message = JSON.stringify(messageObj); for (const sAccessTypeMap of accessTypeMap.values()) { for (const ws of sAccessTypeMap) { if (ws.readyState === WebSocket.OPEN && ws !== excludeWs) { ws.send(message); } } } } function sendToSelf(ws, messageObj) { const message = JSON.stringify(messageObj); if (ws.readyState === WebSocket.OPEN) { ws.send(message); } } function sendToGroupsContaining(substring, messageObj, authenticted = false) { const message = JSON.stringify(messageObj); if (authenticted) { for (const [access, group] of authenticatedGroups.entries()) { if (access.includes(substring)) { for (const ws of group) { if (ws.readyState === WebSocket.OPEN) { ws.send(message); } } } } } else { for (const [access, group] of groups.entries()) { if (access.includes(substring)) { for (const ws of group) { if (ws.readyState === WebSocket.OPEN) { ws.send(message); } } } } } } function safeParse(data) { try { return JSON.parse(data); } catch { return null; } } async function authenticateWithToken(token, ws) { const tokenPermissions = await redisClient.get(token); if (tokenPermissions === null) { ws.send("unauthorized Access"); ws.terminate(); return null; } const authenticatedFreigaben = JSON.parse(tokenPermissions); await redisClient.del(token); return authenticatedFreigaben; } // ---------------------- // Connection handler // ---------------------- wss.on("connection", async (ws, req) => { const params = url.parse(req.url, true).query; const access = typeof params.access === "string" ? params.access : ""; let authenticatedFreigaben = null; ws.isAuthenticated = false; if (access !== 'token' || typeof params.token !== "string") { ws.send("unauthorized Access"); ws.terminate(); return; } authenticatedFreigaben = await authenticateWithToken(params.token, ws); if (!authenticatedFreigaben) return; ws.send("Authentifizierung mit Token erfolgreich"); ws.isAuthenticated = authenticatedFreigaben.authenticated; ws.access = String(authenticatedFreigaben.access); ws.accesstype = String(authenticatedFreigaben.type); // kampfrichter, wk_leitung ws.wkId = String(authenticatedFreigaben.wk_id); ws.isAlive = true; clients.add(ws); addToGroup(ws, ws.wkId, ws.accesstype, ws.access, ws.isAuthenticated); ws.on("pong", () => { ws.isAlive = true; }); ws.on("message", (data) => { if (data.length > MAX_MESSAGE_SIZE) return; const msg = safeParse(data.toString()); if (!msg || typeof msg !== "object") return; // Expected structure: // { // type: string, // payload: object // } const { type, payload } = msg; if (!type) return; let geraetId; switch (type) { case "DISPLAY_CONTROL": if (ws.accesstype !== 'wk_leitung' || ws.access !== "displaycontrol") { ws.send("Unauthorized Request"); ws.close(4003, "Unauthorized"); } else { sendToAccessType(ws.wkId, "display", { type: "UPDATE_DISPLAYCONTROL", payload }, false); } break; case "UPDATE_SCORE": if (ws.accesstype !== "kampfrichter") { ws.send("Unauthorized Request"); ws.close(4003, "Unauthorized"); }; if (!payload || typeof payload !== "object") return; const geraetScreen = payload.geraet; geraetId = String(payload.geraetId); if (!geraetScreen || !geraetId) return; if (ws.access !== geraetId && ws.access !== 'A') { ws.send("Unauthorized Request"); ws.close(4003, "Unauthorized"); } sendToAccess(ws.wkId, "display", geraetId, { type: "UPDATE_SCORE", payload: payload.data }, false); break; case "UPDATE_RANKLIVE_SCORE": if (ws.accesstype !== "kampfrichter") { ws.send("Unauthorized Request"); ws.close(4003, "Unauthorized"); }; if (!payload || typeof payload !== "object") return; geraetId = String(payload.geraetId); if (!geraetId) return; if (ws.access !== geraetId && ws.access !== 'A') { ws.send("Unauthorized Request"); ws.close(4003, "Unauthorized"); } sendToAccess(ws.wkId, "rankLive", geraetId, { type: "UPDATE", payload: payload }, false); sendToAccess(ws.wkId, "rankLive", 'A', { type: "UPDATE", payload: payload }, false); break; case "UPDATE_RANKLIVE_C_SUBABT": if (ws.accesstype !== "kampfrichter") { ws.send("Unauthorized Request"); ws.close(4003, "Unauthorized"); }; if (ws.access !== 'A') { ws.send("Unauthorized Request"); ws.close(4003, "Unauthorized"); } sendToAccessType(ws.wkId, "rankLive", { type: "UPDATE_RANKLIVE_C_SUBABT", payload: {} }, false); break; case "SELF": sendToSelf(ws, { type: "SELF", payload }); break; case "AUDIO": if (ws.accesstype !== "kampfrichter") { ws.send("Unauthorized Request"); ws.close(4003, "Unauthorized"); }; if (!payload || typeof payload !== "object") return; const audioDiscipline = String(payload.audioDiscipline); if (!audioDiscipline) return; if (ws.access !== audioDiscipline && ws.access !== 'A' && audioDiscipline !== 'A') { ws.send("Unauthorized Request"); ws.close(4003, "Unauthorized"); } sendToAccess(ws.wkId, "audio", audioDiscipline, { type: "AUDIO", payload: payload }, false); break; case "KAMPFRICHTER_UPDATE": if (ws.accesstype !== "kampfrichter") { ws.send("Unauthorized Request"); ws.close(4003, "Unauthorized"); }; if (!payload || typeof payload !== "object") return; const discipline = String(payload.discipline); if (!discipline) return; if (ws.access !== discipline && ws.access !== 'A') { ws.send("Unauthorized Request"); ws.close(4003, "Unauthorized"); } sendToAccess(ws.wkId, "kampfrichter", discipline, { type: "UPDATE", payload: payload }, true, ws); sendToAccess(ws.wkId, "kampfrichter", 'A', { type: "UPDATE", payload: payload }, true, ws); break; case "EINSTELLUNGEN_DISPLAY_UPDATE": if (ws.accesstype !== 'wk_leitung' || ws.access !== "einstellungen") { ws.send("Unauthorized Request"); ws.close(4003, "Unauthorized"); }; if (!payload || typeof payload !== "object") return; const { key, value } = payload; if (!key) return; sendToAccessType(ws.wkId, "display", { type: "EINSTELLUNGEN_DISPLAY_UPDATE", payload: { key, value } }, false); sendToAccess(ws.wkId, 'wk_leitung', "einstellungen", { type: "EINSTELLUNGEN_DISPLAY_UPDATE", payload: { key, value } }, true, true); break; default: ws.send("Invalid Request"); } }); ws.on("close", () => { clients.delete(ws); removeFromGroup(ws, ws.wkId, ws.accesstype, ws.access, ws.isAuthenticated); }); ws.on("error", () => { clients.delete(ws); removeFromGroup(ws, ws.wkId, ws.accesstype, ws.access, ws.isAuthenticated); }); }); // ---------------------- // Heartbeat (cleanup) // ---------------------- setInterval(() => { for (const ws of clients) { if (!ws.isAlive) { ws.terminate(); clients.delete(ws); removeFromGroup(ws, ws.wkId, ws.accesstype, ws.access, ws.isAuthenticated); continue; } ws.isAlive = false; ws.ping(); } }, HEARTBEAT_INTERVAL); // ---------------------- console.log(`WebSocket server running on port ${PORT}`);