import crypto from 'crypto'; import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import axios from 'axios'; const TIMEOUT_SECONDS = 1800; // 30 Minuten const MAX_ACTIVE_VIDEO_CONNECTIONS = 3; const ACTIVE_VIDEO_SESSION_STATUSES = new Set(['ringing', 'connecting', 'active']); const TERMINAL_VIDEO_SESSION_STATUSES = new Set(['rejected', 'cancelled', 'ended', 'failed']); const VIDEO_CONNECTION_STATES = new Set(['new', 'connecting', 'connected', 'disconnected', 'failed', 'closed']); function parseDelimitedEnvList(rawValue) { return String(rawValue || '') .split(/[,\n]/) .map((entry) => entry.trim()) .filter(Boolean); } function buildVideoIceServers() { const jsonConfig = String(process.env.VIDEO_ICE_SERVERS_JSON || '').trim(); if (jsonConfig) { try { const parsed = JSON.parse(jsonConfig); return Array.isArray(parsed) ? parsed : []; } catch (error) { console.error('Ungültiges VIDEO_ICE_SERVERS_JSON:', error.message); return []; } } const iceServers = []; const stunUrls = parseDelimitedEnvList(process.env.VIDEO_STUN_URLS); if (stunUrls.length > 0) { iceServers.push({ urls: stunUrls }); } const turnUrls = parseDelimitedEnvList(process.env.VIDEO_TURN_URLS); const turnUsername = String(process.env.VIDEO_TURN_USERNAME || '').trim(); const turnCredential = String(process.env.VIDEO_TURN_CREDENTIAL || '').trim(); if (turnUrls.length > 0 && turnUsername && turnCredential) { iceServers.push({ urls: turnUrls, username: turnUsername, credential: turnCredential }); } return iceServers; } const VIDEO_ICE_SERVERS = buildVideoIceServers(); const VIDEO_MEDIA_RELAY_CONFIGURED = VIDEO_ICE_SERVERS.some((server) => { const urls = Array.isArray(server?.urls) ? server.urls : [server?.urls]; return urls.some((url) => String(url || '').startsWith('turn:') || String(url || '').startsWith('turns:')); }); if (!VIDEO_MEDIA_RELAY_CONFIGURED) { console.warn('Video-Medienpfad ist nicht vollständig konfiguriert. Setze VIDEO_TURN_URLS, VIDEO_TURN_USERNAME und VIDEO_TURN_CREDENTIAL.'); } class Client { constructor(sessionId) { this.sessionId = sessionId; this.gender = ''; this.country = ''; this.isoCountryCode = ''; this.userName = ''; this.age = 0; this.conversations = {}; this.lastActivity = new Date(); this.loginTimeStamp = new Date(); this.blockedUsers = new Set(); this.socket = null; // Socket.IO Socket-Objekt this.chatAuth = null; // { username, rights: Set } this.pendingChatLogin = null; // { step: 'username'|'password', username: string } } setActivity() { this.lastActivity = new Date(); } activitiesTimedOut() { const secondsSinceActivity = (new Date() - this.lastActivity) / 1000; return secondsSinceActivity > TIMEOUT_SECONDS; } remainingSecondsToTimeout() { const secondsSinceActivity = (new Date() - this.lastActivity) / 1000; return Math.max(0, TIMEOUT_SECONDS - secondsSinceActivity); } currentlyLoggedInSeconds() { return Math.floor((new Date() - this.loginTimeStamp) / 1000); } toJSON() { return { sessionId: this.sessionId, userName: this.userName, gender: this.gender, country: this.country, isoCountryCode: this.isoCountryCode, age: this.age }; } } const CHAT_USERS_FILE_NAME = 'chat-users.json'; const CHAT_RIGHTS = { STAT: 'stat', KICK: 'kick' }; function getLogsDir(__dirname) { return join(__dirname, '../logs'); } function ensureLogsDir(__dirname) { const logsDir = getLogsDir(__dirname); if (!existsSync(logsDir)) { mkdirSync(logsDir, { recursive: true }); } return logsDir; } function getChatUsersPath(__dirname) { return join(ensureLogsDir(__dirname), CHAT_USERS_FILE_NAME); } function sha256(value) { return crypto.createHash('sha256').update(value).digest('hex'); } function ensureChatUsersFile(__dirname) { const usersPath = getChatUsersPath(__dirname); if (existsSync(usersPath)) { return; } // Security: never create predictable default credentials. // Admin users must be configured explicitly in logs/chat-users.json. writeFileSync(usersPath, '[]\n', 'utf-8'); console.warn( `[Auth] ${CHAT_USERS_FILE_NAME} wurde neu erstellt. Bitte mindestens einen Admin-User mit Passwort-Hash konfigurieren.` ); } function loadChatUsers(__dirname) { ensureChatUsersFile(__dirname); const usersPath = getChatUsersPath(__dirname); const raw = readFileSync(usersPath, 'utf-8').trim(); if (!raw) return []; let users = []; try { users = JSON.parse(raw); } catch (error) { throw new Error(`Ungültige ${CHAT_USERS_FILE_NAME}: ${error.message}`); } if (!Array.isArray(users)) { throw new Error(`${CHAT_USERS_FILE_NAME} muss ein Array sein`); } return users .filter((entry) => entry && typeof entry.username === 'string') .map((entry) => ({ username: entry.username.trim(), passwordHash: typeof entry.passwordHash === 'string' ? entry.passwordHash.trim() : '', rights: Array.isArray(entry.rights) ? entry.rights.map((r) => String(r).toLowerCase()) : [] })) .filter((entry) => entry.username && entry.passwordHash); } function parseLoginRecord(line) { const trimmed = line.trim(); if (!trimmed) return null; const parts = trimmed.split(','); if (parts.length < 5) return null; const timestamp = parts[0].trim(); const userName = parts[1].trim(); const gender = parts[parts.length - 1].trim(); const ageRaw = parts[parts.length - 2].trim(); const country = parts.slice(2, parts.length - 2).join(',').trim(); const age = Number.parseInt(ageRaw, 10); const date = new Date(timestamp); if (!timestamp || !userName || !country || Number.isNaN(age) || Number.isNaN(date.getTime())) { return null; } return { timestamp, date, day: timestamp.slice(0, 10), userName, country, age, gender }; } let clients = new Map(); let conversations = new Map(); // Key: "user1:user2" (alphabetisch sortiert) let videoConversations = new Map(); // Key: "user1:user2" let videoSessions = new Map(); // Key: callId // Map: Socket-ID -> Express-Session-ID (für Session-Wiederherstellung) let socketToSessionMap = new Map(); // Exportiere clients Map für Zugriff von außen export function getClientsMap() { return clients; } // Exportiere Funktion zum Abrufen der Session-ID für einen Socket export function getSessionIdForSocket(socketId) { return socketToSessionMap.get(socketId); } export function extractSessionId(handshakeOrRequest) { // Unterstützt sowohl Socket.IO handshake als auch Express request const cookies = handshakeOrRequest.headers?.cookie || handshakeOrRequest.cookies || ''; console.log('extractSessionId - Cookies:', cookies); const sessionMatch = cookies.match(/connect\.sid=([^;]+)/); if (sessionMatch) { let sessionId = sessionMatch[1]; console.log('extractSessionId - Gefundenes Cookie:', sessionId); // Express-Session speichert die Session-ID als signierten Wert: s:xxxxx.signature // Die tatsächliche Session-ID ist nur xxxxx if (sessionId.startsWith('s:')) { const parts = sessionId.split('.'); if (parts.length > 0) { sessionId = parts[0].substring(2); // Entferne 's:' Präfix console.log('extractSessionId - Bereinigte Session-ID:', sessionId); } } return sessionId; } console.log('extractSessionId - Kein connect.sid Cookie gefunden'); // Fallback: Generiere temporäre UUID return crypto.randomUUID(); } // Exportiere Funktion zum Prüfen des Session-Status export function getSessionStatus(req) { // Verwende req.sessionID von Express-Session const sessionId = req.sessionID || extractSessionId(req); if (!sessionId) { return null; } const client = clients.get(sessionId); if (!client || !client.userName) { return null; } return { sessionId: client.sessionId, userName: client.userName, gender: client.gender, age: client.age, country: client.country, isoCountryCode: client.isoCountryCode }; } function getConversationKey(user1, user2) { return [user1, user2].sort().join(':'); } function isSocketConnected(socket) { return !!(socket && socket.connected); } function isClientOnline(client) { return !!(client && client.userName && isSocketConnected(client.socket)); } function getClientByUserName(userName) { for (const client of clients.values()) { if (client.userName === userName) { return client; } } return null; } function getOrCreateVideoConversation(user1, user2) { const users = [user1, user2].sort(); const convKey = users.join(':'); let state = videoConversations.get(convKey); if (!state) { state = { convKey, users, consents: { [users[0]]: false, [users[1]]: false }, activeCallIds: new Set() }; videoConversations.set(convKey, state); } else { for (const user of users) { if (typeof state.consents[user] !== 'boolean') { state.consents[user] = false; } } if (!(state.activeCallIds instanceof Set)) { state.activeCallIds = new Set(Array.isArray(state.activeCallIds) ? state.activeCallIds : []); } } return state; } function getExistingVideoConversation(user1, user2) { return videoConversations.get(getConversationKey(user1, user2)) || null; } function getVideoConversationForRead(user1, user2) { const existing = getExistingVideoConversation(user1, user2); if (existing) { return existing; } const users = [user1, user2].sort(); return { convKey: users.join(':'), users, consents: { [users[0]]: false, [users[1]]: false }, activeCallIds: new Set() }; } function removeVideoConversationIfEmpty(user1, user2) { const convKey = getConversationKey(user1, user2); const state = videoConversations.get(convKey); if (!state) return; const hasConsent = Object.values(state.consents || {}).some(Boolean); const hasActiveCalls = Array.from(state.activeCallIds || []).some((callId) => { const session = videoSessions.get(callId); return session && ACTIVE_VIDEO_SESSION_STATUSES.has(session.status); }); if (!hasConsent && !hasActiveCalls) { videoConversations.delete(convKey); } } function getOtherParticipant(session, userName) { return session.participants.find((participant) => participant !== userName) || null; } function buildVideoConsentPayload(viewerName, state) { const otherUserName = state.users.find((user) => user !== viewerName) || null; return { withUserName: otherUserName, localConsent: !!state.consents[viewerName], remoteConsent: otherUserName ? !!state.consents[otherUserName] : false, videoVisible: !!(otherUserName && state.consents[viewerName] && state.consents[otherUserName]) }; } function emitVideoConsentStateToUser(userName, otherUserName) { const client = getClientByUserName(userName); if (!isClientOnline(client)) return; const state = getVideoConversationForRead(userName, otherUserName); client.socket.emit('videoConsent:update', buildVideoConsentPayload(userName, state)); } function emitVideoConsentStateToParticipants(user1, user2) { emitVideoConsentStateToUser(user1, user2); emitVideoConsentStateToUser(user2, user1); } function buildVideoCallPayloadForUser(session, userName) { const otherUserName = getOtherParticipant(session, userName); const muteStates = session.muteStates || {}; const connectionStates = session.connectionStates || {}; const includeMedia = session.status === 'connecting' || session.status === 'active'; return { callId: session.callId, roomId: session.roomId, withUserName: otherUserName, initiatedBy: session.initiatedBy, status: session.status, createdAt: session.createdAt, updatedAt: session.updatedAt, endedAt: session.endedAt || null, reason: session.reason || null, localMuted: !!muteStates[userName], remoteMuted: otherUserName ? !!muteStates[otherUserName] : false, connectionState: connectionStates[userName] || 'new', remoteConnectionState: otherUserName ? (connectionStates[otherUserName] || 'new') : 'new', media: includeMedia ? { mode: 'webrtc-relay', relayOnly: true, iceTransportPolicy: 'relay', iceServers: VIDEO_ICE_SERVERS, isCaller: session.initiatedBy === userName } : null }; } function emitVideoCallEventToUser(userName, eventName, session) { const client = getClientByUserName(userName); if (!isClientOnline(client)) return; client.socket.emit(eventName, buildVideoCallPayloadForUser(session, userName)); } function emitVideoCapacityToUser(userName) { const client = getClientByUserName(userName); if (!isClientOnline(client)) return; const activeConnections = getActiveVideoSessionsForUser(userName).length; client.socket.emit('videoCall:capacity', { activeConnections, maxConnections: MAX_ACTIVE_VIDEO_CONNECTIONS, reachedMax: activeConnections >= MAX_ACTIVE_VIDEO_CONNECTIONS }); } function getActiveVideoSessionsForUser(userName) { return Array.from(videoSessions.values()).filter((session) => ( session.participants.includes(userName) && ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) )); } function hasVideoCapacity(userName) { return getActiveVideoSessionsForUser(userName).length < MAX_ACTIVE_VIDEO_CONNECTIONS; } function findActiveVideoSessionBetween(user1, user2) { return Array.from(videoSessions.values()).find((session) => ( ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) && session.participants.includes(user1) && session.participants.includes(user2) )) || null; } function createVideoSession(callerUserName, calleeUserName) { const now = new Date().toISOString(); const callId = crypto.randomUUID(); return { callId, roomId: `video-${callId}`, participants: [callerUserName, calleeUserName], initiatedBy: callerUserName, status: 'ringing', muteStates: { [callerUserName]: false, [calleeUserName]: false }, connectionStates: { [callerUserName]: 'new', [calleeUserName]: 'new' }, createdAt: now, updatedAt: now, endedAt: null, reason: null }; } function touchVideoSession(session, status, reason = null) { session.status = status; session.updatedAt = new Date().toISOString(); if (reason) { session.reason = reason; } if (TERMINAL_VIDEO_SESSION_STATUSES.has(status)) { session.endedAt = session.updatedAt; } } function registerVideoSession(session) { videoSessions.set(session.callId, session); const state = getOrCreateVideoConversation(session.participants[0], session.participants[1]); state.activeCallIds.add(session.callId); return state; } function finalizeVideoSession(session) { const state = getExistingVideoConversation(session.participants[0], session.participants[1]); if (state) { state.activeCallIds.delete(session.callId); } videoSessions.delete(session.callId); removeVideoConversationIfEmpty(session.participants[0], session.participants[1]); } function sendVideoCallError(socket, code, message, details = {}) { socket.emit('videoCall:error', { code, message, ...details }); } function emitVideoCallUpdateToParticipants(session) { emitVideoCallEventToUser(session.participants[0], 'videoCall:update', session); emitVideoCallEventToUser(session.participants[1], 'videoCall:update', session); } function buildVideoSignalPayload(callId, fromUserName, signalType, details = {}) { return { callId, fromUserName, signalType, ...details }; } function isRelayIceCandidate(candidate) { if (!candidate || typeof candidate !== 'object') { return false; } if (candidate.type) { return String(candidate.type).toLowerCase() === 'relay'; } const candidateLine = String(candidate.candidate || ''); return /\btyp relay\b/i.test(candidateLine); } function endVideoSession(session, reason = 'ended', initiatorUserName = null) { if (!session || TERMINAL_VIDEO_SESSION_STATUSES.has(session.status)) { return; } touchVideoSession(session, 'ended', reason); for (const participant of session.participants) { const payload = buildVideoCallPayloadForUser(session, participant); if (initiatorUserName) { payload.endedBy = initiatorUserName; } const client = getClientByUserName(participant); if (isClientOnline(client)) { client.socket.emit('videoCall:end', payload); } } finalizeVideoSession(session); for (const participant of session.participants) { emitVideoCapacityToUser(participant); } } function endVideoSessionsForPair(user1, user2, reason = 'ended', initiatorUserName = null) { for (const session of Array.from(videoSessions.values())) { if ( ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) && session.participants.includes(user1) && session.participants.includes(user2) ) { endVideoSession(session, reason, initiatorUserName); } } } function endAllVideoSessionsForUser(userName, reason = 'ended', initiatorUserName = null) { for (const session of Array.from(videoSessions.values())) { if (ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) && session.participants.includes(userName)) { endVideoSession(session, reason, initiatorUserName); } } } function logClientLogin(client, __dirname) { try { const logsDir = join(__dirname, '../logs'); // Erstelle logs-Verzeichnis falls es nicht existiert if (!existsSync(logsDir)) { try { mkdirSync(logsDir, { recursive: true }); console.log(`[Log] Logs-Verzeichnis erstellt: ${logsDir}`); } catch (mkdirError) { console.error(`[Log] Fehler beim Erstellen des Logs-Verzeichnisses: ${mkdirError.message}`); return; // Beende Funktion, wenn Verzeichnis nicht erstellt werden kann } } const logPath = join(logsDir, 'logins.log'); const logEntry = `${new Date().toISOString()},${client.userName},${client.country},${client.age},${client.gender}\n`; appendFileSync(logPath, logEntry, 'utf-8'); } catch (error) { console.error('Fehler beim Loggen des Logins:', error.message); } } function checkAndLogStart(__dirname) { try { const logsDir = join(__dirname, '../logs'); // Erstelle logs-Verzeichnis falls es nicht existiert if (!existsSync(logsDir)) { try { mkdirSync(logsDir, { recursive: true }); console.log(`[Log] Logs-Verzeichnis erstellt: ${logsDir}`); } catch (mkdirError) { console.error(`[Log] Fehler beim Erstellen des Logs-Verzeichnisses: ${mkdirError.message}`); return; // Beende Funktion, wenn Verzeichnis nicht erstellt werden kann } } const logPath = join(logsDir, 'starts.log'); const logEntry = `${new Date().toISOString()}\n`; appendFileSync(logPath, logEntry, 'utf-8'); } catch (error) { console.error('Fehler beim Loggen des Starts:', error.message); } } export function setupBroadcast(io, __dirname) { // Länderliste beim Start laden let countriesMap = {}; async function downloadCountries() { try { const response = await axios.get('https://pkgstore.datahub.io/core/country-list/data_csv/data/d7c9d7cfb42cb69f4422dec222dbbaa8/data_csv.csv'); const lines = response.data.split('\n'); countriesMap = {}; // Parse CSV-Zeile mit Berücksichtigung von Anführungszeichen const parseCSVLine = (line) => { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { // Ignoriere Anführungszeichen, sie werden nicht zum Wert hinzugefügt inQuotes = !inQuotes; } else if (char === ',' && !inQuotes) { result.push(current.trim()); current = ''; } else { current += char; } } result.push(current.trim()); return result; }; for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; const [name, code] = parseCSVLine(line); if (name && code) { // Entferne alle Anführungszeichen (auch am Anfang/Ende) const cleanName = name.replace(/^["']+|["']+$/g, '').replace(/["']/g, '').trim(); const cleanCode = code.replace(/^["']+|["']+$/g, '').replace(/["']/g, '').trim(); if (cleanName && cleanCode) { countriesMap[cleanName] = cleanCode.toLowerCase(); } } } console.log(`Länderliste geladen: ${Object.keys(countriesMap).length} Länder`); } catch (error) { console.error('Fehler beim Laden der Länderliste:', error); } } downloadCountries(); setInterval(downloadCountries, 24 * 60 * 60 * 1000); // Täglich aktualisieren function sendCommandResult(socket, lines, kind = 'info') { const payload = Array.isArray(lines) ? lines : [String(lines)]; socket.emit('commandResult', { lines: payload, kind }); } function sendCommandTable(socket, title, columns, rows) { socket.emit('commandTable', { title: String(title || 'Ausgabe'), columns: Array.isArray(columns) ? columns.map((c) => String(c)) : [], rows: Array.isArray(rows) ? rows : [] }); } function hasRight(client, right) { return !!client.chatAuth && client.chatAuth.rights instanceof Set && client.chatAuth.rights.has(right); } function getLoginsPath() { return join(ensureLogsDir(__dirname), 'logins.log'); } function readLoginRecords() { const logPath = getLoginsPath(); if (!existsSync(logPath)) return []; const raw = readFileSync(logPath, 'utf-8'); return raw .split('\n') .map(parseLoginRecord) .filter(Boolean); } function aggregateTop(items, keySelector, limit = 10) { const counts = new Map(); for (const item of items) { const key = keySelector(item); counts.set(key, (counts.get(key) || 0) + 1); } return Array.from(counts.entries()) .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .slice(0, limit); } function readStartsLogStats() { const path = join(ensureLogsDir(__dirname), 'starts.log'); if (!existsSync(path)) { return { count: 0, first: null, last: null }; } const lines = readFileSync(path, 'utf-8') .split('\n') .map((l) => l.trim()) .filter(Boolean); if (lines.length === 0) { return { count: 0, first: null, last: null }; } return { count: lines.length, first: lines[0], last: lines[lines.length - 1] }; } /** * Sammelt alle verfügbaren Kennzahlen: Live-Server, starts.log, vollständige Auswertung von logins.log * (entspricht inhaltlich den /stat-Unterbefehlen in einer Tabelle). */ function buildFullAllStatsRows(records) { const rows = []; const push = (a, b) => rows.push([String(a), b == null ? '' : String(b)]); const liveOnline = Array.from(clients.values()).filter( (c) => c.userName && c.socket && c.socket.connected ).length; const socketCount = io.sockets && io.sockets.sockets && typeof io.sockets.sockets.size === 'number' ? io.sockets.sockets.size : 0; let totalMessages = 0; for (const msgs of conversations.values()) { totalMessages += Array.isArray(msgs) ? msgs.length : 0; } const starts = readStartsLogStats(); push('— Live (Server) —', ''); push('Nutzer mit Chat-Login und aktiver Verbindung', liveOnline); push('Socket.IO-Verbindungen (/)', socketCount); push('Client-Sitzungen im Speicher', clients.size); push('Unterhaltungen im Speicher (Paare)', conversations.size); push('Nachrichten-Einträge gesamt (RAM)', totalMessages); push('Server-Starts protokolliert (starts.log)', starts.count); if (starts.first) { push('Erster Start (Timestamp)', starts.first); } if (starts.last) { push('Letzter Start (Timestamp)', starts.last); } push('— Login-Protokoll (logins.log, UTC) —', ''); if (records.length === 0) { push('Keine Einträge', '—'); return rows; } const sortedByDate = records.slice().sort((a, b) => a.date - b.date); const firstTs = sortedByDate[0].timestamp; const lastTs = sortedByDate[sortedByDate.length - 1].timestamp; const daysSorted = records.map((r) => r.day).sort(); const dayMin = daysSorted[0]; const dayMax = daysSorted[daysSorted.length - 1]; const today = new Date().toISOString().slice(0, 10); const dayRecords = records.filter((r) => r.day === today); const latestByUserToday = new Map(); dayRecords .slice() .sort((a, b) => b.date - a.date) .forEach((record) => { if (!latestByUserToday.has(record.userName)) { latestByUserToday.set(record.userName, record); } }); const uniqueToday = latestByUserToday.size; const namesToday = Array.from(latestByUserToday.keys()).sort((a, b) => a.localeCompare(b, 'de')); const uniqueNames = new Set(records.map((r) => r.userName)).size; const ages = records.map((r) => r.age); const minAge = Math.min(...ages); const maxAge = Math.max(...ages); const youngest = records.find((r) => r.age === minAge); const oldest = records.find((r) => r.age === maxAge); push('Zeitraum (Kalendertage)', `${dayMin} … ${dayMax}`); push('Erster Login (Timestamp)', firstTs); push('Letzter Login (Timestamp)', lastTs); push('Logins gesamt', records.length); push(`Logins heute (${today} UTC)`, dayRecords.length); push('Verschiedene Nutzer heute (UTC)', uniqueToday); const listToday = namesToday.join(', '); push( 'Nutzer heute (Liste)', listToday.length > 500 ? `${uniqueToday} Nutzer — Auszug: ${namesToday.slice(0, 8).join(', ')} … (vollständig: /stat today)` : listToday || '—' ); push('Verschiedene Namen (historisch)', uniqueNames); push('Ø Logins pro Namen (historisch)', (records.length / uniqueNames).toFixed(2)); push('Jüngster Nutzer (erster Treffer im Log)', `${youngest.userName} (${youngest.age})`); push('Ältester Nutzer (erster Treffer im Log)', `${oldest.userName} (${oldest.age})`); const topGenders = aggregateTop( records, (r) => (r.gender || '').trim() || '?', 15 ); push('Geschlecht (Häufigkeit)', topGenders.map(([g, n]) => `${g}(${n})`).join(', ') || '—'); const topCountries = aggregateTop(records, (r) => r.country, 20); push('Top 20 Länder', topCountries.map(([c, n]) => `${c}(${n})`).join(', ') || '—'); const topNames = aggregateTop(records, (r) => r.userName, 20); const topNamesDetail = topNames .map(([name, count]) => { const latestRecord = records .filter((r) => r.userName === name) .sort((a, b) => b.date - a.date)[0]; return `${name}(${count}, zuletzt ${latestRecord.timestamp})`; }) .join('; '); push( 'Top 20 Namen (Anzahl, letzter Login)', topNamesDetail.length > 1200 ? `${topNamesDetail.slice(0, 1197)}…` : topNamesDetail ); push('Hinweis', 'Tabellen je Tag/Zeitraum: /stat today | /stat date | /stat range'); return rows; } function executeStatsCommand(socket, client, parts) { if (!hasRight(client, CHAT_RIGHTS.STAT)) { sendCommandResult(socket, 'Keine Berechtigung: Recht "stat" fehlt.'); return; } const records = readLoginRecords(); if (records.length === 0) { sendCommandResult(socket, 'Keine Login-Daten vorhanden.'); return; } const sub = (parts[1] || '').toLowerCase(); if (!sub || sub === 'help') { sendCommandTable(socket, 'Hilfe: Statistik-Befehle', ['Befehl', 'Beschreibung'], [ ['/stat today', 'Logins des heutigen Tages'], ['/stat date YYYY-MM-DD', 'Logins an einem bestimmten Datum'], ['/stat range YYYY-MM-DD YYYY-MM-DD', 'Logins pro Tag im Zeitraum'], ['/stat ages', 'Jüngster und ältester Nutzer'], ['/stat names', 'Häufigkeit der verwendeten Namen'], ['/stat countries', 'Häufigkeit der Länder'], ['/all-stats', 'Alle Kennzahlen: Live-Server + starts.log + komplette logins.log-Auswertung'] ]); return; } if (sub === 'today') { const day = new Date().toISOString().slice(0, 10); const dayRecords = records.filter((r) => r.day === day); const latestByUserToday = new Map(); dayRecords .slice() .sort((a, b) => b.date - a.date) .forEach((record) => { if (!latestByUserToday.has(record.userName)) { latestByUserToday.set(record.userName, record); } }); const uniqueUsersToday = Array.from(latestByUserToday.values()) .sort((a, b) => a.userName.localeCompare(b.userName, 'de')) .map((r) => `${r.userName} (${r.age})`); sendCommandTable(socket, 'Statistik: Heute', ['Metrik', 'Wert'], [ ['Tag', day], ['Logins', dayRecords.length], ['Heute eingeloggt', uniqueUsersToday.length > 0 ? uniqueUsersToday.join(', ') : '-'] ]); return; } if (sub === 'date') { const day = parts[2]; if (!day || !/^\d{4}-\d{2}-\d{2}$/.test(day)) { sendCommandResult(socket, 'Nutzung: /stat date YYYY-MM-DD'); return; } const dayRecords = records.filter((r) => r.day === day); sendCommandTable(socket, 'Statistik: Datum', ['Tag', 'Logins'], [[day, dayRecords.length]]); return; } if (sub === 'range') { const from = parts[2]; const to = parts[3]; if (!from || !to || !/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) { sendCommandResult(socket, 'Nutzung: /stat range YYYY-MM-DD YYYY-MM-DD'); return; } const filtered = records.filter((r) => r.day >= from && r.day <= to); const perDay = aggregateTop(filtered, (r) => r.day, 1000) .sort((a, b) => a[0].localeCompare(b[0])) .map(([day, count]) => [day, count]); sendCommandTable(socket, `Statistik: Zeitraum ${from} bis ${to}`, ['Tag', 'Logins'], perDay); return; } if (sub === 'ages') { const ages = records.map((r) => r.age); const minAge = Math.min(...ages); const maxAge = Math.max(...ages); const youngest = records.find((r) => r.age === minAge); const oldest = records.find((r) => r.age === maxAge); sendCommandTable(socket, 'Statistik: Alter', ['Kategorie', 'Name', 'Alter'], [ ['Jüngster Nutzer', youngest.userName, youngest.age], ['Ältester Nutzer', oldest.userName, oldest.age] ]); return; } if (sub === 'names') { const topNames = aggregateTop(records, (r) => r.userName, 20); sendCommandTable( socket, `Statistik: Namen (gesamt verschieden: ${new Set(records.map((r) => r.userName)).size})`, ['Name', 'Alter', 'Anzahl', 'Letzter Login'], topNames.map(([name, count]) => { const latestRecord = records .filter((r) => r.userName === name) .sort((a, b) => b.date - a.date)[0]; return [name, latestRecord ? latestRecord.age : '-', count, latestRecord ? latestRecord.timestamp : '-']; }) ); return; } if (sub === 'countries') { const topCountries = aggregateTop(records, (r) => r.country, 20); sendCommandTable( socket, 'Statistik: Länder', ['Land', 'Anzahl'], topCountries.map(([country, count]) => [country, count]) ); return; } sendCommandResult(socket, 'Unbekannter /stat-Befehl. Nutze /stat help'); } function executeAllStatsCommand(socket, client) { if (!hasRight(client, CHAT_RIGHTS.STAT)) { sendCommandResult(socket, 'Keine Berechtigung: Recht "stat" fehlt.'); return; } const rows = buildFullAllStatsRows(readLoginRecords()); sendCommandTable(socket, 'Statistik: Vollständige Übersicht (/all-stats)', ['Metrik', 'Wert'], rows); } function executeKickCommand(socket, client, parts) { if (!hasRight(client, CHAT_RIGHTS.KICK)) { sendCommandResult(socket, 'Keine Berechtigung: Recht "kick" fehlt.'); return; } const targetName = (parts[1] || '').trim(); if (!targetName) { sendCommandResult(socket, 'Nutzung: /kick '); return; } let targetSessionId = null; let targetClient = null; for (const [sid, c] of clients.entries()) { if (c.userName === targetName && c.socket && c.socket.connected) { targetSessionId = sid; targetClient = c; break; } } if (!targetClient) { sendCommandResult(socket, `User "${targetName}" ist nicht online.`); return; } if (targetClient.socket) { targetClient.socket.emit('error', { message: 'Du wurdest vom Chat getrennt (kick).' }); targetClient.socket.disconnect(true); } clients.delete(targetSessionId); broadcastUserList(); sendCommandResult(socket, `User "${targetName}" wurde gekickt.`); } function verifyChatUser(username, password) { const users = loadChatUsers(__dirname); const user = users.find((u) => u.username.toLowerCase() === username.toLowerCase()); if (!user) return null; const [algo, hash] = user.passwordHash.split(':'); if (algo !== 'sha256' || !hash) return null; const inputHash = sha256(password); if (inputHash !== hash) return null; return { username: user.username, rights: new Set(user.rights) }; } function executeCommand(socket, client, rawInput) { const input = rawInput.trim(); // Laufender Login-Dialog: Nur Eingaben ohne Slash als Username/Passwort behandeln. if (client.pendingChatLogin && !input.startsWith('/')) { if (client.pendingChatLogin.step === 'username') { const enteredUser = input; if (!enteredUser) { sendCommandResult(socket, 'Username darf nicht leer sein. Bitte Username eingeben:', 'loginPromptUsername'); return true; } client.pendingChatLogin = { step: 'password', username: enteredUser }; sendCommandResult(socket, 'Passwort eingeben:', 'loginPromptPassword'); return true; } if (client.pendingChatLogin.step === 'password') { const username = client.pendingChatLogin.username; const auth = verifyChatUser(username, input); client.pendingChatLogin = null; if (!auth) { sendCommandResult(socket, 'Login fehlgeschlagen. Benutzername oder Passwort falsch.', 'loginError'); return true; } client.chatAuth = auth; sendCommandResult( socket, `Login erfolgreich als ${auth.username}. Rechte: ${Array.from(auth.rights).join(', ') || 'keine'}`, 'loginSuccess' ); return true; } } else if (client.pendingChatLogin && input.startsWith('/')) { // Ein neuer /Befehl bricht den Login-Vorgang ab. client.pendingChatLogin = null; sendCommandResult(socket, 'Login-Vorgang abgebrochen.', 'loginAbort'); // und läuft unten als normaler Befehl weiter } if (!input.startsWith('/')) return false; const parts = input.split(/\s+/); const command = parts[0].toLowerCase(); if (command === '/login') { const username = (parts[1] || '').trim(); if (username) { client.pendingChatLogin = { step: 'password', username }; sendCommandResult(socket, 'Passwort eingeben:', 'loginPromptPassword'); } else { client.pendingChatLogin = { step: 'username', username: '' }; sendCommandResult(socket, 'Username eingeben:', 'loginPromptUsername'); } return true; } if (command === '/logout-admin') { const wasLoggedIn = !!client.chatAuth; client.chatAuth = null; client.pendingChatLogin = null; sendCommandResult( socket, wasLoggedIn ? 'Admin/Command-Login wurde abgemeldet.' : 'Es war kein Admin/Command-Login aktiv.', 'loginLogout' ); return true; } if (command === '/whoami-rights') { if (!client.chatAuth) { sendCommandResult(socket, 'Nicht per Command-Login angemeldet.', 'whoami'); return true; } sendCommandResult(socket, [ `Angemeldet als: ${client.chatAuth.username}`, `Rechte: ${Array.from(client.chatAuth.rights).join(', ') || 'keine'}` ], 'whoami'); return true; } if (command === '/help' || command === '/?') { sendCommandTable(socket, 'Hilfe: Verfügbare Befehle', ['Befehl', 'Beschreibung'], [ ['/login [username]', 'Admin-/Command-Login starten'], ['/logout-admin', 'Admin-/Command-Login beenden'], ['/whoami-rights', 'Aktuelle Admin-Rechte anzeigen'], ['/stat help', 'Hilfe zu Statistikbefehlen anzeigen'], ['/all-stats', 'Alle Kennzahlen: Live-Server + starts.log + komplette logins.log-Auswertung'], ['/kick ', 'Benutzer aus dem Chat werfen'], ['/help oder /?', 'Diese Hilfe anzeigen'] ]); return true; } if (command === '/stat') { executeStatsCommand(socket, client, parts); return true; } if (command === '/all-stats') { executeAllStatsCommand(socket, client); return true; } if (command === '/kick') { executeKickCommand(socket, client, parts); return true; } sendCommandResult(socket, `Unbekannter Befehl: ${command}`, 'unknown'); return true; } // Socket.IO-Verbindungshandler io.on('connection', (socket) => { const request = socket.handshake; // Versuche Session-ID aus Cookie zu extrahieren let sessionId = extractSessionId(request); console.log('Socket.IO Connect - Session-ID (aus Cookie):', sessionId); console.log('Socket.IO Connect - Cookie:', request.headers?.cookie); // Wenn keine gültige Session-ID gefunden wurde, generiere eine temporäre UUID // Diese wird beim Login durch die Express-Session-ID ersetzt (siehe handleLogin) if (!sessionId || sessionId.length < 10 || !sessionId.match(/^[a-zA-Z0-9_-]+$/)) { sessionId = crypto.randomUUID(); console.log('Socket.IO Connect - Keine gültige Session-ID gefunden, verwende temporäre UUID:', sessionId); socket.data.temporarySessionId = true; // Markiere als temporär } // Speichere Socket-ID mit Session-ID socket.data.sessionId = sessionId; socketToSessionMap.set(socket.id, sessionId); let client = clients.get(sessionId); if (!client) { console.log('Socket.IO Connect - Neuer Client erstellt für Session-ID:', sessionId); client = new Client(sessionId); clients.set(sessionId, client); } else { console.log('Socket.IO Connect - Bestehender Client gefunden für Session-ID:', sessionId, 'User:', client.userName); } // Speichere Socket mit Session-ID client.socket = socket; // Bestätigung an Client senden, inklusive Benutzerdaten falls bereits eingeloggt const connectedData = { sessionId }; if (client.userName) { connectedData.user = { userName: client.userName, gender: client.gender, age: client.age, country: client.country, isoCountryCode: client.isoCountryCode }; connectedData.loggedIn = true; } socket.emit('connected', connectedData); socket.on('disconnect', (reason) => { const currentSessionId = socket.data.sessionId || sessionId; socketToSessionMap.delete(socket.id); console.log(`[Disconnect] Socket getrennt für Session-ID: ${currentSessionId}, Grund: ${reason}`); const client = clients.get(currentSessionId); if (client) { console.log(`[Disconnect] Client gefunden: ${client.userName || 'unbekannt'}, Socket war verbunden: ${client.socket ? client.socket.connected : 'null'}`); if (client.userName) { endAllVideoSessionsForUser(client.userName, 'disconnect', client.userName); } // Setze Socket auf null, damit keine Nachrichten mehr an diesen Client gesendet werden // ABER: Lösche den Client NICHT, damit die Session beim Reload wiederhergestellt werden kann client.socket = null; // Aktualisiere Benutzerliste, damit andere Clients sehen, dass dieser Benutzer offline ist if (client.userName) { console.log(`[Disconnect] Aktualisiere Benutzerliste nach Disconnect von ${client.userName}`); broadcastUserList(); } // Client bleibt in der Map, damit Session-Wiederherstellung funktioniert } else { console.log(`[Disconnect] Kein Client gefunden für Session-ID: ${sessionId}`); } }); socket.on('error', (error) => { console.error('Socket.IO-Fehler:', error); }); // Socket.IO Event-Handler: Setze Express-Session-ID socket.on('setSessionId', (data) => { const { expressSessionId } = data; if (expressSessionId) { console.log('setSessionId - Express-Session-ID erhalten:', expressSessionId); const currentSessionId = socket.data.sessionId; socketToSessionMap.set(socket.id, expressSessionId); if (currentSessionId !== expressSessionId) { console.log('setSessionId - Aktualisiere Session-ID von', currentSessionId, 'zu', expressSessionId); // Prüfe, ob bereits ein Client mit dieser Session-ID existiert let existingClient = clients.get(expressSessionId); // Wenn kein Client mit dieser Session-ID gefunden wurde, prüfe ob ein Client mit dem aktuellen Socket existiert // und bereits eingeloggt ist (für Session-Wiederherstellung nach Reload) if (!existingClient) { const currentClient = clients.get(currentSessionId); if (currentClient && currentClient.userName) { console.log('setSessionId - Client mit Session-ID', currentSessionId, 'ist bereits eingeloggt:', currentClient.userName); // Verwende bestehenden Client und aktualisiere nur die Session-ID existingClient = currentClient; clients.delete(currentSessionId); clients.set(expressSessionId, existingClient); existingClient.sessionId = expressSessionId; } else { // Wenn kein Client mit der aktuellen Session-ID gefunden wurde, suche nach einem Client, // der bereits eingeloggt ist und keinen aktiven Socket hat (für Session-Wiederherstellung) // Dies ist ein Fallback für den Fall, dass die Session-ID beim Reload geändert wurde for (const [sid, c] of clients.entries()) { if (c.userName && (!c.socket || !c.socket.connected)) { console.log('setSessionId - Gefundener Client ohne aktiven Socket:', sid, c.userName); // Verwende diesen Client und aktualisiere die Session-ID existingClient = c; clients.delete(sid); clients.set(expressSessionId, existingClient); existingClient.sessionId = expressSessionId; break; } } } } if (existingClient) { // Verwende bestehenden Client console.log('setSessionId - Verwende bestehenden Client, User:', existingClient.userName || 'nicht eingeloggt'); existingClient.socket = socket; socket.data.sessionId = expressSessionId; // Entferne alten Client, falls unterschiedlich if (currentSessionId !== expressSessionId && clients.has(currentSessionId)) { clients.delete(currentSessionId); } // Sende Login-Status zurück, falls bereits eingeloggt if (existingClient.userName) { console.log('setSessionId - Sende Login-Status zurück für User:', existingClient.userName); socket.emit('connected', { sessionId: expressSessionId, loggedIn: true, user: existingClient.toJSON() }); emitVideoCapacityToUser(existingClient.userName); // Aktualisiere Userliste für alle Clients, damit der wiederhergestellte Client die Liste erhält broadcastUserList(); } } else { // Erstelle neuen Client mit Express-Session-ID console.log('setSessionId - Erstelle neuen Client'); const newClient = new Client(expressSessionId); newClient.socket = socket; clients.set(expressSessionId, newClient); socket.data.sessionId = expressSessionId; // Entferne alten Client if (clients.has(currentSessionId)) { clients.delete(currentSessionId); } } } } }); // Socket.IO Event-Handler // WICHTIG: Hole den Client immer dynamisch basierend auf socket.data.sessionId, // da sich die Session-ID nach setSessionId ändern kann socket.on('login', async (data) => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); await handleLogin(socket, currentClient, data); } catch (error) { console.error('Fehler beim Verarbeiten der Login-Nachricht:', error); socket.emit('error', { message: error.message }); } }); socket.on('message', async (data) => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); handleMessage(socket, currentClient, data); } catch (error) { console.error('Fehler beim Verarbeiten der Message-Nachricht:', error); socket.emit('error', { message: error.message }); } }); socket.on('requestConversation', async (data) => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); handleRequestConversation(socket, currentClient, data); } catch (error) { console.error('Fehler beim Verarbeiten der RequestConversation-Nachricht:', error); socket.emit('error', { message: error.message }); } }); socket.on('userSearch', async (data) => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); handleUserSearch(socket, currentClient, data); } catch (error) { console.error('Fehler beim Verarbeiten der UserSearch-Nachricht:', error); socket.emit('error', { message: error.message }); } }); socket.on('requestHistory', async () => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); handleRequestHistory(socket, currentClient); } catch (error) { console.error('Fehler beim Verarbeiten der RequestHistory-Nachricht:', error); socket.emit('error', { message: error.message }); } }); socket.on('requestOpenConversations', async () => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); handleRequestOpenConversations(socket, currentClient); } catch (error) { console.error('Fehler beim Verarbeiten der RequestOpenConversations-Nachricht:', error); socket.emit('error', { message: error.message }); } }); socket.on('blockUser', async (data) => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); handleBlockUser(socket, currentClient, data); } catch (error) { console.error('Fehler beim Verarbeiten der BlockUser-Nachricht:', error); socket.emit('error', { message: error.message }); } }); socket.on('unblockUser', async (data) => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); handleUnblockUser(socket, currentClient, data); } catch (error) { console.error('Fehler beim Verarbeiten der UnblockUser-Nachricht:', error); socket.emit('error', { message: error.message }); } }); socket.on('videoConsent:set', async (data) => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); handleVideoConsentSet(socket, currentClient, data); } catch (error) { console.error('Fehler beim Verarbeiten von videoConsent:set:', error); sendVideoCallError(socket, 'VIDEO_CONSENT_SET_FAILED', error.message); } }); socket.on('videoCall:invite', async (data) => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); handleVideoCallInvite(socket, currentClient, data); } catch (error) { console.error('Fehler beim Verarbeiten von videoCall:invite:', error); sendVideoCallError(socket, 'VIDEO_INVITE_FAILED', error.message); } }); socket.on('videoCall:accept', async (data) => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); handleVideoCallAccept(socket, currentClient, data); } catch (error) { console.error('Fehler beim Verarbeiten von videoCall:accept:', error); sendVideoCallError(socket, 'VIDEO_ACCEPT_FAILED', error.message); } }); socket.on('videoCall:reject', async (data) => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); handleVideoCallReject(socket, currentClient, data); } catch (error) { console.error('Fehler beim Verarbeiten von videoCall:reject:', error); sendVideoCallError(socket, 'VIDEO_REJECT_FAILED', error.message); } }); socket.on('videoCall:cancel', async (data) => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); handleVideoCallCancel(socket, currentClient, data); } catch (error) { console.error('Fehler beim Verarbeiten von videoCall:cancel:', error); sendVideoCallError(socket, 'VIDEO_CANCEL_FAILED', error.message); } }); socket.on('videoCall:end', async (data) => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); handleVideoCallEnd(socket, currentClient, data); } catch (error) { console.error('Fehler beim Verarbeiten von videoCall:end:', error); sendVideoCallError(socket, 'VIDEO_END_FAILED', error.message); } }); socket.on('videoCall:muteState', async (data) => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); handleVideoCallMuteState(socket, currentClient, data); } catch (error) { console.error('Fehler beim Verarbeiten von videoCall:muteState:', error); sendVideoCallError(socket, 'VIDEO_MUTE_STATE_FAILED', error.message); } }); socket.on('videoCall:signal', async (data) => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); handleVideoCallSignal(socket, currentClient, data); } catch (error) { console.error('Fehler beim Verarbeiten von videoCall:signal:', error); sendVideoCallError(socket, 'VIDEO_SIGNAL_FAILED', error.message); } }); socket.on('videoCall:connectionState', async (data) => { try { const currentClient = clients.get(socket.data.sessionId); if (!currentClient) { socket.emit('error', { message: 'Client nicht gefunden' }); return; } currentClient.setActivity(); handleVideoCallConnectionState(socket, currentClient, data); } catch (error) { console.error('Fehler beim Verarbeiten von videoCall:connectionState:', error); sendVideoCallError(socket, 'VIDEO_CONNECTION_STATE_FAILED', error.message); } }); }); async function handleLogin(socket, client, data) { const { userName, gender, age, country, expressSessionId } = data; // Validierung if (!userName || userName.trim().length < 3) { socket.emit('error', { message: 'Benutzername muss mindestens 3 Zeichen lang sein' }); return; } // Wenn eine Express-Session-ID übergeben wurde, verwende diese statt der Socket-ID if (expressSessionId) { console.log('handleLogin - Express-Session-ID erhalten:', expressSessionId); // Entferne alten Client mit temporärer Socket-ID, falls vorhanden const oldSessionId = socket.data.sessionId; if (oldSessionId && oldSessionId !== expressSessionId && clients.has(oldSessionId)) { console.log('handleLogin - Entferne alten Client mit Session-ID:', oldSessionId); clients.delete(oldSessionId); } // Prüfe, ob bereits ein Client mit dieser Session-ID existiert let existingClient = clients.get(expressSessionId); if (existingClient) { // Verwende bestehenden Client console.log('handleLogin - Verwende bestehenden Client mit Session-ID:', expressSessionId); client = existingClient; // Aktualisiere Socket-Verbindung client.socket = socket; socket.data.sessionId = expressSessionId; } else { // Erstelle neuen Client mit Express-Session-ID console.log('handleLogin - Erstelle neuen Client mit Session-ID:', expressSessionId); client = new Client(expressSessionId); clients.set(expressSessionId, client); client.socket = socket; socket.data.sessionId = expressSessionId; } } else { console.log('handleLogin - Keine Express-Session-ID erhalten, verwende Socket-ID'); } // Prüfe, ob Name bereits verwendet wird for (const [sid, c] of clients.entries()) { if (c.userName === userName && sid !== client.sessionId) { socket.emit('error', { message: 'Dieser Benutzername ist bereits vergeben' }); return; } } client.userName = userName.trim(); client.gender = gender; client.age = age; client.country = country; // ISO-Code aus Länderliste ermitteln client.isoCountryCode = countriesMap[country] || 'unknown'; client.loginTimeStamp = new Date(); client.setActivity(); // Stelle sicher, dass der Socket gesetzt ist if (!client.socket) { console.log('handleLogin - WARNUNG: Socket nicht gesetzt, setze ihn jetzt'); client.socket = socket; } console.log('handleLogin - Client nach Login:', { sessionId: client.sessionId, userName: client.userName, hasSocket: !!client.socket, socketConnected: client.socket ? client.socket.connected : false }); logClientLogin(client, __dirname); checkAndLogStart(__dirname); // Benutzerliste an alle senden broadcastUserList(); // Aktualisiere ungelesene Nachrichten für den neuen Benutzer updateUnreadCount(client); console.log('handleLogin - Sende loginSuccess an Socket'); socket.emit('loginSuccess', { sessionId: client.sessionId, user: client.toJSON() }); emitVideoCapacityToUser(client.userName); } function handleMessage(socket, client, data) { if (!client.userName) { socket.emit('error', { message: 'Nicht eingeloggt' }); return; } const { toUserName, message, messageId, isImage, imageType, imageUrl } = data; // Chat-Befehle werden direkt serverseitig verarbeitet if (!isImage && typeof message === 'string' && executeCommand(socket, client, message)) { return; } if (!toUserName) { socket.emit('error', { message: 'Empfänger fehlt' }); return; } // Finde Empfänger let receiver = null; for (const [sid, c] of clients.entries()) { if (c.userName === toUserName) { receiver = c; break; } } if (!receiver) { socket.emit('error', { message: 'Benutzer nicht gefunden' }); return; } // Prüfe, ob Empfänger noch verbunden ist if (!receiver.socket || !receiver.socket.connected) { // Empfänger ist nicht mehr verbunden, entferne ihn aus der Liste clients.delete(receiver.sessionId); broadcastUserList(); socket.emit('error', { message: 'Benutzer nicht gefunden' }); return; } // Prüfe Blockierung if (receiver.blockedUsers.has(client.userName)) { socket.emit('error', { message: 'Du wurdest von diesem Benutzer blockiert' }); return; } // Speichere Nachricht in Konversation const convKey = getConversationKey(client.userName, toUserName); if (!conversations.has(convKey)) { conversations.set(convKey, []); } const conversation = conversations.get(convKey); conversation.push({ from: client.userName, to: toUserName, message: isImage && imageUrl ? imageUrl : message, // Verwende URL für Bilder messageId, timestamp: new Date().toISOString(), read: false, isImage: isImage || false, imageType: imageType || null, imageUrl: imageUrl || null, imageCode: isImage ? message : null }); // Sende an Empfänger (wenn online) const messagePayload = { from: client.userName, message: isImage && imageUrl ? imageUrl : message, // Verwende URL für Bilder messageId, timestamp: new Date().toISOString(), isImage: isImage || false, imageType: imageType || null, imageUrl: imageUrl || null, imageCode: isImage ? message : null // Code für Server-Referenz }; // Debug-Logging für Bilder if (isImage) { console.log(`[Bild] Sende Bild von ${client.userName} an ${toUserName}, Größe: ${message ? message.length : 0} Zeichen, Typ: ${imageType || 'unbekannt'}`); console.log(`[Bild] Absender Socket verbunden: ${socket.connected}, Empfänger Socket verbunden: ${receiver.socket ? receiver.socket.connected : 'null'}`); } // Prüfe, ob Absender noch verbunden ist if (!socket.connected) { console.error(`[Bild] Absender ${client.userName} Socket nicht mehr verbunden beim Senden!`); return; } // Prüfe, ob Empfänger-Socket noch existiert und verbunden ist if (!receiver.socket || !receiver.socket.connected) { console.error(`[Bild] Empfänger ${toUserName} Socket nicht mehr verbunden beim Senden!`); socket.emit('error', { message: 'Empfänger ist nicht mehr online' }); return; } try { receiver.socket.emit('message', messagePayload); // Bestätigung an Absender (nur wenn noch verbunden) if (socket.connected) { socket.emit('messageSent', { messageId, to: toUserName }); } } catch (error) { console.error(`[Bild] Fehler beim Senden der Nachricht:`, error); if (socket.connected) { socket.emit('error', { message: 'Fehler beim Senden der Nachricht' }); } } // Aktualisiere ungelesene Nachrichten für den Empfänger updateUnreadCount(receiver); } function handleRequestConversation(socket, client, data) { if (!client.userName) { socket.emit('error', { message: 'Nicht eingeloggt' }); return; } const { withUserName } = data; const convKey = getConversationKey(client.userName, withUserName); const conversation = conversations.get(convKey) || []; // Markiere alle Nachrichten von diesem Benutzer als gelesen for (const msg of conversation) { if (msg.to === client.userName && msg.from === withUserName) { msg.read = true; } } // Aktualisiere ungelesene Nachrichten nach dem Markieren als gelesen updateUnreadCount(client); socket.emit('conversation', { with: withUserName, messages: conversation.map(msg => ({ from: msg.from, message: msg.message, timestamp: msg.timestamp, isImage: msg.isImage || false, imageType: msg.imageType || null })) }); emitVideoConsentStateToUser(client.userName, withUserName); const session = findActiveVideoSessionBetween(client.userName, withUserName); if (session) { socket.emit('videoCall:update', buildVideoCallPayloadForUser(session, client.userName)); } } function handleVideoConsentSet(socket, client, data) { if (!client.userName) { socket.emit('error', { message: 'Nicht eingeloggt' }); return; } const withUserName = String(data?.withUserName || '').trim(); const allowed = !!data?.allowed; if (!withUserName || withUserName === client.userName) { sendVideoCallError(socket, 'VIDEO_INVALID_PARTNER', 'Ungültiger Gesprächspartner.'); return; } const state = getOrCreateVideoConversation(client.userName, withUserName); state.consents[client.userName] = allowed; if (!allowed) { endVideoSessionsForPair(client.userName, withUserName, 'consent_revoked', client.userName); } emitVideoConsentStateToParticipants(client.userName, withUserName); } function assertCanUseVideoWith(socket, client, targetUserName) { if (!client.userName) { sendVideoCallError(socket, 'VIDEO_NOT_LOGGED_IN', 'Nicht eingeloggt.'); return null; } const targetClient = getClientByUserName(targetUserName); if (!targetClient || !targetClient.userName) { sendVideoCallError(socket, 'VIDEO_PARTNER_NOT_FOUND', 'Gesprächspartner nicht gefunden.', { withUserName: targetUserName }); return null; } if (!isClientOnline(targetClient)) { sendVideoCallError(socket, 'VIDEO_PARTNER_OFFLINE', 'Gesprächspartner ist nicht online.', { withUserName: targetUserName }); return null; } if (targetClient.blockedUsers.has(client.userName)) { sendVideoCallError(socket, 'VIDEO_BLOCKED_BY_PARTNER', 'Du wurdest von diesem Benutzer blockiert.', { withUserName: targetUserName }); return null; } if (client.blockedUsers.has(targetUserName)) { sendVideoCallError(socket, 'VIDEO_PARTNER_BLOCKED', 'Du hast diesen Benutzer blockiert.', { withUserName: targetUserName }); return null; } const state = getOrCreateVideoConversation(client.userName, targetUserName); if (!state.consents[client.userName] || !state.consents[targetUserName]) { sendVideoCallError(socket, 'VIDEO_CONSENT_REQUIRED', 'Videochat ist erst nach beidseitiger Freigabe sichtbar.', { withUserName: targetUserName }); return null; } return { targetClient, state }; } function handleVideoCallInvite(socket, client, data) { const withUserName = String(data?.withUserName || '').trim(); if (!withUserName || withUserName === client.userName) { sendVideoCallError(socket, 'VIDEO_INVALID_PARTNER', 'Ungültiger Gesprächspartner.'); return; } if (!VIDEO_MEDIA_RELAY_CONFIGURED) { sendVideoCallError(socket, 'VIDEO_MEDIA_NOT_CONFIGURED', 'Video-Medienserver ist derzeit nicht konfiguriert.'); return; } const check = assertCanUseVideoWith(socket, client, withUserName); if (!check) return; if (!hasVideoCapacity(client.userName)) { emitVideoCapacityToUser(client.userName); sendVideoCallError(socket, 'VIDEO_CAPACITY_REACHED', 'Maximal drei Videoverbindungen gleichzeitig erlaubt.', { withUserName, activeConnections: getActiveVideoSessionsForUser(client.userName).length, maxConnections: MAX_ACTIVE_VIDEO_CONNECTIONS }); return; } if (!hasVideoCapacity(withUserName)) { emitVideoCapacityToUser(withUserName); sendVideoCallError(socket, 'VIDEO_PARTNER_CAPACITY_REACHED', 'Der Gesprächspartner hat bereits die maximale Anzahl an Videoverbindungen erreicht.', { withUserName }); return; } const existingSession = findActiveVideoSessionBetween(client.userName, withUserName); if (existingSession) { sendVideoCallError(socket, 'VIDEO_CALL_ALREADY_EXISTS', 'Für diesen Gesprächspartner existiert bereits ein laufender Videochat.', { withUserName, callId: existingSession.callId }); return; } const session = createVideoSession(client.userName, withUserName); registerVideoSession(session); emitVideoCallEventToUser(client.userName, 'videoCall:invite', session); emitVideoCallEventToUser(withUserName, 'videoCall:incoming', session); emitVideoCapacityToUser(client.userName); emitVideoCapacityToUser(withUserName); } function handleVideoCallAccept(socket, client, data) { const callId = String(data?.callId || '').trim(); const session = videoSessions.get(callId); if (!session || session.status !== 'ringing' || !session.participants.includes(client.userName)) { sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); return; } if (session.initiatedBy === client.userName) { sendVideoCallError(socket, 'VIDEO_CALL_INVALID_ACCEPT', 'Der Anrufer kann den eigenen Anruf nicht annehmen.', { callId }); return; } if (!VIDEO_MEDIA_RELAY_CONFIGURED) { sendVideoCallError(socket, 'VIDEO_MEDIA_NOT_CONFIGURED', 'Video-Medienserver ist derzeit nicht konfiguriert.', { callId }); return; } const otherUserName = getOtherParticipant(session, client.userName); const check = assertCanUseVideoWith(socket, client, otherUserName); if (!check) { touchVideoSession(session, 'failed', 'preconditions_failed'); emitVideoCallEventToUser(session.participants[0], 'videoCall:end', session); emitVideoCallEventToUser(session.participants[1], 'videoCall:end', session); finalizeVideoSession(session); emitVideoCapacityToUser(session.participants[0]); emitVideoCapacityToUser(session.participants[1]); return; } touchVideoSession(session, 'connecting'); emitVideoCallEventToUser(session.participants[0], 'videoCall:start', session); emitVideoCallEventToUser(session.participants[1], 'videoCall:start', session); emitVideoCapacityToUser(session.participants[0]); emitVideoCapacityToUser(session.participants[1]); } function handleVideoCallReject(socket, client, data) { const callId = String(data?.callId || '').trim(); const session = videoSessions.get(callId); if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) { sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); return; } if (session.status !== 'ringing' || session.initiatedBy === client.userName) { sendVideoCallError(socket, 'VIDEO_CALL_INVALID_REJECT', 'Nur der Angerufene kann einen klingelnden Anruf ablehnen.', { callId }); return; } touchVideoSession(session, 'rejected', 'rejected'); emitVideoCallEventToUser(session.participants[0], 'videoCall:reject', session); emitVideoCallEventToUser(session.participants[1], 'videoCall:reject', session); finalizeVideoSession(session); emitVideoCapacityToUser(session.participants[0]); emitVideoCapacityToUser(session.participants[1]); } function handleVideoCallCancel(socket, client, data) { const callId = String(data?.callId || '').trim(); const session = videoSessions.get(callId); if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) { sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); return; } if (session.status !== 'ringing') { sendVideoCallError(socket, 'VIDEO_CALL_INVALID_CANCEL', 'Nur klingelnde Anrufe können abgebrochen werden.', { callId }); return; } if (session.initiatedBy !== client.userName) { sendVideoCallError(socket, 'VIDEO_CALL_INVALID_CANCEL', 'Nur der Anrufer kann einen klingelnden Anruf abbrechen.', { callId }); return; } touchVideoSession(session, 'cancelled', 'cancelled'); emitVideoCallEventToUser(session.participants[0], 'videoCall:cancel', session); emitVideoCallEventToUser(session.participants[1], 'videoCall:cancel', session); finalizeVideoSession(session); emitVideoCapacityToUser(session.participants[0]); emitVideoCapacityToUser(session.participants[1]); } function handleVideoCallEnd(socket, client, data) { const callId = String(data?.callId || '').trim(); const session = videoSessions.get(callId); if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) { sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); return; } endVideoSession(session, 'ended', client.userName); } function handleVideoCallMuteState(socket, client, data) { const callId = String(data?.callId || '').trim(); const muted = !!data?.muted; const session = videoSessions.get(callId); if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) { sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); return; } session.muteStates = session.muteStates || {}; session.muteStates[client.userName] = muted; session.updatedAt = new Date().toISOString(); emitVideoCallEventToUser(session.participants[0], 'videoCall:muteState', session); emitVideoCallEventToUser(session.participants[1], 'videoCall:muteState', session); } function handleVideoCallSignal(socket, client, data) { const callId = String(data?.callId || '').trim(); const signalType = String(data?.signalType || '').trim(); const session = videoSessions.get(callId); if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) { sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); return; } const otherUserName = getOtherParticipant(session, client.userName); const targetClient = getClientByUserName(otherUserName); if (!isClientOnline(targetClient)) { endVideoSession(session, 'partner_disconnected', client.userName); return; } if (signalType === 'description') { const description = data?.description; const descriptionType = String(description?.type || '').trim(); const sdp = String(description?.sdp || '').trim(); if (!descriptionType || !sdp || !['offer', 'answer'].includes(descriptionType)) { sendVideoCallError(socket, 'VIDEO_SIGNAL_INVALID_DESCRIPTION', 'Ungültige Video-Signalisierung.', { callId }); return; } targetClient.socket.emit('videoCall:signal', buildVideoSignalPayload(callId, client.userName, signalType, { description: { type: descriptionType, sdp } })); return; } if (signalType === 'candidate') { const candidate = data?.candidate; if (!isRelayIceCandidate(candidate)) { sendVideoCallError(socket, 'VIDEO_SIGNAL_NON_RELAY_CANDIDATE', 'Nur Relay-Kandidaten sind für Videochat erlaubt.', { callId }); return; } targetClient.socket.emit('videoCall:signal', buildVideoSignalPayload(callId, client.userName, signalType, { candidate })); return; } sendVideoCallError(socket, 'VIDEO_SIGNAL_UNSUPPORTED', 'Nicht unterstütztes Video-Signalisierungsformat.', { callId }); } function handleVideoCallConnectionState(socket, client, data) { const callId = String(data?.callId || '').trim(); const nextState = String(data?.connectionState || '').trim().toLowerCase(); const session = videoSessions.get(callId); if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) { sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId }); return; } if (!VIDEO_CONNECTION_STATES.has(nextState)) { sendVideoCallError(socket, 'VIDEO_CONNECTION_STATE_INVALID', 'Ungültiger Video-Verbindungsstatus.', { callId }); return; } session.connectionStates = session.connectionStates || {}; session.connectionStates[client.userName] = nextState; session.updatedAt = new Date().toISOString(); if (nextState === 'failed' || nextState === 'closed') { endVideoSession(session, 'media_connection_failed', client.userName); return; } if ( session.status === 'connecting' && session.participants.every((participant) => session.connectionStates[participant] === 'connected') ) { touchVideoSession(session, 'active'); } emitVideoCallUpdateToParticipants(session); } function handleUserSearch(socket, client, data) { if (!client.userName) { socket.emit('error', { message: 'Nicht eingeloggt' }); return; } const { nameIncludes, minAge, maxAge, countries, genders } = data; const results = []; for (const [sid, c] of clients.entries()) { if (!c.userName || c.userName === client.userName) continue; // Name-Filter if (nameIncludes && !c.userName.toLowerCase().includes(nameIncludes.toLowerCase())) { continue; } // Alter-Filter if (minAge && c.age < minAge) continue; if (maxAge && c.age > maxAge) continue; // Länder-Filter if (countries && countries.length > 0 && !countries.includes(c.country)) { continue; } // Geschlecht-Filter if (genders && genders.length > 0 && !genders.includes(c.gender)) { continue; } results.push(c.toJSON()); } socket.emit('searchResults', { results }); } function handleRequestHistory(socket, client) { if (!client.userName) { socket.emit('error', { message: 'Nicht eingeloggt' }); return; } const history = []; for (const [convKey, messages] of conversations.entries()) { const [user1, user2] = convKey.split(':'); if (user1 === client.userName || user2 === client.userName) { const otherUser = user1 === client.userName ? user2 : user1; history.push({ userName: otherUser, lastMessage: messages.length > 0 ? messages[messages.length - 1] : null }); } } socket.emit('historyResults', { results: history }); } function handleRequestOpenConversations(socket, client) { if (!client.userName) { socket.emit('error', { message: 'Nicht eingeloggt' }); return; } const inbox = []; for (const [convKey, messages] of conversations.entries()) { const [user1, user2] = convKey.split(':'); if (user1 === client.userName || user2 === client.userName) { const otherUser = user1 === client.userName ? user2 : user1; const unreadCount = messages.filter(m => m.to === client.userName && !m.read ).length; if (unreadCount > 0) { inbox.push({ userName: otherUser, unreadCount }); } } } socket.emit('inboxResults', { results: inbox }); } function handleBlockUser(socket, client, data) { if (!client.userName) { socket.emit('error', { message: 'Nicht eingeloggt' }); return; } const { userName } = data; client.blockedUsers.add(userName); getOrCreateVideoConversation(client.userName, userName).consents[client.userName] = false; endVideoSessionsForPair(client.userName, userName, 'blocked', client.userName); emitVideoConsentStateToParticipants(client.userName, userName); socket.emit('userBlocked', { userName }); } function handleUnblockUser(socket, client, data) { if (!client.userName) { socket.emit('error', { message: 'Nicht eingeloggt' }); return; } const { userName } = data; client.blockedUsers.delete(userName); emitVideoConsentStateToParticipants(client.userName, userName); socket.emit('userUnblocked', { userName }); } function updateUnreadCount(client) { if (!client.userName || !client.socket || !client.socket.connected) { return; } // Zähle Personen mit ungelesenen Nachrichten für diesen Benutzer let unreadPersons = 0; for (const [convKey, messages] of conversations.entries()) { const [user1, user2] = convKey.split(':'); if (user1 === client.userName || user2 === client.userName) { const unreadCount = messages.filter(m => m.to === client.userName && !m.read ).length; if (unreadCount > 0) { unreadPersons++; } } } // Sende Update an Client client.socket.emit('unreadChats', { count: unreadPersons }); } function broadcastUserList() { // Filtere nur eingeloggte Benutzer mit aktiver Verbindung const userList = Array.from(clients.values()) .filter(c => { const hasUserName = !!c.userName; const hasSocket = !!c.socket; const isConnected = c.socket && c.socket.connected; if (hasUserName && (!hasSocket || !isConnected)) { console.log('broadcastUserList - Client ohne Socket oder nicht verbunden:', c.userName, 'socket:', hasSocket, 'connected:', isConnected); } return hasUserName && hasSocket && isConnected; }) .map(c => c.toJSON()); console.log('broadcastUserList - Sende Userliste mit', userList.length, 'Benutzern:', userList.map(u => u.userName)); // Sende an alle verbundenen Clients io.emit('userList', { users: userList }); } function broadcastToUser(userName, data) { // Finde den Client mit diesem Benutzernamen und sende die Nachricht for (const [sid, c] of clients.entries()) { if (c.userName === userName && c.socket) { c.socket.emit(data.type, data); return; } } } // Timeout-Check alle 60 Sekunden setInterval(() => { for (const [sid, client] of clients.entries()) { if (client.activitiesTimedOut()) { console.log(`Client ${client.userName} hat Timeout erreicht`); if (client.userName) { endAllVideoSessionsForUser(client.userName, 'timeout', client.userName); } if (client.socket?.id) { socketToSessionMap.delete(client.socket.id); } clients.delete(sid); broadcastUserList(); } } }, 60000); }