From 643369f086a3b1ea86a97244e2ef205589388139 Mon Sep 17 00:00:00 2001 From: Metacube Date: Mon, 3 Nov 2025 18:48:22 +0100 Subject: [PATCH] Sitzungen absichern und Nutzerliste straffen --- write.php | 726 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 605 insertions(+), 121 deletions(-) diff --git a/write.php b/write.php index cba271d..a034c8b 100644 --- a/write.php +++ b/write.php @@ -28,7 +28,8 @@ define('DB_FILE', __DIR__ . '/chat_secure.db'); define('MESSAGE_RETENTION_HOURS', 24); define('LOG_RETENTION_MONTHS', 6); define('ONLINE_TIMEOUT_SECONDS', 30); -define('SSE_RETRY_MS', 1000); +define('SSE_RETRY_MS', 500); +define('MAX_MESSAGES_PER_FETCH', 200); // Rate Limiting define('MAX_MESSAGES_PER_MINUTE', 10); @@ -112,7 +113,17 @@ function getDB() { FOREIGN KEY (user_id) REFERENCES users(id) ) '); - + + // User Sessions Table + $db->exec(' + CREATE TABLE IF NOT EXISTS user_sessions ( + user_id INTEGER PRIMARY KEY, + session_token TEXT NOT NULL, + last_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + '); + // Reports Table $db->exec(' CREATE TABLE IF NOT EXISTS reports ( @@ -194,7 +205,8 @@ function getDB() { $db->exec('CREATE INDEX IF NOT EXISTS idx_users_age_group ON users(age_group)'); $db->exec('CREATE INDEX IF NOT EXISTS idx_blocks ON blocks(blocker_id, blocked_id)'); $db->exec('CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status)'); - + $db->exec('CREATE INDEX IF NOT EXISTS idx_user_sessions_last_seen ON user_sessions(last_seen)'); + return $db; } @@ -382,6 +394,9 @@ function cleanupOldData() { // Delete old logs (keep 6 months) $months = LOG_RETENTION_MONTHS; $db->exec("DELETE FROM security_logs WHERE timestamp < datetime('now', '-{$months} months')"); + + // Remove stale session placeholders + $db->exec("DELETE FROM user_sessions WHERE last_seen < datetime('now', '-5 minutes')"); } // ═══════════════════════════════════════════════════════════ @@ -424,10 +439,117 @@ function updateOnlineStatus($userId) { '); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->execute(); - + $stmt = $db->prepare('UPDATE users SET last_seen = CURRENT_TIMESTAMP WHERE id = :user_id'); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->execute(); + + touchUserSession($userId); +} + +function generateSessionToken() { + return bin2hex(random_bytes(32)); +} + +function startUserSession($userId) { + if (!$userId) { + return ['allowed' => false, 'error' => 'Ungültige Benutzer-ID']; + } + + $db = getDB(); + $stmt = $db->prepare('SELECT session_token, last_seen FROM user_sessions WHERE user_id = :user_id'); + $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); + $result = $stmt->execute(); + $existing = $result->fetchArray(SQLITE3_ASSOC); + + if ($existing && !empty($existing['last_seen'])) { + $secondsSinceLastSeen = time() - strtotime($existing['last_seen']); + + if ($secondsSinceLastSeen < ONLINE_TIMEOUT_SECONDS) { + return [ + 'allowed' => false, + 'error' => 'Du bist bereits auf einem anderen Gerät eingeloggt. Bitte dort zuerst ausloggen oder kurz warten.' + ]; + } + } + + $token = generateSessionToken(); + + $stmt = $db->prepare(' + INSERT OR REPLACE INTO user_sessions (user_id, session_token, last_seen) + VALUES (:user_id, :token, CURRENT_TIMESTAMP) + '); + $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); + $stmt->bindValue(':token', $token, SQLITE3_TEXT); + $stmt->execute(); + + $_SESSION['session_token'] = $token; + + return ['allowed' => true, 'token' => $token]; +} + +function touchUserSession($userId) { + if (!$userId || empty($_SESSION['session_token'])) { + return; + } + + $db = getDB(); + $stmt = $db->prepare(' + UPDATE user_sessions + SET last_seen = CURRENT_TIMESTAMP + WHERE user_id = :user_id AND session_token = :token + '); + $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); + $stmt->bindValue(':token', $_SESSION['session_token'], SQLITE3_TEXT); + $stmt->execute(); +} + +function clearUserSession($userId) { + if (!$userId) { + return; + } + + $db = getDB(); + $stmt = $db->prepare('DELETE FROM user_sessions WHERE user_id = :user_id'); + $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); + $stmt->execute(); + + unset($_SESSION['session_token']); +} + +function validateActiveSession() { + if (!isLoggedIn()) { + return false; + } + + $token = $_SESSION['session_token'] ?? null; + if (!$token) { + return false; + } + + $db = getDB(); + $stmt = $db->prepare('SELECT session_token, last_seen FROM user_sessions WHERE user_id = :user_id'); + $stmt->bindValue(':user_id', getCurrentUserId(), SQLITE3_INTEGER); + $result = $stmt->execute(); + $session = $result->fetchArray(SQLITE3_ASSOC); + + if (!$session) { + return false; + } + + if (!hash_equals($session['session_token'], $token)) { + return false; + } + + if (!empty($session['last_seen'])) { + $secondsSinceLastSeen = time() - strtotime($session['last_seen']); + + if ($secondsSinceLastSeen > ONLINE_TIMEOUT_SECONDS * 3) { + return false; + } + } + + return true; } // ═══════════════════════════════════════════════════════════ @@ -512,9 +634,19 @@ if (isset($_POST['action']) || isset($_GET['action'])) { $stmt->bindValue(':birthdate', $birthdate, SQLITE3_TEXT); $stmt->bindValue(':age_group', $ageGroup, SQLITE3_TEXT); $stmt->execute(); - + $dbUserId = $db->lastInsertRowID(); - + + $sessionResult = startUserSession($dbUserId); + if (!$sessionResult['allowed']) { + logSecurityEvent($dbUserId, 'LOGIN_BLOCKED_DUPLICATE_SESSION', 'REGISTER'); + echo json_encode([ + 'success' => false, + 'error' => $sessionResult['error'] + ]); + exit; + } + $_SESSION['user_id'] = $dbUserId; $_SESSION['username'] = $username; $_SESSION['user_display_id'] = $userId; @@ -567,18 +699,32 @@ if (isset($_POST['action']) || isset($_GET['action'])) { // ─────────────────────────────────────────────────────── if ($action === 'logout') { if (isLoggedIn()) { - logSecurityEvent(getCurrentUserId(), 'LOGOUT', ''); + $currentUserId = getCurrentUserId(); + logSecurityEvent($currentUserId, 'LOGOUT', ''); + clearUserSession($currentUserId); } session_destroy(); echo json_encode(['success' => true]); exit; } - + // All other actions require login if (!isLoggedIn() && !isAdmin()) { echo json_encode(['success' => false, 'error' => 'Nicht eingeloggt']); exit; } + + if (isLoggedIn() && !isAdmin() && !validateActiveSession()) { + $userId = getCurrentUserId(); + clearUserSession($userId); + session_destroy(); + echo json_encode(['success' => false, 'error' => 'Deine Sitzung ist nicht mehr gültig. Bitte erneut einloggen.']); + exit; + } + + if (isLoggedIn() && !isAdmin()) { + touchUserSession(getCurrentUserId()); + } // ─────────────────────────────────────────────────────── // PING (UPDATE ONLINE STATUS) @@ -714,28 +860,33 @@ if (isset($_POST['action']) || isset($_GET['action'])) { } $query = ' - SELECT - m.id, - m.from_user_id, - m.to_user_id, - m.message, - m.timestamp, - m.is_read, - m.is_flagged, - u.username as from_username, - u.user_id as from_display_id - FROM messages m - JOIN users u ON m.from_user_id = u.id - WHERE - (m.from_user_id = :current_user_id AND m.to_user_id = :other_user_id) - OR - (m.from_user_id = :other_user_id AND m.to_user_id = :current_user_id) - ORDER BY m.timestamp ASC + SELECT * FROM ( + SELECT + m.id, + m.from_user_id, + m.to_user_id, + m.message, + m.timestamp, + m.is_read, + m.is_flagged, + u.username as from_username, + u.user_id as from_display_id + FROM messages m + JOIN users u ON m.from_user_id = u.id + WHERE + (m.from_user_id = :current_user_id AND m.to_user_id = :other_user_id) + OR + (m.from_user_id = :other_user_id AND m.to_user_id = :current_user_id) + ORDER BY m.id DESC + LIMIT :limit + ) + ORDER BY id ASC '; - + $stmt = $db->prepare($query); $stmt->bindValue(':current_user_id', $currentUserId, SQLITE3_INTEGER); $stmt->bindValue(':other_user_id', $otherUserId, SQLITE3_INTEGER); + $stmt->bindValue(':limit', MAX_MESSAGES_PER_FETCH, SQLITE3_INTEGER); $result = $stmt->execute(); $messages = []; @@ -1373,16 +1524,22 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') { if (!isLoggedIn()) { exit; } - + + if (!validateActiveSession()) { + exit; + } + header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('Connection: keep-alive'); header('X-Accel-Buffering: no'); - + $currentUserId = getCurrentUserId(); $currentAgeGroup = getCurrentAgeGroup(); $lastMessageId = intval($_GET['last_message_id'] ?? 0); - + + touchUserSession($currentUserId); + set_time_limit(0); ob_implicit_flush(true); while (ob_get_level() > 0) { @@ -1461,6 +1618,7 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') { + 💬 Secure Private Chat @@ -2430,6 +2632,7 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') { +
@@ -2798,72 +3001,302 @@ const state = { users: [], messages: [], lastMessageId: 0, - eventSource: null + eventSource: null, + isLoadingUsers: false, + isLoadingMessages: false, + connectionErrorShown: false }; -async function loadUsers() { - const response = await fetch('?action=get_users'); - const result = await response.json(); +const userListEl = document.getElementById('userList'); +const userSearchInput = document.getElementById('userSearch'); +const chatWelcomeEl = document.getElementById('chatWelcome'); +const chatMessagesContainerEl = document.getElementById('chatMessagesContainer'); +const chatMessagesEl = document.getElementById('chatMessages'); +const chatStateMessageEl = document.getElementById('chatStateMessage'); +const chatMessagesHeaderEl = document.getElementById('chatMessagesHeader'); +const chatInputEl = document.getElementById('chatInput'); +const sendButtonEl = document.getElementById('sendButton'); +let messageAbortController = null; - if (result.success) { - state.users = result.users; +async function loadUsers() { + if (!userListEl) { + return; + } + + state.isLoadingUsers = true; + renderUserList(); + + try { + const response = await fetch('?action=get_users'); + if (!response.ok) { + throw new Error('NETZWERK_FEHLER'); + } + + const result = await response.json(); + + if (result.success) { + state.users = Array.isArray(result.users) ? result.users : []; + } else { + throw new Error(result.error || 'Nutzerliste konnte nicht geladen werden.'); + } + + state.isLoadingUsers = false; renderUserList(); + } catch (error) { + console.error('Nutzerliste konnte nicht geladen werden:', error); + state.isLoadingUsers = false; + if (userListEl) { + userListEl.innerHTML = '
Nutzerliste konnte nicht geladen werden.
'; + } } } function renderUserList() { - const userList = document.getElementById('userList'); - const searchTerm = document.getElementById('userSearch').value.toLowerCase(); + if (!userListEl) { + return; + } - const filtered = state.users.filter(u => u.display_name.toLowerCase().includes(searchTerm)); + const searchTerm = (userSearchInput?.value || '').toLowerCase(); + const users = Array.isArray(state.users) ? state.users : []; - userList.innerHTML = filtered.map(user => ` -
-
- ${user.username.charAt(0).toUpperCase()} -
-
- - ${user.unread_count > 0 ? `
${user.unread_count}
` : ''} -
- `).join(''); + if (state.isLoadingUsers && users.length === 0) { + userListEl.innerHTML = '
Nutzer werden geladen…
'; + return; + } + + if (users.length === 0) { + userListEl.innerHTML = '
Noch keine passenden Kontakte verfügbar.
'; + return; + } + + const filtered = users.filter(user => user.display_name.toLowerCase().includes(searchTerm)); + + if (filtered.length === 0) { + userListEl.innerHTML = '
Keine Treffer für deine Suche.
'; + return; + } + + const offlineLimit = 5; + const onlineUsers = []; + const offlineUsers = []; + + filtered.forEach(user => { + if (user.is_online) { + onlineUsers.push(user); + } else { + offlineUsers.push(user); + } + }); + + const limitedUsers = onlineUsers.concat(offlineUsers.slice(0, offlineLimit)); + + const fragment = document.createDocumentFragment(); + + limitedUsers.forEach(user => { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'user-item' + (Number(user.id) === Number(state.selectedUserId) ? ' active' : ''); + item.dataset.userId = String(user.id); + item.dataset.displayName = user.display_name; + + const avatar = document.createElement('div'); + avatar.className = 'user-avatar'; + avatar.textContent = (user.username || '?').charAt(0).toUpperCase(); + + const indicator = document.createElement('div'); + indicator.className = 'online-indicator' + (user.is_online ? '' : ' offline-indicator'); + avatar.appendChild(indicator); + + const infoWrapper = document.createElement('div'); + infoWrapper.className = 'user-info-text'; + + const name = document.createElement('div'); + name.className = 'user-name'; + name.textContent = user.display_name; + + const status = document.createElement('div'); + status.className = 'user-status'; + status.textContent = user.is_online ? 'Online' : 'Offline'; + + infoWrapper.appendChild(name); + infoWrapper.appendChild(status); + + item.appendChild(avatar); + item.appendChild(infoWrapper); + + if (Number(user.unread_count) > 0) { + const unread = document.createElement('div'); + unread.className = 'unread-badge'; + unread.textContent = String(user.unread_count); + item.appendChild(unread); + } + + item.addEventListener('click', () => { + selectUser(Number(user.id), user.display_name); + }); + + fragment.appendChild(item); + }); + + userListEl.innerHTML = ''; + userListEl.appendChild(fragment); + + if (offlineUsers.length > offlineLimit) { + const hint = document.createElement('div'); + hint.className = 'user-status'; + hint.style.textAlign = 'center'; + hint.style.marginTop = '12px'; + hint.textContent = 'Weitere Offline-Nutzer werden ausgeblendet.'; + userListEl.appendChild(hint); + } +} + +function renderChatHeader(displayName) { + if (!chatMessagesHeaderEl) { + return; + } + + chatMessagesHeaderEl.innerHTML = ''; + + const avatar = document.createElement('div'); + avatar.className = 'user-avatar'; + const initial = (displayName?.trim() || '?').charAt(0).toUpperCase(); + avatar.textContent = initial || '?'; + + const info = document.createElement('div'); + const name = document.createElement('div'); + name.className = 'user-name'; + name.textContent = displayName; + info.appendChild(name); + + chatMessagesHeaderEl.appendChild(avatar); + chatMessagesHeaderEl.appendChild(info); +} + +function updateChatState(type, message = '') { + if (!chatStateMessageEl || !chatMessagesEl) { + return; + } + + chatStateMessageEl.className = 'chat-state-message'; + + if (!type) { + chatStateMessageEl.textContent = ''; + chatStateMessageEl.classList.add('hidden'); + chatMessagesEl.classList.remove('hidden'); + return; + } + + chatStateMessageEl.textContent = message; + chatStateMessageEl.classList.remove('hidden'); + + if (type === 'loading') { + chatStateMessageEl.classList.add('loading-state'); + } else if (type === 'error') { + chatStateMessageEl.classList.add('error-state'); + } else if (type === 'empty') { + chatStateMessageEl.classList.add('empty-messages'); + } + + const hideMessages = type === 'loading' || type === 'error' || type === 'empty'; + chatMessagesEl.classList.toggle('hidden', hideMessages); } function selectUser(userId, displayName) { state.selectedUserId = userId; + state.messages = []; - document.getElementById('chatWelcome').style.display = 'none'; - document.getElementById('chatMessagesContainer').style.display = 'flex'; + if (chatWelcomeEl) { + chatWelcomeEl.style.display = 'none'; + } - document.getElementById('chatMessagesHeader').innerHTML = ` -
${displayName.charAt(0).toUpperCase()}
-
${displayName}
- `; + if (chatMessagesContainerEl) { + chatMessagesContainerEl.style.display = 'flex'; + } - loadMessages(userId); + if (chatMessagesEl) { + chatMessagesEl.innerHTML = ''; + chatMessagesEl.classList.add('hidden'); + } + + renderChatHeader(displayName); + updateChatState('loading', 'Nachrichten werden geladen…'); renderUserList(); + loadMessages(userId); } async function loadMessages(userId) { - const response = await fetch(`?action=get_messages&user_id=${userId}`); - const result = await response.json(); + if (!userId) { + return; + } - if (result.success) { - state.messages = result.messages; - renderMessages(); - markAsRead(userId); + if (messageAbortController) { + messageAbortController.abort(); + } - if (result.messages.length > 0) { - state.lastMessageId = Math.max(...result.messages.map(m => m.id)); + const currentController = new AbortController(); + messageAbortController = currentController; + + state.isLoadingMessages = true; + updateChatState('loading', 'Nachrichten werden geladen…'); + + try { + const response = await fetch(`?action=get_messages&user_id=${userId}`, { signal: currentController.signal }); + if (!response.ok) { + throw new Error('NETZWERK_FEHLER'); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.error || 'Nachrichten konnten nicht geladen werden.'); + } + + state.messages = Array.isArray(result.messages) ? result.messages : []; + + if (state.messages.length > 0) { + renderMessages(); + markAsRead(userId); + const newLastMessageId = Math.max(...state.messages.map(m => Number(m.id))); + state.lastMessageId = Math.max(state.lastMessageId, newLastMessageId); + } else { + if (chatMessagesEl) { + chatMessagesEl.innerHTML = ''; + } + updateChatState('empty', 'Noch keine Nachrichten. Starte das Gespräch!'); + } + } catch (error) { + if (error && error.name === 'AbortError') { + return; + } + console.error('Nachrichten konnten nicht geladen werden:', error); + state.messages = []; + if (chatMessagesEl) { + chatMessagesEl.innerHTML = ''; + } + const errorMessage = (error && error.message && error.message !== 'NETZWERK_FEHLER') + ? error.message + : 'Nachrichten konnten nicht geladen werden. Bitte versuche es erneut.'; + updateChatState('error', errorMessage); + } finally { + if (messageAbortController === currentController) { + messageAbortController = null; + state.isLoadingMessages = false; } } } function renderMessages() { - const container = document.getElementById('chatMessages'); + const container = chatMessagesEl; + + if (!container) { + return; + } + + if (!Array.isArray(state.messages) || state.messages.length === 0) { + container.innerHTML = ''; + return; + } container.innerHTML = state.messages.map(msg => { const isSent = msg.from_user_id === state.currentUserId; @@ -2877,12 +3310,17 @@ function renderMessages() { `; }).join(''); + container.classList.remove('hidden'); container.scrollTop = container.scrollHeight; + updateChatState(null); } async function sendMessage() { - const input = document.getElementById('chatInput'); - const message = input.value.trim(); + if (!chatInputEl) { + return; + } + + const message = chatInputEl.value.trim(); if (!message || !state.selectedUserId) return; @@ -2895,7 +3333,8 @@ async function sendMessage() { const result = await response.json(); if (result.success) { - input.value = ''; + chatInputEl.value = ''; + chatInputEl.dispatchEvent(new Event('input')); } else { alert(result.error); } @@ -2911,21 +3350,47 @@ async function markAsRead(userId) { } function startSSE() { - state.eventSource = new EventSource(`?stream=events&last_message_id=${state.lastMessageId}`); + if (state.eventSource) { + state.eventSource.close(); + } + + const url = `?stream=events&last_message_id=${state.lastMessageId}&t=${Date.now()}`; + state.eventSource = new EventSource(url); + + state.eventSource.onopen = () => { + state.connectionErrorShown = false; + + if (!state.selectedUserId) { + return; + } + + if (state.isLoadingMessages) { + return; + } + + if (state.messages.length === 0) { + updateChatState('empty', 'Noch keine Nachrichten. Starte das Gespräch!'); + } else { + updateChatState(null); + } + }; state.eventSource.onmessage = (event) => { + state.connectionErrorShown = false; const data = JSON.parse(event.data); if (data.type === 'messages' && data.messages) { data.messages.forEach(msg => { - if (msg.id > state.lastMessageId) { - state.lastMessageId = msg.id; + const messageId = Number(msg.id); + + if (messageId > state.lastMessageId) { + state.lastMessageId = messageId; if (state.selectedUserId && ((msg.from_user_id === state.selectedUserId && msg.to_user_id === state.currentUserId) || (msg.from_user_id === state.currentUserId && msg.to_user_id === state.selectedUserId))) { - if (!state.messages.find(m => m.id === msg.id)) { + if (!state.messages.find(m => Number(m.id) === messageId)) { state.messages.push(msg); renderMessages(); @@ -2940,6 +3405,22 @@ function startSSE() { loadUsers(); } }; + + state.eventSource.onerror = () => { + if (!state.connectionErrorShown) { + state.connectionErrorShown = true; + console.warn('SSE-Verbindung unterbrochen, versuche Neuverbindung.'); + if (state.selectedUserId && !state.isLoadingMessages) { + updateChatState('error', 'Live-Verbindung unterbrochen. Erneuter Verbindungsversuch…'); + } + } + + if (state.eventSource) { + state.eventSource.close(); + } + + setTimeout(startSSE, 500); + }; } function escapeHtml(text) { @@ -2948,22 +3429,25 @@ function escapeHtml(text) { return div.innerHTML; } -document.getElementById('sendButton').addEventListener('click', sendMessage); -document.getElementById('chatInput').addEventListener('keypress', (e) => { +sendButtonEl?.addEventListener('click', sendMessage); + +chatInputEl?.addEventListener('keypress', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); -document.getElementById('userSearch').addEventListener('input', renderUserList); -document.getElementById('logoutBtn').addEventListener('click', async () => { + +userSearchInput?.addEventListener('input', () => renderUserList()); + +document.getElementById('logoutBtn')?.addEventListener('click', async () => { const formData = new FormData(); formData.append('action', 'logout'); await fetch('', { method: 'POST', body: formData }); window.location.reload(); }); -document.getElementById('chatInput').addEventListener('input', function() { +chatInputEl?.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 100) + 'px'; });