From 71d4d56db66453a5e290f7fb0350f2a5433a70ae Mon Sep 17 00:00:00 2001 From: Metacube Date: Mon, 3 Nov 2025 19:20:48 +0100 Subject: [PATCH 01/10] Fix chat join flow and add JPG attachments --- write.php | 1511 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 1179 insertions(+), 332 deletions(-) diff --git a/write.php b/write.php index cba271d..a42b025 100644 --- a/write.php +++ b/write.php @@ -28,7 +28,10 @@ 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); +define('MAX_ATTACHMENT_SIZE', 200 * 1024); // 200 KB +define('UPLOAD_DIR', __DIR__ . '/uploads'); // Rate Limiting define('MAX_MESSAGES_PER_MINUTE', 10); @@ -70,131 +73,174 @@ $PROFANITY_FILTER = [ // ═══════════════════════════════════════════════════════════ function getDB() { - $db = new SQLite3(DB_FILE); - $db->busyTimeout(5000); - - // Users Table (mit Geburtsdatum und User-ID) - $db->exec(' - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL, - user_id TEXT UNIQUE NOT NULL, - birthdate DATE NOT NULL, - age_group TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_seen DATETIME DEFAULT CURRENT_TIMESTAMP, - is_banned INTEGER DEFAULT 0, - ban_reason TEXT - ) - '); - - // Messages Table - $db->exec(' - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - from_user_id INTEGER NOT NULL, - to_user_id INTEGER NOT NULL, - message TEXT NOT NULL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - is_read INTEGER DEFAULT 0, - is_flagged INTEGER DEFAULT 0, - flag_reason TEXT, - FOREIGN KEY (from_user_id) REFERENCES users(id), - FOREIGN KEY (to_user_id) REFERENCES users(id) - ) - '); - - // Online Status Table - $db->exec(' - CREATE TABLE IF NOT EXISTS online_status ( - user_id INTEGER PRIMARY KEY, - last_ping DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) - ) - '); - - // Reports Table - $db->exec(' - CREATE TABLE IF NOT EXISTS reports ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - reporter_id INTEGER NOT NULL, - reported_user_id INTEGER NOT NULL, - reason TEXT NOT NULL, - message_id INTEGER, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - status TEXT DEFAULT "pending", - FOREIGN KEY (reporter_id) REFERENCES users(id), - FOREIGN KEY (reported_user_id) REFERENCES users(id), - FOREIGN KEY (message_id) REFERENCES messages(id) - ) - '); - - // Blocks Table - $db->exec(' - CREATE TABLE IF NOT EXISTS blocks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - blocker_id INTEGER NOT NULL, - blocked_id INTEGER NOT NULL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (blocker_id) REFERENCES users(id), - FOREIGN KEY (blocked_id) REFERENCES users(id), - UNIQUE(blocker_id, blocked_id) - ) - '); - - // Rate Limiting Table - $db->exec(' - CREATE TABLE IF NOT EXISTS rate_limits ( - user_id INTEGER NOT NULL, - action_type TEXT NOT NULL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) - ) - '); - - // Logs Table (für Behörden) - $db->exec(' - CREATE TABLE IF NOT EXISTS security_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, - action TEXT NOT NULL, - details TEXT, - ip_address TEXT, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) - ) - '); - - // Admin Table - $db->exec(' - CREATE TABLE IF NOT EXISTS admins ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - '); - - // Create default admin if not exists - $stmt = $db->prepare('SELECT COUNT(*) as count FROM admins WHERE username = :username'); - $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); - $result = $stmt->execute(); - $row = $result->fetchArray(SQLITE3_ASSOC); - - if ($row['count'] == 0) { - $stmt = $db->prepare('INSERT INTO admins (username, password_hash) VALUES (:username, :password)'); - $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); - $stmt->bindValue(':password', ADMIN_PASSWORD, SQLITE3_TEXT); - $stmt->execute(); + static $db = null; + static $initialized = false; + + if ($db === null) { + if (!is_dir(UPLOAD_DIR)) { + @mkdir(UPLOAD_DIR, 0755, true); + } + + $db = new SQLite3(DB_FILE); + $db->busyTimeout(5000); } - - // Create indexes - $db->exec('CREATE INDEX IF NOT EXISTS idx_messages_users ON messages(from_user_id, to_user_id)'); - $db->exec('CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)'); - $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)'); - + + if (!$initialized) { + // Users Table (mit Geburtsdatum und User-ID) + $db->exec(' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + user_id TEXT UNIQUE NOT NULL, + birthdate DATE NOT NULL, + age_group TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + is_banned INTEGER DEFAULT 0, + ban_reason TEXT + ) + '); + + // Messages Table + $db->exec(' + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_user_id INTEGER NOT NULL, + to_user_id INTEGER NOT NULL, + message TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + is_read INTEGER DEFAULT 0, + is_flagged INTEGER DEFAULT 0, + flag_reason TEXT, + attachment_path TEXT, + attachment_type TEXT, + attachment_size INTEGER, + FOREIGN KEY (from_user_id) REFERENCES users(id), + FOREIGN KEY (to_user_id) REFERENCES users(id) + ) + '); + + // Ensure attachment columns exist for older installations + $messagesInfo = $db->query('PRAGMA table_info(messages)'); + $messageColumns = []; + while ($column = $messagesInfo->fetchArray(SQLITE3_ASSOC)) { + $messageColumns[$column['name']] = true; + } + if (!isset($messageColumns['attachment_path'])) { + $db->exec('ALTER TABLE messages ADD COLUMN attachment_path TEXT'); + } + if (!isset($messageColumns['attachment_type'])) { + $db->exec('ALTER TABLE messages ADD COLUMN attachment_type TEXT'); + } + if (!isset($messageColumns['attachment_size'])) { + $db->exec('ALTER TABLE messages ADD COLUMN attachment_size INTEGER'); + } + + // Online Status Table + $db->exec(' + CREATE TABLE IF NOT EXISTS online_status ( + user_id INTEGER PRIMARY KEY, + last_ping DATETIME DEFAULT CURRENT_TIMESTAMP, + 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 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reporter_id INTEGER NOT NULL, + reported_user_id INTEGER NOT NULL, + reason TEXT NOT NULL, + message_id INTEGER, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + status TEXT DEFAULT "pending", + FOREIGN KEY (reporter_id) REFERENCES users(id), + FOREIGN KEY (reported_user_id) REFERENCES users(id), + FOREIGN KEY (message_id) REFERENCES messages(id) + ) + '); + + // Blocks Table + $db->exec(' + CREATE TABLE IF NOT EXISTS blocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + blocker_id INTEGER NOT NULL, + blocked_id INTEGER NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (blocker_id) REFERENCES users(id), + FOREIGN KEY (blocked_id) REFERENCES users(id), + UNIQUE(blocker_id, blocked_id) + ) + '); + + // Rate Limiting Table + $db->exec(' + CREATE TABLE IF NOT EXISTS rate_limits ( + user_id INTEGER NOT NULL, + action_type TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + '); + + // Logs Table (für Behörden) + $db->exec(' + CREATE TABLE IF NOT EXISTS security_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + action TEXT NOT NULL, + details TEXT, + ip_address TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + '); + + // Admin Table + $db->exec(' + CREATE TABLE IF NOT EXISTS admins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + '); + + // Create default admin if not exists + $stmt = $db->prepare('SELECT COUNT(*) as count FROM admins WHERE username = :username'); + $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); + $result = $stmt->execute(); + $row = $result->fetchArray(SQLITE3_ASSOC); + + if ($row['count'] == 0) { + $stmt = $db->prepare('INSERT INTO admins (username, password_hash) VALUES (:username, :password)'); + $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); + $stmt->bindValue(':password', ADMIN_PASSWORD, SQLITE3_TEXT); + $stmt->execute(); + } + + // Create indexes + $db->exec('CREATE INDEX IF NOT EXISTS idx_messages_users ON messages(from_user_id, to_user_id)'); + $db->exec('CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)'); + $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)'); + + $initialized = true; + } + return $db; } @@ -371,9 +417,27 @@ function isBlocked($userId, $otherUserId) { function cleanupOldData() { $db = getDB(); - + // Delete old messages $hours = MESSAGE_RETENTION_HOURS; + $attachmentResult = $db->query("SELECT attachment_path FROM messages WHERE attachment_path IS NOT NULL AND timestamp < datetime('now', '-{$hours} hours')"); + while ($attachmentRow = $attachmentResult->fetchArray(SQLITE3_ASSOC)) { + $relativePath = $attachmentRow['attachment_path'] ?? ''; + if (!$relativePath) { + continue; + } + + $normalizedPath = str_replace('\\', '/', $relativePath); + if (strpos($normalizedPath, 'uploads/') !== 0) { + continue; + } + + $fullPath = __DIR__ . '/' . $normalizedPath; + if (is_file($fullPath)) { + @unlink($fullPath); + } + } + $db->exec("DELETE FROM messages WHERE timestamp < datetime('now', '-{$hours} hours')"); // Delete old rate limits @@ -382,6 +446,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 +491,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 +686,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 +751,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 +912,36 @@ 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, + m.attachment_path, + m.attachment_type, + m.attachment_size, + 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 = []; @@ -748,6 +954,9 @@ if (isset($_POST['action']) || isset($_GET['action'])) { 'timestamp' => $row['timestamp'], 'is_read' => $row['is_read'], 'is_flagged' => $row['is_flagged'], + 'attachment_url' => $row['attachment_path'] ?: null, + 'attachment_type' => $row['attachment_type'] ?: null, + 'attachment_size' => $row['attachment_size'] !== null ? (int)$row['attachment_size'] : null, 'from_username' => $row['from_username'], 'from_display_name' => $row['from_username'] . '#' . $row['from_display_id'] ]; @@ -763,28 +972,81 @@ if (isset($_POST['action']) || isset($_GET['action'])) { if ($action === 'send_message') { $toUserId = intval($_POST['to_user_id'] ?? 0); $message = trim($_POST['message'] ?? ''); - + $hasAttachment = isset($_FILES['attachment']) && ($_FILES['attachment']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE; + if ($toUserId <= 0) { echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); exit; } - - if (empty($message)) { - echo json_encode(['success' => false, 'error' => 'Nachricht darf nicht leer sein']); + + if (!$hasAttachment && $message === '') { + echo json_encode(['success' => false, 'error' => 'Nachricht oder Bild erforderlich']); exit; } - + if (strlen($message) > 1000) { echo json_encode(['success' => false, 'error' => 'Nachricht zu lang (max 1000 Zeichen)']); exit; } - + + $attachmentFile = $hasAttachment ? $_FILES['attachment'] : null; + $attachmentMime = null; + $attachmentSize = null; + + if ($hasAttachment && $attachmentFile) { + if (!is_uploaded_file($attachmentFile['tmp_name'])) { + echo json_encode(['success' => false, 'error' => 'Ungültiger Datei-Upload']); + exit; + } + + if ($attachmentFile['error'] !== UPLOAD_ERR_OK) { + echo json_encode(['success' => false, 'error' => 'Bild konnte nicht hochgeladen werden']); + exit; + } + + if ($attachmentFile['size'] > MAX_ATTACHMENT_SIZE) { + echo json_encode(['success' => false, 'error' => 'Bild ist zu groß (max. 200 KB)']); + exit; + } + + $attachmentSize = (int)$attachmentFile['size']; + + $mime = null; + if (function_exists('finfo_open')) { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + if ($finfo) { + $mime = finfo_file($finfo, $attachmentFile['tmp_name']); + finfo_close($finfo); + } + } + if (!$mime && function_exists('mime_content_type')) { + $mime = mime_content_type($attachmentFile['tmp_name']); + } + if (!$mime && isset($attachmentFile['type'])) { + $mime = $attachmentFile['type']; + } + + $mime = strtolower((string)$mime); + if (!in_array($mime, ['image/jpeg', 'image/pjpeg', 'image/jpg'], true)) { + echo json_encode(['success' => false, 'error' => 'Nur JPG-Bilder sind erlaubt']); + exit; + } + + $imageInfo = @getimagesize($attachmentFile['tmp_name']); + if ($imageInfo === false || !in_array($imageInfo[2], [IMAGETYPE_JPEG], true)) { + echo json_encode(['success' => false, 'error' => 'Bilddatei konnte nicht verifiziert werden']); + exit; + } + + $attachmentMime = 'image/jpeg'; + } + // Check if blocked if (isBlocked(getCurrentUserId(), $toUserId)) { echo json_encode(['success' => false, 'error' => 'Nachricht kann nicht gesendet werden']); exit; } - + $db = getDB(); $currentUserId = getCurrentUserId(); $currentAgeGroup = getCurrentAgeGroup(); @@ -812,91 +1074,119 @@ if (isset($_POST['action']) || isset($_GET['action'])) { echo json_encode(['success' => false, 'error' => $rateLimitCheck['reason']]); exit; } - - // Keyword Blacklist - $keywordCheck = checkKeywordBlacklist($message); - if ($keywordCheck['blocked']) { - logSecurityEvent($currentUserId, 'KEYWORD_BLOCKED', "Keyword: {$keywordCheck['keyword']}"); - echo json_encode([ - 'success' => false, - 'error' => 'Deine Nachricht enthält nicht erlaubte Inhalte', - 'details' => 'Verbotenes Wort erkannt: ' . $keywordCheck['keyword'] - ]); - exit; + + if ($message !== '') { + // Keyword Blacklist + $keywordCheck = checkKeywordBlacklist($message); + if ($keywordCheck['blocked']) { + logSecurityEvent($currentUserId, 'KEYWORD_BLOCKED', "Keyword: {$keywordCheck['keyword']}"); + echo json_encode([ + 'success' => false, + 'error' => 'Deine Nachricht enthält nicht erlaubte Inhalte', + 'details' => 'Verbotenes Wort erkannt: ' . $keywordCheck['keyword'] + ]); + exit; + } + + // Profanity Filter + $profanityCheck = checkProfanityFilter($message); + if ($profanityCheck['blocked']) { + logSecurityEvent($currentUserId, 'PROFANITY_BLOCKED', "Word: {$profanityCheck['word']}"); + echo json_encode([ + 'success' => false, + 'error' => 'Deine Nachricht enthält Schimpfwörter', + 'details' => 'Bitte verwende eine angemessene Sprache' + ]); + exit; + } + + // Link Filter + $linkCheck = checkLinkFilter($message); + if ($linkCheck['blocked']) { + logSecurityEvent($currentUserId, 'LINK_BLOCKED', "Message: $message"); + echo json_encode([ + 'success' => false, + 'error' => 'Links sind nicht erlaubt', + 'details' => 'Aus Sicherheitsgründen können keine URLs gesendet werden' + ]); + exit; + } } - - // Profanity Filter - $profanityCheck = checkProfanityFilter($message); - if ($profanityCheck['blocked']) { - logSecurityEvent($currentUserId, 'PROFANITY_BLOCKED', "Word: {$profanityCheck['word']}"); - echo json_encode([ - 'success' => false, - 'error' => 'Deine Nachricht enthält Schimpfwörter', - 'details' => 'Bitte verwende eine angemessene Sprache' - ]); - exit; - } - - // Link Filter - $linkCheck = checkLinkFilter($message); - if ($linkCheck['blocked']) { - logSecurityEvent($currentUserId, 'LINK_BLOCKED', "Message: $message"); - echo json_encode([ - 'success' => false, - 'error' => 'Links sind nicht erlaubt', - 'details' => 'Aus Sicherheitsgründen können keine URLs gesendet werden' - ]); - exit; - } - + // Auto-Flagging (verdächtige Muster) $isFlagged = 0; $flagReason = ''; - - // Check for repeated characters (AAAAAAA) - if (preg_match('/(.)\1{5,}/', $message)) { - $isFlagged = 1; - $flagReason = 'Repeated characters'; + + if ($message !== '') { + if (preg_match('/(.)\1{5,}/', $message)) { + $isFlagged = 1; + $flagReason = 'Repeated characters'; + } + + if (strlen($message) > 20 && $message === strtoupper($message)) { + $isFlagged = 1; + $flagReason = 'All caps'; + } + + $emojiCount = preg_match_all('/[\x{1F600}-\x{1F64F}]/u', $message); + if ($emojiCount > 10) { + $isFlagged = 1; + $flagReason = 'Excessive emojis'; + } } - - // Check for all caps (min 20 chars) - if (strlen($message) > 20 && $message === strtoupper($message)) { - $isFlagged = 1; - $flagReason = 'All caps'; + + $attachmentPath = null; + if ($hasAttachment && $attachmentFile) { + $randomName = bin2hex(random_bytes(16)) . '.jpg'; + $destination = rtrim(UPLOAD_DIR, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $randomName; + + if (!move_uploaded_file($attachmentFile['tmp_name'], $destination)) { + echo json_encode(['success' => false, 'error' => 'Bild konnte nicht gespeichert werden']); + exit; + } + + $attachmentPath = 'uploads/' . $randomName; } - - // Check for excessive emojis - $emojiCount = preg_match_all('/[\x{1F600}-\x{1F64F}]/u', $message); - if ($emojiCount > 10) { - $isFlagged = 1; - $flagReason = 'Excessive emojis'; - } - + // Insert message $stmt = $db->prepare(' - INSERT INTO messages (from_user_id, to_user_id, message, is_flagged, flag_reason) - VALUES (:from_user_id, :to_user_id, :message, :is_flagged, :flag_reason) + INSERT INTO messages (from_user_id, to_user_id, message, is_flagged, flag_reason, attachment_path, attachment_type, attachment_size) + VALUES (:from_user_id, :to_user_id, :message, :is_flagged, :flag_reason, :attachment_path, :attachment_type, :attachment_size) '); $stmt->bindValue(':from_user_id', $currentUserId, SQLITE3_INTEGER); $stmt->bindValue(':to_user_id', $toUserId, SQLITE3_INTEGER); $stmt->bindValue(':message', $message, SQLITE3_TEXT); $stmt->bindValue(':is_flagged', $isFlagged, SQLITE3_INTEGER); $stmt->bindValue(':flag_reason', $flagReason, SQLITE3_TEXT); + if ($attachmentPath) { + $stmt->bindValue(':attachment_path', $attachmentPath, SQLITE3_TEXT); + $stmt->bindValue(':attachment_type', $attachmentMime, SQLITE3_TEXT); + $stmt->bindValue(':attachment_size', $attachmentSize, SQLITE3_INTEGER); + } else { + $stmt->bindValue(':attachment_path', null, SQLITE3_NULL); + $stmt->bindValue(':attachment_type', null, SQLITE3_NULL); + $stmt->bindValue(':attachment_size', null, SQLITE3_NULL); + } $stmt->execute(); - + $messageId = $db->lastInsertRowID(); - + // Log rate limit logRateLimit($currentUserId); - + if ($isFlagged) { logSecurityEvent($currentUserId, 'MESSAGE_FLAGGED', "Reason: $flagReason, Message ID: $messageId"); } - + + if ($attachmentPath) { + logSecurityEvent($currentUserId, 'ATTACHMENT_UPLOADED', "Message ID: $messageId, Size: $attachmentSize"); + } + echo json_encode([ 'success' => true, 'message_id' => $messageId, - 'timestamp' => date('Y-m-d H:i:s') + 'timestamp' => date('Y-m-d H:i:s'), + 'attachment_url' => $attachmentPath ]); exit; } @@ -1373,16 +1663,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) { @@ -1395,17 +1691,20 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') { $db = getDB(); - $stmt = $db->prepare(' - SELECT - m.id, - m.from_user_id, - m.to_user_id, - m.message, - m.timestamp, - uf.username as from_username, - uf.user_id as from_display_id, - uf.age_group as from_age_group, - ut.age_group as to_age_group + $stmt = $db->prepare(' + SELECT + m.id, + m.from_user_id, + m.to_user_id, + m.message, + m.timestamp, + m.attachment_path, + m.attachment_type, + m.attachment_size, + uf.username as from_username, + uf.user_id as from_display_id, + uf.age_group as from_age_group, + ut.age_group as to_age_group FROM messages m JOIN users uf ON m.from_user_id = uf.id JOIN users ut ON m.to_user_id = ut.id @@ -1436,6 +1735,9 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') { 'to_user_id' => $row['to_user_id'], 'message' => $row['message'], 'timestamp' => $row['timestamp'], + 'attachment_url' => $row['attachment_path'] ?: null, + 'attachment_type' => $row['attachment_type'] ?: null, + 'attachment_size' => $row['attachment_size'] !== null ? (int)$row['attachment_size'] : null, 'from_username' => $row['from_username'], 'from_display_name' => $row['from_username'] . '#' . $row['from_display_id'] ]; @@ -1461,6 +1763,7 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') { + 💬 Secure Private Chat @@ -2430,14 +2850,24 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') { +
+
+ + + +
+ @@ -2798,106 +3228,428 @@ 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'); +const attachmentButtonEl = document.getElementById('attachmentButton'); +const attachmentInputEl = document.getElementById('attachmentInput'); +const attachmentInfoEl = document.getElementById('attachmentInfo'); +const attachmentFileNameEl = document.getElementById('attachmentFileName'); +const attachmentClearBtnEl = document.getElementById('attachmentClearBtn'); +const attachmentWarningEl = document.getElementById('attachmentWarning'); +const ATTACHMENT_MAX_SIZE = 200 * 1024; +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'; + clearAttachmentSelection(); + clearAttachmentWarning(); - document.getElementById('chatMessagesHeader').innerHTML = ` -
${displayName.charAt(0).toUpperCase()}
-
${displayName}
- `; + if (chatWelcomeEl) { + chatWelcomeEl.style.display = 'none'; + } - loadMessages(userId); + if (chatMessagesContainerEl) { + chatMessagesContainerEl.style.display = 'flex'; + } + + 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; const time = new Date(msg.timestamp).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + const hasText = typeof msg.message === 'string' && msg.message.trim() !== ''; + const attachmentUrl = msg.attachment_url; + + const textHtml = hasText ? `
${escapeHtml(msg.message)}
` : ''; + const attachmentHtml = attachmentUrl + ? `
Gesendetes Bild
` + : ''; return `
-
${escapeHtml(msg.message)}
+ ${attachmentHtml} + ${textHtml}
${time}
`; }).join(''); + container.classList.remove('hidden'); container.scrollTop = container.scrollHeight; + updateChatState(null); +} + +function clearAttachmentSelection() { + if (attachmentInputEl) { + attachmentInputEl.value = ''; + } + if (attachmentInfoEl) { + attachmentInfoEl.classList.add('hidden'); + } + if (attachmentFileNameEl) { + attachmentFileNameEl.textContent = ''; + } +} + +function showAttachmentWarning(message) { + if (attachmentWarningEl) { + attachmentWarningEl.textContent = message; + attachmentWarningEl.classList.remove('hidden'); + } else { + alert(message); + } +} + +function clearAttachmentWarning() { + if (attachmentWarningEl) { + attachmentWarningEl.textContent = ''; + attachmentWarningEl.classList.add('hidden'); + } } async function sendMessage() { - const input = document.getElementById('chatInput'); - const message = input.value.trim(); + if (!chatInputEl) { + return; + } - if (!message || !state.selectedUserId) return; + if (!state.selectedUserId) { + showAttachmentWarning('Bitte wähle zuerst einen Chat aus.'); + return; + } + + const message = chatInputEl.value.trim(); + const attachmentFile = attachmentInputEl?.files?.[0] || null; + + if (!message && !attachmentFile) { + showAttachmentWarning('Bitte gib eine Nachricht ein oder hänge ein JPG-Bild an.'); + return; + } + + clearAttachmentWarning(); + + if (attachmentFile) { + const fileType = (attachmentFile.type || '').toLowerCase(); + const fileName = attachmentFile.name || ''; + const isJpeg = /^image\/jpe?g$/.test(fileType) || /\.jpe?g$/i.test(fileName); + + if (!isJpeg) { + showAttachmentWarning('Nur JPG-Bilder sind erlaubt.'); + clearAttachmentSelection(); + return; + } + + if (attachmentFile.size > ATTACHMENT_MAX_SIZE) { + showAttachmentWarning('Bild ist zu groß (max. 200 KB).'); + clearAttachmentSelection(); + return; + } + } const formData = new FormData(); formData.append('action', 'send_message'); formData.append('to_user_id', state.selectedUserId); formData.append('message', message); - const response = await fetch('', { method: 'POST', body: formData }); - const result = await response.json(); + if (attachmentFile) { + formData.append('attachment', attachmentFile); + } - if (result.success) { - input.value = ''; - } else { - alert(result.error); + try { + const response = await fetch('', { method: 'POST', body: formData }); + const result = await response.json(); + + if (result.success) { + chatInputEl.value = ''; + chatInputEl.dispatchEvent(new Event('input')); + clearAttachmentSelection(); + clearAttachmentWarning(); + } else { + showAttachmentWarning(result.error || 'Nachricht konnte nicht gesendet werden.'); + } + } catch (error) { + console.error('Nachricht konnte nicht gesendet werden:', error); + showAttachmentWarning('Nachricht konnte nicht gesendet werden.'); } } @@ -2911,21 +3663,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 +3718,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 +3742,75 @@ function escapeHtml(text) { return div.innerHTML; } -document.getElementById('sendButton').addEventListener('click', sendMessage); -document.getElementById('chatInput').addEventListener('keypress', (e) => { +function escapeAttribute(value) { + const div = document.createElement('div'); + div.textContent = value ?? ''; + return div.innerHTML.replace(/"/g, '"').replace(/'/g, '''); +} + +attachmentButtonEl?.addEventListener('click', () => { + attachmentInputEl?.click(); +}); + +attachmentInputEl?.addEventListener('change', () => { + clearAttachmentWarning(); + + if (!attachmentInputEl.files || attachmentInputEl.files.length === 0) { + clearAttachmentSelection(); + return; + } + + const file = attachmentInputEl.files[0]; + const fileType = (file.type || '').toLowerCase(); + const fileName = file.name || ''; + const isJpeg = /^image\/jpe?g$/.test(fileType) || /\.jpe?g$/i.test(fileName); + + if (!isJpeg) { + showAttachmentWarning('Nur JPG-Bilder sind erlaubt.'); + clearAttachmentSelection(); + return; + } + + if (file.size > ATTACHMENT_MAX_SIZE) { + showAttachmentWarning('Bild ist zu groß (max. 200 KB).'); + clearAttachmentSelection(); + return; + } + + if (attachmentInfoEl) { + attachmentInfoEl.classList.remove('hidden'); + } + + if (attachmentFileNameEl) { + const sizeKb = Math.max(1, Math.round(file.size / 1024)); + attachmentFileNameEl.textContent = `${file.name} (${sizeKb} KB)`; + } +}); + +attachmentClearBtnEl?.addEventListener('click', () => { + clearAttachmentSelection(); + clearAttachmentWarning(); +}); + +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'; }); From 19f0df38862ecc9b8a7798f1259e0680e22b8e4b Mon Sep 17 00:00:00 2001 From: Metacube Date: Mon, 3 Nov 2025 19:44:39 +0100 Subject: [PATCH 02/10] Add 3D traceroute visualization app --- route.php | 469 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 route.php diff --git a/route.php b/route.php new file mode 100644 index 0000000..9a74408 --- /dev/null +++ b/route.php @@ -0,0 +1,469 @@ +&1'; + $rawOutput = shell_exec($command); + if ($rawOutput === null) { + $error = 'Traceroute konnte nicht ausgeführt werden. Ist das Kommando verfügbar?'; + } else { + $traceData = parseTraceroute($rawOutput); + if (empty($traceData)) { + $error = 'Keine Hops gefunden. Prüfen Sie den Hostnamen oder versuchen Sie es später erneut.'; + } + } + } +} + +if (empty($traceData)) { + $traceData = getSampleTrace(); + if ($error === '') { + $error = 'Es werden Beispieldaten angezeigt. Starten Sie eine Abfrage, um echte Traceroute-Daten zu sehen.'; + } +} + +function parseTraceroute(string $raw): array +{ + $lines = preg_split('/\r?\n/', trim($raw)); + if (!$lines) { + return []; + } + + $hops = []; + foreach ($lines as $line) { + if (preg_match('/^\s*\d+\s+/', $line) !== 1) { + continue; + } + + preg_match_all('/(\d+\.\d+)\s+ms/', $line, $latencyMatches); + $latencies = array_map('floatval', $latencyMatches[1] ?? []); + $avgLatency = !empty($latencies) ? array_sum($latencies) / count($latencies) : null; + + if (preg_match('/^\s*(\d+)\s+([0-9\.\*]+)/', $line, $parts) !== 1) { + continue; + } + + $hopNumber = (int) $parts[1]; + $ip = $parts[2]; + if ($ip === '*') { + $ip = 'Zeitüberschreitung'; + } + + $hops[] = [ + 'hop' => $hopNumber, + 'ip' => $ip, + 'avgLatency' => $avgLatency, + 'raw' => trim($line), + ]; + } + + return $hops; +} + +function getSampleTrace(): array +{ + return [ + ['hop' => 1, 'ip' => '192.168.0.1', 'avgLatency' => 1.2, 'raw' => '1 192.168.0.1 1.123 ms 1.234 ms 1.301 ms'], + ['hop' => 2, 'ip' => '10.12.34.1', 'avgLatency' => 9.4, 'raw' => '2 10.12.34.1 9.123 ms 9.567 ms 9.400 ms'], + ['hop' => 3, 'ip' => '172.16.5.4', 'avgLatency' => 18.7, 'raw' => '3 172.16.5.4 18.432 ms 18.913 ms 18.787 ms'], + ['hop' => 4, 'ip' => '203.0.113.5', 'avgLatency' => 32.9, 'raw' => '4 203.0.113.5 32.113 ms 33.441 ms 33.212 ms'], + ['hop' => 5, 'ip' => '93.184.216.34', 'avgLatency' => 48.2, 'raw' => '5 93.184.216.34 48.112 ms 48.501 ms 48.032 ms'], + ]; +} + +function generatePositions(array $trace): array +{ + $positions = []; + $radius = 25; + $spacing = 10; + foreach ($trace as $index => $hop) { + $angle = $index * 0.9; + $positions[] = [ + 'x' => cos($angle) * $radius, + 'y' => $index * $spacing, + 'z' => sin($angle) * $radius, + ]; + } + return $positions; +} + +$positions = generatePositions($traceData); +?> + + + + + + 3D Traceroute Visualisierung + + + +
+

3D Traceroute Explorer

+

Visualisieren Sie Netzwerkpfade im dreidimensionalen Raum und erkunden Sie die einzelnen Hops.

+
+
+
+ +
+ +
+ + + + + + From 99eaee55eb45e0792817978a30f61c9daea624ec Mon Sep 17 00:00:00 2001 From: Metacube Date: Mon, 3 Nov 2025 19:54:52 +0100 Subject: [PATCH 03/10] Add login form and robust realtime fallback --- write.php | 2159 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 1772 insertions(+), 387 deletions(-) diff --git a/write.php b/write.php index cba271d..3824f03 100644 --- a/write.php +++ b/write.php @@ -28,7 +28,10 @@ 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); +define('MAX_ATTACHMENT_SIZE', 200 * 1024); // 200 KB +define('UPLOAD_DIR', __DIR__ . '/uploads'); // Rate Limiting define('MAX_MESSAGES_PER_MINUTE', 10); @@ -70,131 +73,174 @@ $PROFANITY_FILTER = [ // ═══════════════════════════════════════════════════════════ function getDB() { - $db = new SQLite3(DB_FILE); - $db->busyTimeout(5000); - - // Users Table (mit Geburtsdatum und User-ID) - $db->exec(' - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL, - user_id TEXT UNIQUE NOT NULL, - birthdate DATE NOT NULL, - age_group TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_seen DATETIME DEFAULT CURRENT_TIMESTAMP, - is_banned INTEGER DEFAULT 0, - ban_reason TEXT - ) - '); - - // Messages Table - $db->exec(' - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - from_user_id INTEGER NOT NULL, - to_user_id INTEGER NOT NULL, - message TEXT NOT NULL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - is_read INTEGER DEFAULT 0, - is_flagged INTEGER DEFAULT 0, - flag_reason TEXT, - FOREIGN KEY (from_user_id) REFERENCES users(id), - FOREIGN KEY (to_user_id) REFERENCES users(id) - ) - '); - - // Online Status Table - $db->exec(' - CREATE TABLE IF NOT EXISTS online_status ( - user_id INTEGER PRIMARY KEY, - last_ping DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) - ) - '); - - // Reports Table - $db->exec(' - CREATE TABLE IF NOT EXISTS reports ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - reporter_id INTEGER NOT NULL, - reported_user_id INTEGER NOT NULL, - reason TEXT NOT NULL, - message_id INTEGER, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - status TEXT DEFAULT "pending", - FOREIGN KEY (reporter_id) REFERENCES users(id), - FOREIGN KEY (reported_user_id) REFERENCES users(id), - FOREIGN KEY (message_id) REFERENCES messages(id) - ) - '); - - // Blocks Table - $db->exec(' - CREATE TABLE IF NOT EXISTS blocks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - blocker_id INTEGER NOT NULL, - blocked_id INTEGER NOT NULL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (blocker_id) REFERENCES users(id), - FOREIGN KEY (blocked_id) REFERENCES users(id), - UNIQUE(blocker_id, blocked_id) - ) - '); - - // Rate Limiting Table - $db->exec(' - CREATE TABLE IF NOT EXISTS rate_limits ( - user_id INTEGER NOT NULL, - action_type TEXT NOT NULL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) - ) - '); - - // Logs Table (für Behörden) - $db->exec(' - CREATE TABLE IF NOT EXISTS security_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, - action TEXT NOT NULL, - details TEXT, - ip_address TEXT, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) - ) - '); - - // Admin Table - $db->exec(' - CREATE TABLE IF NOT EXISTS admins ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - '); - - // Create default admin if not exists - $stmt = $db->prepare('SELECT COUNT(*) as count FROM admins WHERE username = :username'); - $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); - $result = $stmt->execute(); - $row = $result->fetchArray(SQLITE3_ASSOC); - - if ($row['count'] == 0) { - $stmt = $db->prepare('INSERT INTO admins (username, password_hash) VALUES (:username, :password)'); - $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); - $stmt->bindValue(':password', ADMIN_PASSWORD, SQLITE3_TEXT); - $stmt->execute(); + static $db = null; + static $initialized = false; + + if ($db === null) { + if (!is_dir(UPLOAD_DIR)) { + @mkdir(UPLOAD_DIR, 0755, true); + } + + $db = new SQLite3(DB_FILE); + $db->busyTimeout(5000); } - - // Create indexes - $db->exec('CREATE INDEX IF NOT EXISTS idx_messages_users ON messages(from_user_id, to_user_id)'); - $db->exec('CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)'); - $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)'); - + + if (!$initialized) { + // Users Table (mit Geburtsdatum und User-ID) + $db->exec(' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + user_id TEXT UNIQUE NOT NULL, + birthdate DATE NOT NULL, + age_group TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + is_banned INTEGER DEFAULT 0, + ban_reason TEXT + ) + '); + + // Messages Table + $db->exec(' + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_user_id INTEGER NOT NULL, + to_user_id INTEGER NOT NULL, + message TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + is_read INTEGER DEFAULT 0, + is_flagged INTEGER DEFAULT 0, + flag_reason TEXT, + attachment_path TEXT, + attachment_type TEXT, + attachment_size INTEGER, + FOREIGN KEY (from_user_id) REFERENCES users(id), + FOREIGN KEY (to_user_id) REFERENCES users(id) + ) + '); + + // Ensure attachment columns exist for older installations + $messagesInfo = $db->query('PRAGMA table_info(messages)'); + $messageColumns = []; + while ($column = $messagesInfo->fetchArray(SQLITE3_ASSOC)) { + $messageColumns[$column['name']] = true; + } + if (!isset($messageColumns['attachment_path'])) { + $db->exec('ALTER TABLE messages ADD COLUMN attachment_path TEXT'); + } + if (!isset($messageColumns['attachment_type'])) { + $db->exec('ALTER TABLE messages ADD COLUMN attachment_type TEXT'); + } + if (!isset($messageColumns['attachment_size'])) { + $db->exec('ALTER TABLE messages ADD COLUMN attachment_size INTEGER'); + } + + // Online Status Table + $db->exec(' + CREATE TABLE IF NOT EXISTS online_status ( + user_id INTEGER PRIMARY KEY, + last_ping DATETIME DEFAULT CURRENT_TIMESTAMP, + 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 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reporter_id INTEGER NOT NULL, + reported_user_id INTEGER NOT NULL, + reason TEXT NOT NULL, + message_id INTEGER, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + status TEXT DEFAULT "pending", + FOREIGN KEY (reporter_id) REFERENCES users(id), + FOREIGN KEY (reported_user_id) REFERENCES users(id), + FOREIGN KEY (message_id) REFERENCES messages(id) + ) + '); + + // Blocks Table + $db->exec(' + CREATE TABLE IF NOT EXISTS blocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + blocker_id INTEGER NOT NULL, + blocked_id INTEGER NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (blocker_id) REFERENCES users(id), + FOREIGN KEY (blocked_id) REFERENCES users(id), + UNIQUE(blocker_id, blocked_id) + ) + '); + + // Rate Limiting Table + $db->exec(' + CREATE TABLE IF NOT EXISTS rate_limits ( + user_id INTEGER NOT NULL, + action_type TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + '); + + // Logs Table (für Behörden) + $db->exec(' + CREATE TABLE IF NOT EXISTS security_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + action TEXT NOT NULL, + details TEXT, + ip_address TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + '); + + // Admin Table + $db->exec(' + CREATE TABLE IF NOT EXISTS admins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + '); + + // Create default admin if not exists + $stmt = $db->prepare('SELECT COUNT(*) as count FROM admins WHERE username = :username'); + $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); + $result = $stmt->execute(); + $row = $result->fetchArray(SQLITE3_ASSOC); + + if ($row['count'] == 0) { + $stmt = $db->prepare('INSERT INTO admins (username, password_hash) VALUES (:username, :password)'); + $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); + $stmt->bindValue(':password', ADMIN_PASSWORD, SQLITE3_TEXT); + $stmt->execute(); + } + + // Create indexes + $db->exec('CREATE INDEX IF NOT EXISTS idx_messages_users ON messages(from_user_id, to_user_id)'); + $db->exec('CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)'); + $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)'); + + $initialized = true; + } + return $db; } @@ -371,9 +417,27 @@ function isBlocked($userId, $otherUserId) { function cleanupOldData() { $db = getDB(); - + // Delete old messages $hours = MESSAGE_RETENTION_HOURS; + $attachmentResult = $db->query("SELECT attachment_path FROM messages WHERE attachment_path IS NOT NULL AND timestamp < datetime('now', '-{$hours} hours')"); + while ($attachmentRow = $attachmentResult->fetchArray(SQLITE3_ASSOC)) { + $relativePath = $attachmentRow['attachment_path'] ?? ''; + if (!$relativePath) { + continue; + } + + $normalizedPath = str_replace('\\', '/', $relativePath); + if (strpos($normalizedPath, 'uploads/') !== 0) { + continue; + } + + $fullPath = __DIR__ . '/' . $normalizedPath; + if (is_file($fullPath)) { + @unlink($fullPath); + } + } + $db->exec("DELETE FROM messages WHERE timestamp < datetime('now', '-{$hours} hours')"); // Delete old rate limits @@ -382,6 +446,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 +491,124 @@ 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, $force = false) { + 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 && !$force && !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. Übernimm die Sitzung nur, wenn du wirklich ausgeloggt bist.', + 'can_force' => true + ]; + } + } + + if ($force && $existing) { + $stmt = $db->prepare('DELETE FROM user_sessions WHERE user_id = :user_id'); + $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); + $stmt->execute(); + } + + $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; } // ═══════════════════════════════════════════════════════════ @@ -446,7 +627,7 @@ if (isset($_POST['action']) || isset($_GET['action'])) { $username = trim($_POST['username'] ?? ''); $birthdate = trim($_POST['birthdate'] ?? ''); $agreed_terms = isset($_POST['agreed_terms']) && $_POST['agreed_terms'] === 'true'; - + // Validierung if (empty($username) || empty($birthdate)) { echo json_encode(['success' => false, 'error' => 'Username und Geburtsdatum erforderlich']); @@ -512,9 +693,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; @@ -533,7 +724,85 @@ if (isset($_POST['action']) || isset($_GET['action'])) { ]); exit; } - + + // ─────────────────────────────────────────────────────── + // LOGIN + // ─────────────────────────────────────────────────────── + if ($action === 'login') { + $username = trim($_POST['username'] ?? ''); + $birthdate = trim($_POST['birthdate'] ?? ''); + $forceLogin = in_array(($_POST['force_login'] ?? '0'), ['1', 'true', 'TRUE'], true); + + if ($username === '' || $birthdate === '') { + echo json_encode(['success' => false, 'error' => 'Bitte gib Username und Geburtsdatum ein.']); + exit; + } + + $db = getDB(); + $stmt = $db->prepare(' + SELECT id, username, user_id as display_id, birthdate, age_group, is_banned, ban_reason + FROM users + WHERE LOWER(username) = LOWER(:username) + LIMIT 1 + '); + $stmt->bindValue(':username', $username, SQLITE3_TEXT); + $result = $stmt->execute(); + $user = $result->fetchArray(SQLITE3_ASSOC); + + if (!$user) { + echo json_encode(['success' => false, 'error' => 'Account wurde nicht gefunden.']); + exit; + } + + if ((int)$user['is_banned'] === 1) { + $reason = $user['ban_reason'] ? (string)$user['ban_reason'] : 'Verstoß gegen Regeln'; + echo json_encode(['success' => false, 'error' => 'Dein Account ist gesperrt: ' . $reason]); + exit; + } + + if ($user['birthdate'] !== $birthdate) { + logSecurityEvent($user['id'], 'LOGIN_FAILED', 'Falsches Geburtsdatum'); + echo json_encode(['success' => false, 'error' => 'Daten stimmen nicht überein.']); + exit; + } + + $sessionResult = startUserSession($user['id'], $forceLogin); + if (!$sessionResult['allowed']) { + $response = [ + 'success' => false, + 'error' => $sessionResult['error'] ?? 'Anmeldung nicht möglich.' + ]; + + if (!empty($sessionResult['can_force'])) { + $response['can_force'] = true; + } + + echo json_encode($response); + exit; + } + + if ($forceLogin) { + logSecurityEvent($user['id'], 'LOGIN_FORCE', 'Sitzung übernommen'); + } + + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['user_display_id'] = $user['display_id']; + $_SESSION['age_group'] = $user['age_group']; + $_SESSION['birthdate'] = $user['birthdate']; + + updateOnlineStatus($user['id']); + logSecurityEvent($user['id'], 'LOGIN', 'Erfolgreiche Anmeldung'); + + echo json_encode([ + 'success' => true, + 'user_id' => $user['id'], + 'display_name' => $user['username'] . '#' . $user['display_id'], + 'age_group' => $user['age_group'] + ]); + exit; + } + // ─────────────────────────────────────────────────────── // ADMIN LOGIN // ─────────────────────────────────────────────────────── @@ -567,18 +836,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 +997,36 @@ 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, + m.attachment_path, + m.attachment_type, + m.attachment_size, + 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 = []; @@ -748,6 +1039,9 @@ if (isset($_POST['action']) || isset($_GET['action'])) { 'timestamp' => $row['timestamp'], 'is_read' => $row['is_read'], 'is_flagged' => $row['is_flagged'], + 'attachment_url' => $row['attachment_path'] ?: null, + 'attachment_type' => $row['attachment_type'] ?: null, + 'attachment_size' => $row['attachment_size'] !== null ? (int)$row['attachment_size'] : null, 'from_username' => $row['from_username'], 'from_display_name' => $row['from_username'] . '#' . $row['from_display_id'] ]; @@ -763,28 +1057,81 @@ if (isset($_POST['action']) || isset($_GET['action'])) { if ($action === 'send_message') { $toUserId = intval($_POST['to_user_id'] ?? 0); $message = trim($_POST['message'] ?? ''); - + $hasAttachment = isset($_FILES['attachment']) && ($_FILES['attachment']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE; + if ($toUserId <= 0) { echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); exit; } - - if (empty($message)) { - echo json_encode(['success' => false, 'error' => 'Nachricht darf nicht leer sein']); + + if (!$hasAttachment && $message === '') { + echo json_encode(['success' => false, 'error' => 'Nachricht oder Bild erforderlich']); exit; } - + if (strlen($message) > 1000) { echo json_encode(['success' => false, 'error' => 'Nachricht zu lang (max 1000 Zeichen)']); exit; } - + + $attachmentFile = $hasAttachment ? $_FILES['attachment'] : null; + $attachmentMime = null; + $attachmentSize = null; + + if ($hasAttachment && $attachmentFile) { + if (!is_uploaded_file($attachmentFile['tmp_name'])) { + echo json_encode(['success' => false, 'error' => 'Ungültiger Datei-Upload']); + exit; + } + + if ($attachmentFile['error'] !== UPLOAD_ERR_OK) { + echo json_encode(['success' => false, 'error' => 'Bild konnte nicht hochgeladen werden']); + exit; + } + + if ($attachmentFile['size'] > MAX_ATTACHMENT_SIZE) { + echo json_encode(['success' => false, 'error' => 'Bild ist zu groß (max. 200 KB)']); + exit; + } + + $attachmentSize = (int)$attachmentFile['size']; + + $mime = null; + if (function_exists('finfo_open')) { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + if ($finfo) { + $mime = finfo_file($finfo, $attachmentFile['tmp_name']); + finfo_close($finfo); + } + } + if (!$mime && function_exists('mime_content_type')) { + $mime = mime_content_type($attachmentFile['tmp_name']); + } + if (!$mime && isset($attachmentFile['type'])) { + $mime = $attachmentFile['type']; + } + + $mime = strtolower((string)$mime); + if (!in_array($mime, ['image/jpeg', 'image/pjpeg', 'image/jpg'], true)) { + echo json_encode(['success' => false, 'error' => 'Nur JPG-Bilder sind erlaubt']); + exit; + } + + $imageInfo = @getimagesize($attachmentFile['tmp_name']); + if ($imageInfo === false || !in_array($imageInfo[2], [IMAGETYPE_JPEG], true)) { + echo json_encode(['success' => false, 'error' => 'Bilddatei konnte nicht verifiziert werden']); + exit; + } + + $attachmentMime = 'image/jpeg'; + } + // Check if blocked if (isBlocked(getCurrentUserId(), $toUserId)) { echo json_encode(['success' => false, 'error' => 'Nachricht kann nicht gesendet werden']); exit; } - + $db = getDB(); $currentUserId = getCurrentUserId(); $currentAgeGroup = getCurrentAgeGroup(); @@ -812,91 +1159,119 @@ if (isset($_POST['action']) || isset($_GET['action'])) { echo json_encode(['success' => false, 'error' => $rateLimitCheck['reason']]); exit; } - - // Keyword Blacklist - $keywordCheck = checkKeywordBlacklist($message); - if ($keywordCheck['blocked']) { - logSecurityEvent($currentUserId, 'KEYWORD_BLOCKED', "Keyword: {$keywordCheck['keyword']}"); - echo json_encode([ - 'success' => false, - 'error' => 'Deine Nachricht enthält nicht erlaubte Inhalte', - 'details' => 'Verbotenes Wort erkannt: ' . $keywordCheck['keyword'] - ]); - exit; + + if ($message !== '') { + // Keyword Blacklist + $keywordCheck = checkKeywordBlacklist($message); + if ($keywordCheck['blocked']) { + logSecurityEvent($currentUserId, 'KEYWORD_BLOCKED', "Keyword: {$keywordCheck['keyword']}"); + echo json_encode([ + 'success' => false, + 'error' => 'Deine Nachricht enthält nicht erlaubte Inhalte', + 'details' => 'Verbotenes Wort erkannt: ' . $keywordCheck['keyword'] + ]); + exit; + } + + // Profanity Filter + $profanityCheck = checkProfanityFilter($message); + if ($profanityCheck['blocked']) { + logSecurityEvent($currentUserId, 'PROFANITY_BLOCKED', "Word: {$profanityCheck['word']}"); + echo json_encode([ + 'success' => false, + 'error' => 'Deine Nachricht enthält Schimpfwörter', + 'details' => 'Bitte verwende eine angemessene Sprache' + ]); + exit; + } + + // Link Filter + $linkCheck = checkLinkFilter($message); + if ($linkCheck['blocked']) { + logSecurityEvent($currentUserId, 'LINK_BLOCKED', "Message: $message"); + echo json_encode([ + 'success' => false, + 'error' => 'Links sind nicht erlaubt', + 'details' => 'Aus Sicherheitsgründen können keine URLs gesendet werden' + ]); + exit; + } } - - // Profanity Filter - $profanityCheck = checkProfanityFilter($message); - if ($profanityCheck['blocked']) { - logSecurityEvent($currentUserId, 'PROFANITY_BLOCKED', "Word: {$profanityCheck['word']}"); - echo json_encode([ - 'success' => false, - 'error' => 'Deine Nachricht enthält Schimpfwörter', - 'details' => 'Bitte verwende eine angemessene Sprache' - ]); - exit; - } - - // Link Filter - $linkCheck = checkLinkFilter($message); - if ($linkCheck['blocked']) { - logSecurityEvent($currentUserId, 'LINK_BLOCKED', "Message: $message"); - echo json_encode([ - 'success' => false, - 'error' => 'Links sind nicht erlaubt', - 'details' => 'Aus Sicherheitsgründen können keine URLs gesendet werden' - ]); - exit; - } - + // Auto-Flagging (verdächtige Muster) $isFlagged = 0; $flagReason = ''; - - // Check for repeated characters (AAAAAAA) - if (preg_match('/(.)\1{5,}/', $message)) { - $isFlagged = 1; - $flagReason = 'Repeated characters'; + + if ($message !== '') { + if (preg_match('/(.)\1{5,}/', $message)) { + $isFlagged = 1; + $flagReason = 'Repeated characters'; + } + + if (strlen($message) > 20 && $message === strtoupper($message)) { + $isFlagged = 1; + $flagReason = 'All caps'; + } + + $emojiCount = preg_match_all('/[\x{1F600}-\x{1F64F}]/u', $message); + if ($emojiCount > 10) { + $isFlagged = 1; + $flagReason = 'Excessive emojis'; + } } - - // Check for all caps (min 20 chars) - if (strlen($message) > 20 && $message === strtoupper($message)) { - $isFlagged = 1; - $flagReason = 'All caps'; + + $attachmentPath = null; + if ($hasAttachment && $attachmentFile) { + $randomName = bin2hex(random_bytes(16)) . '.jpg'; + $destination = rtrim(UPLOAD_DIR, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $randomName; + + if (!move_uploaded_file($attachmentFile['tmp_name'], $destination)) { + echo json_encode(['success' => false, 'error' => 'Bild konnte nicht gespeichert werden']); + exit; + } + + $attachmentPath = 'uploads/' . $randomName; } - - // Check for excessive emojis - $emojiCount = preg_match_all('/[\x{1F600}-\x{1F64F}]/u', $message); - if ($emojiCount > 10) { - $isFlagged = 1; - $flagReason = 'Excessive emojis'; - } - + // Insert message $stmt = $db->prepare(' - INSERT INTO messages (from_user_id, to_user_id, message, is_flagged, flag_reason) - VALUES (:from_user_id, :to_user_id, :message, :is_flagged, :flag_reason) + INSERT INTO messages (from_user_id, to_user_id, message, is_flagged, flag_reason, attachment_path, attachment_type, attachment_size) + VALUES (:from_user_id, :to_user_id, :message, :is_flagged, :flag_reason, :attachment_path, :attachment_type, :attachment_size) '); $stmt->bindValue(':from_user_id', $currentUserId, SQLITE3_INTEGER); $stmt->bindValue(':to_user_id', $toUserId, SQLITE3_INTEGER); $stmt->bindValue(':message', $message, SQLITE3_TEXT); $stmt->bindValue(':is_flagged', $isFlagged, SQLITE3_INTEGER); $stmt->bindValue(':flag_reason', $flagReason, SQLITE3_TEXT); + if ($attachmentPath) { + $stmt->bindValue(':attachment_path', $attachmentPath, SQLITE3_TEXT); + $stmt->bindValue(':attachment_type', $attachmentMime, SQLITE3_TEXT); + $stmt->bindValue(':attachment_size', $attachmentSize, SQLITE3_INTEGER); + } else { + $stmt->bindValue(':attachment_path', null, SQLITE3_NULL); + $stmt->bindValue(':attachment_type', null, SQLITE3_NULL); + $stmt->bindValue(':attachment_size', null, SQLITE3_NULL); + } $stmt->execute(); - + $messageId = $db->lastInsertRowID(); - + // Log rate limit logRateLimit($currentUserId); - + if ($isFlagged) { logSecurityEvent($currentUserId, 'MESSAGE_FLAGGED', "Reason: $flagReason, Message ID: $messageId"); } - + + if ($attachmentPath) { + logSecurityEvent($currentUserId, 'ATTACHMENT_UPLOADED', "Message ID: $messageId, Size: $attachmentSize"); + } + echo json_encode([ 'success' => true, 'message_id' => $messageId, - 'timestamp' => date('Y-m-d H:i:s') + 'timestamp' => date('Y-m-d H:i:s'), + 'attachment_url' => $attachmentPath ]); exit; } @@ -1344,23 +1719,106 @@ if (isset($_POST['action']) || isset($_GET['action'])) { // ─────────────────────────────────────────────────────── if ($action === 'admin_delete_message') { $messageId = intval($_POST['message_id'] ?? 0); - + if ($messageId <= 0) { echo json_encode(['success' => false, 'error' => 'Ungültige Message-ID']); exit; } - + $db = getDB(); $stmt = $db->prepare('DELETE FROM messages WHERE id = :message_id'); $stmt->bindValue(':message_id', $messageId, SQLITE3_INTEGER); $stmt->execute(); - + logSecurityEvent(null, 'ADMIN_DELETE_MESSAGE', "Message ID: $messageId"); - + echo json_encode(['success' => true, 'message' => 'Nachricht wurde gelöscht']); exit; } - + + if ($action === 'poll_updates') { + if (!isLoggedIn()) { + echo json_encode(['success' => false, 'error' => 'Nicht angemeldet']); + exit; + } + + if (!validateActiveSession()) { + echo json_encode(['success' => false, 'error' => 'Sitzung ungültig']); + exit; + } + + $lastMessageId = intval($_POST['last_message_id'] ?? $_GET['last_message_id'] ?? 0); + + $db = getDB(); + $currentUserId = getCurrentUserId(); + $currentAgeGroup = getCurrentAgeGroup(); + + touchUserSession($currentUserId); + + $stmt = $db->prepare(' + SELECT + m.id, + m.from_user_id, + m.to_user_id, + m.message, + m.timestamp, + m.attachment_path, + m.attachment_type, + m.attachment_size, + uf.username as from_username, + uf.user_id as from_display_id, + uf.age_group as from_age_group, + ut.age_group as to_age_group + FROM messages m + JOIN users uf ON m.from_user_id = uf.id + JOIN users ut ON m.to_user_id = ut.id + WHERE m.id > :last_message_id + AND (m.to_user_id = :current_user_id OR m.from_user_id = :current_user_id) + AND NOT EXISTS ( + SELECT 1 FROM blocks + WHERE (blocker_id = :current_user_id AND blocked_id = m.from_user_id) + OR (blocker_id = m.from_user_id AND blocked_id = :current_user_id) + ) + ORDER BY m.id ASC + '); + $stmt->bindValue(':last_message_id', $lastMessageId, SQLITE3_INTEGER); + $stmt->bindValue(':current_user_id', $currentUserId, SQLITE3_INTEGER); + $result = $stmt->execute(); + + $messages = []; + $maxId = $lastMessageId; + + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $otherAgeGroup = $row['from_user_id'] === $currentUserId ? $row['to_age_group'] : $row['from_age_group']; + + if (!canUsersChatByAge($currentAgeGroup, $otherAgeGroup)) { + continue; + } + + $maxId = max($maxId, (int)$row['id']); + + $messages[] = [ + 'id' => $row['id'], + 'from_user_id' => $row['from_user_id'], + 'to_user_id' => $row['to_user_id'], + 'message' => $row['message'], + 'timestamp' => $row['timestamp'], + 'attachment_url' => $row['attachment_path'] ?: null, + 'attachment_type' => $row['attachment_type'] ?: null, + 'attachment_size' => $row['attachment_size'] !== null ? (int)$row['attachment_size'] : null, + 'from_username' => $row['from_username'], + 'from_display_name' => $row['from_username'] . '#' . $row['from_display_id'] + ]; + } + + echo json_encode([ + 'success' => true, + 'messages' => $messages, + 'last_message_id' => $maxId + ]); + exit; + } + echo json_encode(['success' => false, 'error' => 'Unbekannte Aktion']); exit; } @@ -1373,16 +1831,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) { @@ -1395,17 +1859,20 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') { $db = getDB(); - $stmt = $db->prepare(' - SELECT - m.id, - m.from_user_id, - m.to_user_id, - m.message, - m.timestamp, - uf.username as from_username, - uf.user_id as from_display_id, - uf.age_group as from_age_group, - ut.age_group as to_age_group + $stmt = $db->prepare(' + SELECT + m.id, + m.from_user_id, + m.to_user_id, + m.message, + m.timestamp, + m.attachment_path, + m.attachment_type, + m.attachment_size, + uf.username as from_username, + uf.user_id as from_display_id, + uf.age_group as from_age_group, + ut.age_group as to_age_group FROM messages m JOIN users uf ON m.from_user_id = uf.id JOIN users ut ON m.to_user_id = ut.id @@ -1436,6 +1903,9 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') { 'to_user_id' => $row['to_user_id'], 'message' => $row['message'], 'timestamp' => $row['timestamp'], + 'attachment_url' => $row['attachment_path'] ?: null, + 'attachment_type' => $row['attachment_type'] ?: null, + 'attachment_size' => $row['attachment_size'] !== null ? (int)$row['attachment_size'] : null, 'from_username' => $row['from_username'], 'from_display_name' => $row['from_username'] . '#' . $row['from_display_id'] ]; @@ -1461,6 +1931,7 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') { + 💬 Secure Private Chat @@ -2357,20 +3008,26 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') {

💬 Secure Private Chat

Sicherer Chat mit Altersverifikation

- -
- -
+ +
+ + +
+ +
+
+ +
- +
- +

⚠️ Wichtige Regeln

    @@ -2386,10 +3043,31 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') {
- +
- + + + @@ -2430,14 +3108,24 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') {
+
+
+ + + +
+ @@ -2448,6 +3136,37 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') { // JAVASCRIPT // ═══════════════════════════════════════════════════════════ +const currentUrl = new URL(window.location.href); +const basePath = currentUrl.pathname; +const baseParams = new URLSearchParams(currentUrl.search); +const postTarget = `${currentUrl.origin}${basePath}${baseParams.toString() ? `?${baseParams.toString()}` : ''}`; + +function buildUrl(params = {}) { + const url = new URL(basePath, window.location.origin); + baseParams.forEach((value, key) => { + if (!Object.prototype.hasOwnProperty.call(params, key)) { + url.searchParams.set(key, value); + } + }); + + Object.entries(params).forEach(([key, value]) => { + if (value === null || value === undefined) { + return; + } + url.searchParams.set(key, value); + }); + + return url.toString(); +} + +function postFormData(formData) { + return fetch(postTarget, { + method: 'POST', + body: formData, + credentials: 'same-origin' + }); +} + // ADMIN DASHBOARD const adminStatsGrid = document.getElementById('adminStatsGrid'); @@ -2475,7 +3194,7 @@ async function adminFetch(action, payload = {}) { formData.append('action', action); Object.entries(payload).forEach(([key, val]) => formData.append(key, val)); - const response = await fetch('', { method: 'POST', body: formData }); + const response = await postFormData(formData); return response.json(); } @@ -2636,7 +3355,7 @@ function renderBanned(banned) { } async function loadAdminStats() { - const response = await fetch('?action=admin_get_stats'); + const response = await fetch(buildUrl({ action: 'admin_get_stats' }), { credentials: 'same-origin' }); const result = await response.json(); if (result.success) { renderAdminStats(result.stats); @@ -2644,7 +3363,7 @@ async function loadAdminStats() { } async function loadAdminReports() { - const response = await fetch('?action=admin_get_reports'); + const response = await fetch(buildUrl({ action: 'admin_get_reports' }), { credentials: 'same-origin' }); const result = await response.json(); if (result.success) { renderReports(result.reports); @@ -2652,7 +3371,7 @@ async function loadAdminReports() { } async function loadAdminFlagged() { - const response = await fetch('?action=admin_get_flagged'); + const response = await fetch(buildUrl({ action: 'admin_get_flagged' }), { credentials: 'same-origin' }); const result = await response.json(); if (result.success) { renderFlagged(result.flagged); @@ -2660,7 +3379,7 @@ async function loadAdminFlagged() { } async function loadAdminBanned() { - const result = await fetch('?action=admin_get_banned_users'); + const result = await fetch(buildUrl({ action: 'admin_get_banned_users' }), { credentials: 'same-origin' }); const data = await result.json(); if (data.success) { renderBanned(data.banned); @@ -2743,7 +3462,7 @@ adminLoginForm.addEventListener('submit', async (e) => { formData.append('password', document.getElementById('adminPassword').value); try { - const response = await fetch('', { method: 'POST', body: formData }); + const response = await postFormData(formData); const result = await response.json(); if (result.success) { @@ -2759,10 +3478,50 @@ adminLoginForm.addEventListener('submit', async (e) => { }); -// REGISTRATION -document.getElementById('registerForm').addEventListener('submit', async (e) => { +// AUTH FORMS +const authToggleButtons = document.querySelectorAll('.auth-toggle-button'); +const registerForm = document.getElementById('registerForm'); +const loginForm = document.getElementById('loginForm'); +const registerErrorEl = document.getElementById('registerError'); +const loginErrorEl = document.getElementById('loginError'); +const loginTakeoverBox = document.getElementById('loginTakeoverBox'); +const loginTakeoverBtn = document.getElementById('loginTakeoverBtn'); +let lastLoginCredentials = null; +let isSubmittingLogin = false; + +function hideElement(el) { + if (!el) return; + el.style.display = 'none'; + el.textContent = ''; +} + +function showAuthView(view) { + if (view === 'login') { + registerForm?.classList.add('hidden'); + loginForm?.classList.remove('hidden'); + hideElement(registerErrorEl); + } else { + loginForm?.classList.add('hidden'); + registerForm?.classList.remove('hidden'); + hideElement(loginErrorEl); + if (loginTakeoverBox) { + loginTakeoverBox.style.display = 'none'; + } + } +} + +authToggleButtons.forEach(button => { + button.addEventListener('click', () => { + authToggleButtons.forEach(btn => btn.classList.toggle('active', btn === button)); + showAuthView(button.dataset.target === 'login' ? 'login' : 'register'); + }); +}); + +registerForm?.addEventListener('submit', async (e) => { e.preventDefault(); + hideElement(registerErrorEl); + const username = document.getElementById('username').value.trim(); const birthdate = document.getElementById('birthdate').value; const agreedTerms = document.getElementById('agreeTerms').checked; @@ -2771,24 +3530,99 @@ document.getElementById('registerForm').addEventListener('submit', async (e) => formData.append('action', 'register'); formData.append('username', username); formData.append('birthdate', birthdate); - formData.append('agreed_terms', agreedTerms); + formData.append('agreed_terms', agreedTerms ? 'true' : 'false'); try { - const response = await fetch('', { method: 'POST', body: formData }); + const response = await postFormData(formData); const result = await response.json(); if (result.success) { window.location.reload(); - } else { - document.getElementById('errorMessage').textContent = result.error; - document.getElementById('errorMessage').style.display = 'block'; + } else if (registerErrorEl) { + registerErrorEl.textContent = result.error || 'Registrierung fehlgeschlagen.'; + registerErrorEl.style.display = 'block'; } } catch (error) { - document.getElementById('errorMessage').textContent = 'Verbindungsfehler'; - document.getElementById('errorMessage').style.display = 'block'; + if (registerErrorEl) { + registerErrorEl.textContent = 'Verbindungsfehler'; + registerErrorEl.style.display = 'block'; + } } }); +async function submitLogin(force = false) { + if (!lastLoginCredentials || isSubmittingLogin) { + return; + } + + isSubmittingLogin = true; + + if (loginErrorEl) { + loginErrorEl.textContent = ''; + loginErrorEl.style.display = 'none'; + } + + if (loginTakeoverBox) { + loginTakeoverBox.style.display = 'none'; + } + + const formData = new FormData(); + formData.append('action', 'login'); + formData.append('username', lastLoginCredentials.username); + formData.append('birthdate', lastLoginCredentials.birthdate); + formData.append('force_login', force ? '1' : '0'); + + try { + const response = await postFormData(formData); + const result = await response.json(); + + if (result.success) { + window.location.reload(); + return; + } + + if (loginErrorEl) { + loginErrorEl.textContent = result.error || 'Anmeldung fehlgeschlagen.'; + loginErrorEl.style.display = 'block'; + } + + if (result.can_force && loginTakeoverBox) { + loginTakeoverBox.style.display = 'block'; + } + } catch (error) { + if (loginErrorEl) { + loginErrorEl.textContent = 'Verbindungsfehler'; + loginErrorEl.style.display = 'block'; + } + } finally { + isSubmittingLogin = false; + } +} + +loginForm?.addEventListener('submit', async (e) => { + e.preventDefault(); + + if (loginErrorEl) { + loginErrorEl.textContent = ''; + loginErrorEl.style.display = 'none'; + } + + if (loginTakeoverBox) { + loginTakeoverBox.style.display = 'none'; + } + + lastLoginCredentials = { + username: document.getElementById('loginUsername').value.trim(), + birthdate: document.getElementById('loginBirthdate').value + }; + + await submitLogin(false); +}); + +loginTakeoverBtn?.addEventListener('click', async () => { + await submitLogin(true); +}); + // CHAT INTERFACE const state = { @@ -2798,106 +3632,553 @@ 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'); +const attachmentButtonEl = document.getElementById('attachmentButton'); +const attachmentInputEl = document.getElementById('attachmentInput'); +const attachmentInfoEl = document.getElementById('attachmentInfo'); +const attachmentFileNameEl = document.getElementById('attachmentFileName'); +const attachmentClearBtnEl = document.getElementById('attachmentClearBtn'); +const attachmentWarningEl = document.getElementById('attachmentWarning'); +const ATTACHMENT_MAX_SIZE = 200 * 1024; +let messageAbortController = null; +let sseErrorCount = 0; +let usePollingFallback = false; +let pollingTimerId = null; +let isPollingUpdates = false; +const POLLING_INTERVAL_MS = 5000; - if (result.success) { - state.users = result.users; +function processIncomingMessages(messages) { + if (!Array.isArray(messages) || messages.length === 0) { + return; + } + + let shouldRender = false; + const markReadFor = new Set(); + + messages.forEach(msg => { + const messageId = Number(msg.id); + if (Number.isFinite(messageId) && messageId > state.lastMessageId) { + state.lastMessageId = messageId; + } + + const isRelevantChat = 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 (isRelevantChat) { + const alreadyExists = state.messages.some(existing => Number(existing.id) === messageId); + + if (!alreadyExists) { + state.messages.push(msg); + shouldRender = true; + + if (msg.to_user_id === state.currentUserId) { + markReadFor.add(msg.from_user_id); + } + } + } + }); + + if (shouldRender) { + renderMessages(); + } + + markReadFor.forEach(userId => markAsRead(userId)); + + if (messages.length > 0) { + loadUsers(); + } +} + +function stopPollingUpdates() { + if (pollingTimerId) { + clearInterval(pollingTimerId); + pollingTimerId = null; + } +} + +function startPollingUpdates() { + stopPollingUpdates(); + pollMessages(); + pollingTimerId = setInterval(pollMessages, POLLING_INTERVAL_MS); +} + +async function pollMessages() { + if (isPollingUpdates) { + return; + } + + isPollingUpdates = true; + + const formData = new FormData(); + formData.append('action', 'poll_updates'); + formData.append('last_message_id', state.lastMessageId); + + try { + const response = await postFormData(formData); + const result = await response.json(); + + if (result && result.success) { + const messages = Array.isArray(result.messages) ? result.messages : []; + if (typeof result.last_message_id === 'number') { + const newest = Number(result.last_message_id); + if (Number.isFinite(newest)) { + state.lastMessageId = Math.max(state.lastMessageId, newest); + } + } + processIncomingMessages(messages); + } + } catch (error) { + console.warn('Polling fehlgeschlagen:', error); + } finally { + isPollingUpdates = false; + } +} + +function enablePollingFallback() { + if (usePollingFallback) { + return; + } + + usePollingFallback = true; + + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + } + + if (!state.connectionErrorShown && state.selectedUserId && !state.isLoadingMessages) { + updateChatState('error', 'Live-Verbindung blockiert. Wechsel auf sichere Aktualisierung…'); + state.connectionErrorShown = true; + } + + startPollingUpdates(); +} + +function startRealtime() { + if (usePollingFallback) { + startPollingUpdates(); + } else { + startSSE(); + } +} + +async function loadUsers() { + if (!userListEl) { + return; + } + + state.isLoadingUsers = true; + renderUserList(); + + try { + const response = await fetch(buildUrl({ action: 'get_users' }), { credentials: 'same-origin' }); + 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'; + clearAttachmentSelection(); + clearAttachmentWarning(); - document.getElementById('chatMessagesHeader').innerHTML = ` -
${displayName.charAt(0).toUpperCase()}
-
${displayName}
- `; + if (chatWelcomeEl) { + chatWelcomeEl.style.display = 'none'; + } - loadMessages(userId); + if (chatMessagesContainerEl) { + chatMessagesContainerEl.style.display = 'flex'; + } + + 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(buildUrl({ action: 'get_messages', user_id: userId }), { + signal: currentController.signal, + credentials: 'same-origin' + }); + 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; const time = new Date(msg.timestamp).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + const hasText = typeof msg.message === 'string' && msg.message.trim() !== ''; + const attachmentUrl = msg.attachment_url; + + const textHtml = hasText ? `
${escapeHtml(msg.message)}
` : ''; + const attachmentHtml = attachmentUrl + ? `
Gesendetes Bild
` + : ''; return `
-
${escapeHtml(msg.message)}
+ ${attachmentHtml} + ${textHtml}
${time}
`; }).join(''); + container.classList.remove('hidden'); container.scrollTop = container.scrollHeight; + updateChatState(null); +} + +function clearAttachmentSelection() { + if (attachmentInputEl) { + attachmentInputEl.value = ''; + } + if (attachmentInfoEl) { + attachmentInfoEl.classList.add('hidden'); + } + if (attachmentFileNameEl) { + attachmentFileNameEl.textContent = ''; + } +} + +function showAttachmentWarning(message) { + if (attachmentWarningEl) { + attachmentWarningEl.textContent = message; + attachmentWarningEl.classList.remove('hidden'); + } else { + alert(message); + } +} + +function clearAttachmentWarning() { + if (attachmentWarningEl) { + attachmentWarningEl.textContent = ''; + attachmentWarningEl.classList.add('hidden'); + } } async function sendMessage() { - const input = document.getElementById('chatInput'); - const message = input.value.trim(); + if (!chatInputEl) { + return; + } - if (!message || !state.selectedUserId) return; + if (!state.selectedUserId) { + showAttachmentWarning('Bitte wähle zuerst einen Chat aus.'); + return; + } + + const message = chatInputEl.value.trim(); + const attachmentFile = attachmentInputEl?.files?.[0] || null; + + if (!message && !attachmentFile) { + showAttachmentWarning('Bitte gib eine Nachricht ein oder hänge ein JPG-Bild an.'); + return; + } + + clearAttachmentWarning(); + + if (attachmentFile) { + const fileType = (attachmentFile.type || '').toLowerCase(); + const fileName = attachmentFile.name || ''; + const isJpeg = /^image\/jpe?g$/.test(fileType) || /\.jpe?g$/i.test(fileName); + + if (!isJpeg) { + showAttachmentWarning('Nur JPG-Bilder sind erlaubt.'); + clearAttachmentSelection(); + return; + } + + if (attachmentFile.size > ATTACHMENT_MAX_SIZE) { + showAttachmentWarning('Bild ist zu groß (max. 200 KB).'); + clearAttachmentSelection(); + return; + } + } const formData = new FormData(); formData.append('action', 'send_message'); formData.append('to_user_id', state.selectedUserId); formData.append('message', message); - const response = await fetch('', { method: 'POST', body: formData }); - const result = await response.json(); + if (attachmentFile) { + formData.append('attachment', attachmentFile); + } - if (result.success) { - input.value = ''; - } else { - alert(result.error); + try { + const response = await postFormData(formData); + const result = await response.json(); + + if (result.success) { + chatInputEl.value = ''; + chatInputEl.dispatchEvent(new Event('input')); + clearAttachmentSelection(); + clearAttachmentWarning(); + } else { + showAttachmentWarning(result.error || 'Nachricht konnte nicht gesendet werden.'); + } + } catch (error) { + console.error('Nachricht konnte nicht gesendet werden:', error); + showAttachmentWarning('Nachricht konnte nicht gesendet werden.'); } } @@ -2906,39 +4187,90 @@ async function markAsRead(userId) { formData.append('action', 'mark_read'); formData.append('user_id', userId); - await fetch('', { method: 'POST', body: formData }); + await postFormData(formData); loadUsers(); } function startSSE() { - state.eventSource = new EventSource(`?stream=events&last_message_id=${state.lastMessageId}`); + stopPollingUpdates(); + + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + } + + const url = buildUrl({ stream: 'events', last_message_id: state.lastMessageId, t: Date.now() }); + + try { + state.eventSource = new EventSource(url); + } catch (error) { + console.warn('SSE kann nicht gestartet werden, wechsle auf Polling:', error); + enablePollingFallback(); + return; + } + + state.eventSource.onopen = () => { + sseErrorCount = 0; + 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) => { - const data = JSON.parse(event.data); + state.connectionErrorShown = false; - if (data.type === 'messages' && data.messages) { - data.messages.forEach(msg => { - if (msg.id > state.lastMessageId) { - state.lastMessageId = msg.id; - - 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)) { - state.messages.push(msg); - renderMessages(); - - if (msg.to_user_id === state.currentUserId) { - markAsRead(msg.from_user_id); - } - } - } - } - }); - - loadUsers(); + if (!event.data) { + return; } + + try { + const data = JSON.parse(event.data); + if (data.type === 'messages') { + processIncomingMessages(Array.isArray(data.messages) ? data.messages : []); + } + } catch (error) { + console.warn('Konnte SSE-Daten nicht verarbeiten:', error); + } + }; + + state.eventSource.onerror = () => { + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + } + + sseErrorCount += 1; + + 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 (usePollingFallback || sseErrorCount >= 3) { + enablePollingFallback(); + return; + } + + setTimeout(() => { + if (!usePollingFallback) { + startSSE(); + } + }, 500); }; } @@ -2948,22 +4280,75 @@ function escapeHtml(text) { return div.innerHTML; } -document.getElementById('sendButton').addEventListener('click', sendMessage); -document.getElementById('chatInput').addEventListener('keypress', (e) => { +function escapeAttribute(value) { + const div = document.createElement('div'); + div.textContent = value ?? ''; + return div.innerHTML.replace(/"/g, '"').replace(/'/g, '''); +} + +attachmentButtonEl?.addEventListener('click', () => { + attachmentInputEl?.click(); +}); + +attachmentInputEl?.addEventListener('change', () => { + clearAttachmentWarning(); + + if (!attachmentInputEl.files || attachmentInputEl.files.length === 0) { + clearAttachmentSelection(); + return; + } + + const file = attachmentInputEl.files[0]; + const fileType = (file.type || '').toLowerCase(); + const fileName = file.name || ''; + const isJpeg = /^image\/jpe?g$/.test(fileType) || /\.jpe?g$/i.test(fileName); + + if (!isJpeg) { + showAttachmentWarning('Nur JPG-Bilder sind erlaubt.'); + clearAttachmentSelection(); + return; + } + + if (file.size > ATTACHMENT_MAX_SIZE) { + showAttachmentWarning('Bild ist zu groß (max. 200 KB).'); + clearAttachmentSelection(); + return; + } + + if (attachmentInfoEl) { + attachmentInfoEl.classList.remove('hidden'); + } + + if (attachmentFileNameEl) { + const sizeKb = Math.max(1, Math.round(file.size / 1024)); + attachmentFileNameEl.textContent = `${file.name} (${sizeKb} KB)`; + } +}); + +attachmentClearBtnEl?.addEventListener('click', () => { + clearAttachmentSelection(); + clearAttachmentWarning(); +}); + +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 }); + await postFormData(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'; }); @@ -2971,11 +4356,11 @@ document.getElementById('chatInput').addEventListener('input', function() { setInterval(async () => { const formData = new FormData(); formData.append('action', 'ping'); - await fetch('', { method: 'POST', body: formData }); + await postFormData(formData); }, 10000); loadUsers(); -startSSE(); +startRealtime(); setInterval(loadUsers, 30000); From 85c50075fbf3eaa786555fd734bd512394112a6e Mon Sep 17 00:00:00 2001 From: Metacube Date: Mon, 3 Nov 2025 21:33:17 +0100 Subject: [PATCH 04/10] Add TinyHome landing page with upload and contact form --- tiny.php | 454 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 tiny.php diff --git a/tiny.php b/tiny.php new file mode 100644 index 0000000..a357b26 --- /dev/null +++ b/tiny.php @@ -0,0 +1,454 @@ +file($file['tmp_name']); + $allowedTypes = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/gif' => 'gif' + ]; + + if (!array_key_exists($mimeType, $allowedTypes)) { + $errors[] = 'Die Datei "' . htmlspecialchars($file['name']) . '" ist kein unterstütztes Bildformat.'; + return null; + } + + ensureDirectory($targetDir); + $extension = $allowedTypes[$mimeType]; + $filename = sanitizeFileName(pathinfo($file['name'], PATHINFO_FILENAME)); + if ($filename === '') { + $filename = 'upload_' . time(); + } + $destination = rtrim($targetDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename . '_' . uniqid() . '.' . $extension; + + if (!move_uploaded_file($file['tmp_name'], $destination)) { + $errors[] = 'Die Datei "' . htmlspecialchars($file['name']) . '" konnte nicht gespeichert werden.'; + return null; + } + + return [ + 'path' => $destination, + 'original' => $file['name'], + 'type' => $mimeType + ]; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $company = trim($_POST['company'] ?? ''); + $contactEmail = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL) ? $_POST['email'] : ''; + $website = trim($_POST['website'] ?? ''); + $message = trim($_POST['message'] ?? ''); + $bannerLink = trim($_POST['banner_link'] ?? ''); + + if ($company === '') { + $errors[] = 'Bitte geben Sie den Namen Ihres Unternehmens oder Projekts an.'; + } + + if ($contactEmail === '') { + $errors[] = 'Bitte geben Sie eine gültige E-Mail-Adresse an.'; + } + + if ($bannerLink === '') { + $errors[] = 'Bitte geben Sie den Link an, der Ihrem Banner zugeordnet werden soll.'; + } + + $uploadedImage = handleUpload('project_image', __DIR__ . '/uploads/images', $errors); + $uploadedBanner = handleUpload('banner_image', __DIR__ . '/uploads/banners', $errors); + + if (!$errors) { + $boundary = '=_TinyHome_' . md5((string) microtime(true)); + $headers = []; + $fromAddress = $contactEmail ?: 'no-reply@tinyhome.local'; + $headers[] = 'From: ' . $fromAddress; + $headers[] = 'Reply-To: ' . $fromAddress; + $headers[] = 'MIME-Version: 1.0'; + $headers[] = 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; + + $bodyParts = []; + $text = "Neue TinyHome-Anfrage\n\n"; + $text .= "Unternehmen/Projekt: $company\n"; + $text .= "Kontakt-E-Mail: $contactEmail\n"; + if ($website !== '') { + $text .= "Website: $website\n"; + } + $text .= "Banner-Link: $bannerLink\n\n"; + if ($message !== '') { + $text .= "Nachricht:\n$message\n\n"; + } + if ($uploadedImage) { + $text .= 'Projektbild gespeichert unter: ' . $uploadedImage['path'] . "\n"; + } + if ($uploadedBanner) { + $text .= 'Banner gespeichert unter: ' . $uploadedBanner['path'] . "\n"; + } + + $bodyParts[] = '--' . $boundary; + $bodyParts[] = 'Content-Type: text/plain; charset="UTF-8"'; + $bodyParts[] = 'Content-Transfer-Encoding: 8bit'; + $bodyParts[] = ''; + $bodyParts[] = $text; + + foreach ([$uploadedImage, $uploadedBanner] as $upload) { + if (!$upload) { + continue; + } + $fileContent = file_get_contents($upload['path']); + if ($fileContent === false) { + $errors[] = 'Die Datei "' . htmlspecialchars($upload['original']) . '" konnte nicht für den Mailversand gelesen werden.'; + continue; + } + $bodyParts[] = '--' . $boundary; + $bodyParts[] = 'Content-Type: ' . $upload['type'] . '; name="' . sanitizeFileName($upload['original']) . '"'; + $bodyParts[] = 'Content-Transfer-Encoding: base64'; + $bodyParts[] = 'Content-Disposition: attachment; filename="' . sanitizeFileName($upload['original']) . '"'; + $bodyParts[] = ''; + $bodyParts[] = chunk_split(base64_encode($fileContent)); + } + + $bodyParts[] = '--' . $boundary . '--'; + $body = implode("\r\n", $bodyParts); + + if (!$errors) { + $mailSent = mail( + $recipient, + 'Neue TinyHome-Anfrage von ' . $company, + $body, + implode("\r\n", $headers) + ); + + if ($mailSent) { + $successMessage = 'Vielen Dank! Ihre Anfrage wurde erfolgreich übermittelt.'; + } else { + $errors[] = 'Ihre Anfrage konnte leider nicht versendet werden. Bitte versuchen Sie es später erneut.'; + } + } + } +} +?> + + + + + + TinyHome Plattform – Natürlich. Minimalistisch. Erschwinglich. + + + +
+
+

TinyHome Plattform

+

Minimalistisches Wohnen im Einklang mit der Natur – wir verbinden Hersteller, Planer:innen und Menschen, die erschwingliche, hochwertige Tiny Houses suchen.

+ Jetzt Projekt einreichen +
+
+
+
+

Warum TinyHomes?

+
+
+

Naturnah leben

+

Unsere Plattform bündelt Anbieter, die nachhaltige Materialien und ökologische Bauweisen priorisieren.

+
+
+

Minimale Baukosten

+

Smarter Grundriss, effiziente Energie, faire Preise: TinyHomes machen Wohnen wieder erschwinglich.

+
+
+

Flexibel kombinierbar

+

Vom Wochenend-Retreat bis zum ganzjährigen Zuhause – konfigurieren Sie Ihr TinyHome passend zu Ihrem Lebensstil.

+
+
+

Direkter Kontakt

+

Sie erhalten maßgeschneiderte Angebote von geprüften Hersteller:innen aus der DACH-Region.

+
+
+
+ +
+

Projekt vorstellen & Banner übermitteln

+

Übermitteln Sie uns Ihr TinyHome-Konzept – inklusive Banner-Link, damit wir Bestellungen eindeutig zuordnen können. Wir melden uns persönlich bei Ihnen.

+ + +
+ + + +
+
    + +
  • + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ +
+ + From 83262dd020c47b3a380c51fd7d6991400dab12cc Mon Sep 17 00:00:00 2001 From: Metacube Date: Mon, 3 Nov 2025 21:33:59 +0100 Subject: [PATCH 05/10] write.php repair --- write.php | 417 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 259 insertions(+), 158 deletions(-) diff --git a/write.php b/write.php index 28addb9..3c91981 100644 --- a/write.php +++ b/write.php @@ -35,6 +35,13 @@ define('MAX_MESSAGES_PER_FETCH', 200); define('MAX_MESSAGES_PER_MINUTE', 10); define('MAX_MESSAGES_PER_HOUR', 100); define('MAX_MESSAGES_PER_DAY_U18', 50); +define('UPLOAD_DIR', __DIR__ . '/uploads'); +define('MAX_ATTACHMENT_SIZE', 200 * 1024); // 200 KB + +// Upload-Verzeichnis erstellen +if (!is_dir(UPLOAD_DIR)) { + mkdir(UPLOAD_DIR, 0755, true); +} // Admin Credentials (BITTE ÄNDERN!) define('ADMIN_USERNAME', 'admin'); @@ -89,21 +96,28 @@ function getDB() { ) '); + + + // Messages Table - $db->exec(' - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - from_user_id INTEGER NOT NULL, - to_user_id INTEGER NOT NULL, - message TEXT NOT NULL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - is_read INTEGER DEFAULT 0, - is_flagged INTEGER DEFAULT 0, - flag_reason TEXT, - FOREIGN KEY (from_user_id) REFERENCES users(id), - FOREIGN KEY (to_user_id) REFERENCES users(id) - ) - '); +$db->exec(' + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_user_id INTEGER NOT NULL, + to_user_id INTEGER NOT NULL, + message TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + is_read INTEGER DEFAULT 0, + is_flagged INTEGER DEFAULT 0, + flag_reason TEXT, + attachment_path TEXT, + attachment_type TEXT, + attachment_size INTEGER, + FOREIGN KEY (from_user_id) REFERENCES users(id), + FOREIGN KEY (to_user_id) REFERENCES users(id) + ) +'); + // Online Status Table $db->exec(' @@ -187,23 +201,21 @@ function getDB() { '); // Create default admin if not exists - $stmt = $db->prepare('SELECT COUNT(*) as count FROM admins WHERE username = :username'); - $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); - $result = $stmt->execute(); - $row = $result->fetchArray(SQLITE3_ASSOC); - - if ($row['count'] == 0) { - $stmt = $db->prepare('INSERT INTO admins (username, password_hash) VALUES (:username, :password)'); - $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); - $result = $stmt->execute(); - $row = $result->fetchArray(SQLITE3_ASSOC); + // Admin-Account erstellen (nur einmal!) +$stmt = $db->prepare('SELECT COUNT(*) as count FROM admins WHERE username = :username'); +$stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); +$result = $stmt->execute(); +$row = $result->fetchArray(SQLITE3_ASSOC); - if ($row['count'] == 0) { - $stmt = $db->prepare('INSERT INTO admins (username, password_hash) VALUES (:username, :password)'); - $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); - $stmt->bindValue(':password', ADMIN_PASSWORD, SQLITE3_TEXT); - $stmt->execute(); - } +if ($row['count'] == 0) { + $stmt = $db->prepare('INSERT INTO admins (username, password_hash) VALUES (:username, :password)'); + $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); + $stmt->bindValue(':password', ADMIN_PASSWORD, SQLITE3_TEXT); + $stmt->execute(); +} + + + // Create indexes $db->exec('CREATE INDEX IF NOT EXISTS idx_messages_users ON messages(from_user_id, to_user_id)'); @@ -1830,8 +1842,16 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') { echo "retry: " . SSE_RETRY_MS . "\n\n"; flush(); - $db = getDB(); + $lastPingTime = time(); + // ✅ ENDLOSSCHLEIFE HINZUFÜGEN! + while (true) { + if (connection_aborted()) { + break; + } + + $db = getDB(); + $stmt = $db->prepare(' SELECT m.id, @@ -1846,55 +1866,67 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') { uf.user_id as from_display_id, uf.age_group as from_age_group, ut.age_group as to_age_group - FROM messages m - JOIN users uf ON m.from_user_id = uf.id - JOIN users ut ON m.to_user_id = ut.id - WHERE m.id > :last_message_id - AND (m.to_user_id = :current_user_id OR m.from_user_id = :current_user_id) - AND NOT EXISTS ( - SELECT 1 FROM blocks - WHERE (blocker_id = :current_user_id AND blocked_id = m.from_user_id) - OR (blocker_id = m.from_user_id AND blocked_id = :current_user_id) - ) - ORDER BY m.id ASC - '); - $stmt->bindValue(':last_message_id', $lastMessageId, SQLITE3_INTEGER); - $stmt->bindValue(':current_user_id', $currentUserId, SQLITE3_INTEGER); - $result = $stmt->execute(); - - $messages = []; - while ($row = $result->fetchArray(SQLITE3_ASSOC)) { - $otherAgeGroup = $row['from_user_id'] === $currentUserId ? $row['to_age_group'] : $row['from_age_group']; + FROM messages m + JOIN users uf ON m.from_user_id = uf.id + JOIN users ut ON m.to_user_id = ut.id + WHERE m.id > :last_message_id + AND (m.to_user_id = :current_user_id OR m.from_user_id = :current_user_id) + AND NOT EXISTS ( + SELECT 1 FROM blocks + WHERE (blocker_id = :current_user_id AND blocked_id = m.from_user_id) + OR (blocker_id = m.from_user_id AND blocked_id = :current_user_id) + ) + ORDER BY m.id ASC + '); + $stmt->bindValue(':last_message_id', $lastMessageId, SQLITE3_INTEGER); + $stmt->bindValue(':current_user_id', $currentUserId, SQLITE3_INTEGER); + $result = $stmt->execute(); + + $messages = []; + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $otherAgeGroup = $row['from_user_id'] === $currentUserId ? $row['to_age_group'] : $row['from_age_group']; - if (!canUsersChatByAge($currentAgeGroup, $otherAgeGroup)) { - continue; + if (!canUsersChatByAge($currentAgeGroup, $otherAgeGroup)) { + continue; + } + + $messages[] = [ + 'id' => $row['id'], + 'from_user_id' => $row['from_user_id'], + 'to_user_id' => $row['to_user_id'], + 'message' => $row['message'], + 'timestamp' => $row['timestamp'], + 'attachment_url' => $row['attachment_path'] ?: null, + 'attachment_type' => $row['attachment_type'] ?: null, + 'attachment_size' => $row['attachment_size'] !== null ? (int)$row['attachment_size'] : null, + 'from_username' => $row['from_username'], + 'from_display_name' => $row['from_username'] . '#' . $row['from_display_id'] + ]; + + $lastMessageId = max($lastMessageId, (int)$row['id']); } - - $messages[] = [ - 'id' => $row['id'], - 'from_user_id' => $row['from_user_id'], - 'to_user_id' => $row['to_user_id'], - 'message' => $row['message'], - 'timestamp' => $row['timestamp'], - 'attachment_url' => $row['attachment_path'] ?: null, - 'attachment_type' => $row['attachment_type'] ?: null, - 'attachment_size' => $row['attachment_size'] !== null ? (int)$row['attachment_size'] : null, - 'from_username' => $row['from_username'], - 'from_display_name' => $row['from_username'] . '#' . $row['from_display_id'] - ]; - } - - if (!empty($messages)) { - echo "data: " . json_encode(['type' => 'messages', 'messages' => $messages]) . "\n\n"; - flush(); - } else { - echo "data: " . json_encode(['type' => 'ping']) . "\n\n"; - flush(); + + if (!empty($messages)) { + echo "data: " . json_encode(['type' => 'messages', 'messages' => $messages]) . "\n\n"; + flush(); + } + + // Ping alle 15 Sekunden + if (time() - $lastPingTime >= 15) { + echo "data: " . json_encode(['type' => 'ping']) . "\n\n"; + flush(); + $lastPingTime = time(); + touchUserSession($currentUserId); + } + + // Kurze Pause, um CPU zu schonen + usleep(500000); // 0.5 Sekunden } exit; } + // ═══════════════════════════════════════════════════════════ // HTML OUTPUT // ═══════════════════════════════════════════════════════════ @@ -3803,7 +3835,6 @@ function selectUser(userId, displayName) { renderUserList(); loadMessages(userId); } - async function loadMessages(userId) { if (!userId) { return; @@ -3813,12 +3844,18 @@ async function loadMessages(userId) { updateChatState('loading', 'Nachrichten werden geladen…'); try { - const response = await fetch(`?action=get_messages&user_id=${userId}`); + const response = await fetch(buildUrl({ action: 'get_messages', user_id: userId })); + if (!response.ok) { throw new Error('NETZWERK_FEHLER'); } - const result = await response.json(); + const text = await response.text(); + if (!text || text.trim() === '') { + throw new Error('Leere Antwort vom Server'); + } + + const result = JSON.parse(text); if (!result.success) { throw new Error(result.error || 'Nachrichten konnten nicht geladen werden.'); @@ -3852,6 +3889,7 @@ async function loadMessages(userId) { } } + function renderMessages() { const container = chatMessagesEl; @@ -3889,50 +3927,127 @@ function renderMessages() { updateChatState(null); } -async function sendMessage() { - if (!chatInputEl) { + + +let attachmentFile = null; +const ATTACHMENT_MAX_SIZE = 200 * 1024; // 200 KB + +const attachmentButton = document.getElementById('attachmentButton'); +const attachmentInput = document.getElementById('attachmentInput'); +const attachmentInfo = document.getElementById('attachmentInfo'); +const attachmentFileName = document.getElementById('attachmentFileName'); +const attachmentClearBtn = document.getElementById('attachmentClearBtn'); +const attachmentWarning = document.getElementById('attachmentWarning'); + +function escapeAttribute(text) { + const div = document.createElement('div'); + div.textContent = text ?? ''; + return div.innerHTML.replace(/"/g, '"'); +} + +function showAttachmentWarning(message) { + if (attachmentWarning) { + attachmentWarning.textContent = message; + attachmentWarning.classList.remove('hidden'); + } +} + +function clearAttachmentWarning() { + if (attachmentWarning) { + attachmentWarning.textContent = ''; + attachmentWarning.classList.add('hidden'); + } +} + +function clearAttachmentSelection() { + attachmentFile = null; + if (attachmentInput) attachmentInput.value = ''; + if (attachmentInfo) attachmentInfo.classList.add('hidden'); + if (attachmentFileName) attachmentFileName.textContent = ''; + clearAttachmentWarning(); +} + +attachmentButton?.addEventListener('click', () => { + attachmentInput?.click(); +}); + +attachmentInput?.addEventListener('change', (e) => { + const file = e.target.files?.[0]; + if (!file) { + clearAttachmentSelection(); return; } - - const message = chatInputEl.value.trim(); - - clearAttachmentWarning(); - - if (attachmentFile) { - const fileType = (attachmentFile.type || '').toLowerCase(); - const fileName = attachmentFile.name || ''; - const isJpeg = /^image\/jpe?g$/.test(fileType) || /\.jpe?g$/i.test(fileName); - - if (!isJpeg) { - showAttachmentWarning('Nur JPG-Bilder sind erlaubt.'); - clearAttachmentSelection(); - return; - } - - if (attachmentFile.size > ATTACHMENT_MAX_SIZE) { - showAttachmentWarning('Bild ist zu groß (max. 200 KB).'); - clearAttachmentSelection(); - return; - } + + const fileType = (file.type || '').toLowerCase(); + const fileName = file.name || ''; + const isJpeg = /^image\/jpe?g$/.test(fileType) || /\.jpe?g$/i.test(fileName); + + if (!isJpeg) { + showAttachmentWarning('Nur JPG-Bilder sind erlaubt.'); + clearAttachmentSelection(); + return; } + + if (file.size > ATTACHMENT_MAX_SIZE) { + showAttachmentWarning('Bild ist zu groß (max. 200 KB).'); + clearAttachmentSelection(); + return; + } + + attachmentFile = file; + if (attachmentFileName) { + attachmentFileName.textContent = fileName; + } + if (attachmentInfo) { + attachmentInfo.classList.remove('hidden'); + } + clearAttachmentWarning(); +}); +attachmentClearBtn?.addEventListener('click', clearAttachmentSelection); + + +async function sendMessage() { + if (!chatInputEl) return; + + const message = chatInputEl.value.trim(); + + if (!message && !attachmentFile) { + return; + } + + if (!state.selectedUserId) { + alert('Bitte wähle einen Chat-Partner aus'); + return; + } + const formData = new FormData(); formData.append('action', 'send_message'); formData.append('to_user_id', state.selectedUserId); formData.append('message', message); - + if (attachmentFile) { formData.append('attachment', attachmentFile); } - - if (result.success) { - chatInputEl.value = ''; - chatInputEl.dispatchEvent(new Event('input')); - } else { - alert(result.error); + + try { + const response = await postFormData(formData); + const result = await response.json(); + + if (result.success) { + chatInputEl.value = ''; + chatInputEl.style.height = 'auto'; + clearAttachmentSelection(); + // Nachricht wird via SSE empfangen + } else { + alert(result.error || 'Nachricht konnte nicht gesendet werden'); + } + } catch (error) { + alert('Verbindungsfehler beim Senden'); } } + async function markAsRead(userId) { const formData = new FormData(); formData.append('action', 'mark_read'); @@ -3941,87 +4056,73 @@ async function markAsRead(userId) { await postFormData(formData); loadUsers(); } - function startSSE() { if (state.eventSource) { state.eventSource.close(); } - const url = `?stream=events&last_message_id=${state.lastMessageId}&t=${Date.now()}`; + const url = buildUrl({ + stream: 'events', + last_message_id: state.lastMessageId + }); + 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 => { - 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 => Number(m.id) === messageId)) { - state.messages.push(msg); - renderMessages(); - - if (msg.to_user_id === state.currentUserId) { - markAsRead(msg.from_user_id); + try { + const data = JSON.parse(event.data); + if (data.type === 'messages' && Array.isArray(data.messages)) { + data.messages.forEach(msg => { + const messageId = Number(msg.id); + + if (messageId > state.lastMessageId) { + state.lastMessageId = messageId; + + const isRelevant = 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 (isRelevant) { + const exists = state.messages.some(m => Number(m.id) === messageId); + if (!exists) { + state.messages.push(msg); + renderMessages(); + + if (msg.to_user_id === state.currentUserId) { + markAsRead(msg.from_user_id); + } } } } - } - }); - - try { - const data = JSON.parse(event.data); - if (data.type === 'messages') { - processIncomingMessages(Array.isArray(data.messages) ? data.messages : []); + }); + + loadUsers(); // Aktualisiere Nutzerliste } } catch (error) { - console.warn('Konnte SSE-Daten nicht verarbeiten:', error); + console.warn('SSE-Daten konnten nicht verarbeitet werden:', error); } }; 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…'); - } + console.warn('SSE-Verbindung unterbrochen'); } - + if (state.eventSource) { state.eventSource.close(); } - - setTimeout(startSSE, 1500); + + setTimeout(startSSE, 2000); }; } + function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text ?? ''; @@ -4058,7 +4159,7 @@ setInterval(async () => { }, 10000); loadUsers(); -startRealtime(); +startSSE(); setInterval(loadUsers, 30000); From 234f37c5e348831b053aff11f88dbfe4dce86828 Mon Sep 17 00:00:00 2001 From: Metacube Date: Tue, 4 Nov 2025 10:04:19 +0100 Subject: [PATCH 06/10] Delete write.php --- write.php | 4168 ----------------------------------------------------- 1 file changed, 4168 deletions(-) delete mode 100644 write.php diff --git a/write.php b/write.php deleted file mode 100644 index 3c91981..0000000 --- a/write.php +++ /dev/null @@ -1,4168 +0,0 @@ -busyTimeout(5000); - - // Users Table (mit Geburtsdatum und User-ID) - $db->exec(' - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL, - user_id TEXT UNIQUE NOT NULL, - birthdate DATE NOT NULL, - age_group TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_seen DATETIME DEFAULT CURRENT_TIMESTAMP, - is_banned INTEGER DEFAULT 0, - ban_reason TEXT - ) - '); - - - - - // Messages Table -$db->exec(' - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - from_user_id INTEGER NOT NULL, - to_user_id INTEGER NOT NULL, - message TEXT NOT NULL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - is_read INTEGER DEFAULT 0, - is_flagged INTEGER DEFAULT 0, - flag_reason TEXT, - attachment_path TEXT, - attachment_type TEXT, - attachment_size INTEGER, - FOREIGN KEY (from_user_id) REFERENCES users(id), - FOREIGN KEY (to_user_id) REFERENCES users(id) - ) -'); - - - // Online Status Table - $db->exec(' - CREATE TABLE IF NOT EXISTS online_status ( - user_id INTEGER PRIMARY KEY, - last_ping DATETIME DEFAULT CURRENT_TIMESTAMP, - 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 ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - reporter_id INTEGER NOT NULL, - reported_user_id INTEGER NOT NULL, - reason TEXT NOT NULL, - message_id INTEGER, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - status TEXT DEFAULT "pending", - FOREIGN KEY (reporter_id) REFERENCES users(id), - FOREIGN KEY (reported_user_id) REFERENCES users(id), - FOREIGN KEY (message_id) REFERENCES messages(id) - ) - '); - - // Blocks Table - $db->exec(' - CREATE TABLE IF NOT EXISTS blocks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - blocker_id INTEGER NOT NULL, - blocked_id INTEGER NOT NULL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (blocker_id) REFERENCES users(id), - FOREIGN KEY (blocked_id) REFERENCES users(id), - UNIQUE(blocker_id, blocked_id) - ) - '); - - // Rate Limiting Table - $db->exec(' - CREATE TABLE IF NOT EXISTS rate_limits ( - user_id INTEGER NOT NULL, - action_type TEXT NOT NULL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) - ) - '); - - // Logs Table (für Behörden) - $db->exec(' - CREATE TABLE IF NOT EXISTS security_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, - action TEXT NOT NULL, - details TEXT, - ip_address TEXT, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) - ) - '); - - // Admin Table - $db->exec(' - CREATE TABLE IF NOT EXISTS admins ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - '); - - // Create default admin if not exists - // Admin-Account erstellen (nur einmal!) -$stmt = $db->prepare('SELECT COUNT(*) as count FROM admins WHERE username = :username'); -$stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); -$result = $stmt->execute(); -$row = $result->fetchArray(SQLITE3_ASSOC); - -if ($row['count'] == 0) { - $stmt = $db->prepare('INSERT INTO admins (username, password_hash) VALUES (:username, :password)'); - $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); - $stmt->bindValue(':password', ADMIN_PASSWORD, SQLITE3_TEXT); - $stmt->execute(); -} - - - - - // Create indexes - $db->exec('CREATE INDEX IF NOT EXISTS idx_messages_users ON messages(from_user_id, to_user_id)'); - $db->exec('CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)'); - $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)'); - - $initialized = true; - } - - // Create indexes - $db->exec('CREATE INDEX IF NOT EXISTS idx_messages_users ON messages(from_user_id, to_user_id)'); - $db->exec('CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)'); - $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; -} - -// ═══════════════════════════════════════════════════════════ -// SECURITY FUNCTIONS -// ═══════════════════════════════════════════════════════════ - -function generateUserId() { - return str_pad(mt_rand(1000, 9999), 4, '0', STR_PAD_LEFT); -} - -function calculateAge($birthdate) { - $birth = new DateTime($birthdate); - $today = new DateTime(); - return $birth->diff($today)->y; -} - -function getAgeGroup($birthdate) { - $age = calculateAge($birthdate); - return $age < 18 ? 'U18' : 'O18'; -} - -function checkKeywordBlacklist($message) { - global $KEYWORD_BLACKLIST; - - $message_lower = mb_strtolower($message, 'UTF-8'); - - foreach ($KEYWORD_BLACKLIST as $keyword) { - if (stripos($message_lower, $keyword) !== false) { - return ['blocked' => true, 'keyword' => $keyword]; - } - } - - return ['blocked' => false]; -} - -function checkProfanityFilter($message) { - global $PROFANITY_FILTER; - - $message_lower = mb_strtolower($message, 'UTF-8'); - - foreach ($PROFANITY_FILTER as $word) { - if (stripos($message_lower, $word) !== false) { - return ['blocked' => true, 'word' => $word]; - } - } - - return ['blocked' => false]; -} - -function checkLinkFilter($message) { - $patterns = [ - '/https?:\/\//i', - '/www\./i', - '/[a-z0-9-]+\.(com|de|ch|net|org|info)/i' - ]; - - foreach ($patterns as $pattern) { - if (preg_match($pattern, $message)) { - return ['blocked' => true]; - } - } - - return ['blocked' => false]; -} - -function checkRateLimit($userId, $ageGroup) { - $db = getDB(); - - // Check messages per minute - $stmt = $db->prepare(" - SELECT COUNT(*) as count - FROM rate_limits - WHERE user_id = :user_id - AND action_type = 'message' - AND timestamp > datetime('now', '-1 minute') - "); - $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); - $result = $stmt->execute(); - $row = $result->fetchArray(SQLITE3_ASSOC); - - if ($row['count'] >= MAX_MESSAGES_PER_MINUTE) { - return ['allowed' => false, 'reason' => 'Zu viele Nachrichten pro Minute (max ' . MAX_MESSAGES_PER_MINUTE . ')']; - } - - // Check messages per hour - $stmt = $db->prepare(" - SELECT COUNT(*) as count - FROM rate_limits - WHERE user_id = :user_id - AND action_type = 'message' - AND timestamp > datetime('now', '-1 hour') - "); - $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); - $result = $stmt->execute(); - $row = $result->fetchArray(SQLITE3_ASSOC); - - if ($row['count'] >= MAX_MESSAGES_PER_HOUR) { - return ['allowed' => false, 'reason' => 'Zu viele Nachrichten pro Stunde (max ' . MAX_MESSAGES_PER_HOUR . ')']; - } - - // Check messages per day for U18 - if ($ageGroup === 'U18') { - $stmt = $db->prepare(" - SELECT COUNT(*) as count - FROM rate_limits - WHERE user_id = :user_id - AND action_type = 'message' - AND timestamp > datetime('now', '-1 day') - "); - $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); - $result = $stmt->execute(); - $row = $result->fetchArray(SQLITE3_ASSOC); - - if ($row['count'] >= MAX_MESSAGES_PER_DAY_U18) { - return ['allowed' => false, 'reason' => 'Tages-Limit erreicht (max ' . MAX_MESSAGES_PER_DAY_U18 . ' für U18)']; - } - } - - return ['allowed' => true]; -} - -function logRateLimit($userId) { - $db = getDB(); - $stmt = $db->prepare("INSERT INTO rate_limits (user_id, action_type) VALUES (:user_id, 'message')"); - $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); - $stmt->execute(); -} - -function logSecurityEvent($userId, $action, $details = '') { - $db = getDB(); - $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; - - $stmt = $db->prepare(' - INSERT INTO security_logs (user_id, action, details, ip_address) - VALUES (:user_id, :action, :details, :ip) - '); - $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); - $stmt->bindValue(':action', $action, SQLITE3_TEXT); - $stmt->bindValue(':details', $details, SQLITE3_TEXT); - $stmt->bindValue(':ip', $ip, SQLITE3_TEXT); - $stmt->execute(); -} - -function canUsersChatByAge($ageGroupA, $ageGroupB) { - if (!$ageGroupA || !$ageGroupB) { - return false; - } - - // Wenn einer minderjährig ist, müssen beide minderjährig sein - if ($ageGroupA === 'U18' || $ageGroupB === 'U18') { - return $ageGroupA === 'U18' && $ageGroupB === 'U18'; - } - - // Volljährige dürfen miteinander chatten - return true; -} - -function isBlocked($userId, $otherUserId) { - $db = getDB(); - $stmt = $db->prepare(' - SELECT COUNT(*) as count - FROM blocks - WHERE (blocker_id = :user1 AND blocked_id = :user2) - OR (blocker_id = :user2 AND blocked_id = :user1) - '); - $stmt->bindValue(':user1', $userId, SQLITE3_INTEGER); - $stmt->bindValue(':user2', $otherUserId, SQLITE3_INTEGER); - $result = $stmt->execute(); - $row = $result->fetchArray(SQLITE3_ASSOC); - - return $row['count'] > 0; -} - -function cleanupOldData() { - $db = getDB(); - - // Delete old messages - $hours = MESSAGE_RETENTION_HOURS; - $attachmentResult = $db->query("SELECT attachment_path FROM messages WHERE attachment_path IS NOT NULL AND timestamp < datetime('now', '-{$hours} hours')"); - while ($attachmentRow = $attachmentResult->fetchArray(SQLITE3_ASSOC)) { - $relativePath = $attachmentRow['attachment_path'] ?? ''; - if (!$relativePath) { - continue; - } - - $normalizedPath = str_replace('\\', '/', $relativePath); - if (strpos($normalizedPath, 'uploads/') !== 0) { - continue; - } - - $fullPath = __DIR__ . '/' . $normalizedPath; - if (is_file($fullPath)) { - @unlink($fullPath); - } - } - - $db->exec("DELETE FROM messages WHERE timestamp < datetime('now', '-{$hours} hours')"); - - // Delete old rate limits - $db->exec("DELETE FROM rate_limits WHERE timestamp < datetime('now', '-1 day')"); - - // 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')"); -} - -// ═══════════════════════════════════════════════════════════ -// SESSION & AUTH -// ═══════════════════════════════════════════════════════════ - -session_start(); -$isAdminPage = isset($_GET['admin']); - -function isLoggedIn() { - return isset($_SESSION['user_id']) && isset($_SESSION['username']); -} - -function isAdmin() { - return isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true; -} - -function getCurrentUserId() { - return $_SESSION['user_id'] ?? null; -} - -function getCurrentUsername() { - return $_SESSION['username'] ?? null; -} - -function getCurrentUserDisplayName() { - if (!isLoggedIn()) return null; - return $_SESSION['username'] . '#' . $_SESSION['user_display_id']; -} - -function getCurrentAgeGroup() { - return $_SESSION['age_group'] ?? null; -} - -function updateOnlineStatus($userId) { - $db = getDB(); - $stmt = $db->prepare(' - INSERT OR REPLACE INTO online_status (user_id, last_ping) - VALUES (:user_id, CURRENT_TIMESTAMP) - '); - $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; -} - -// ═══════════════════════════════════════════════════════════ -// AJAX API HANDLER -// ═══════════════════════════════════════════════════════════ - -if (isset($_POST['action']) || isset($_GET['action'])) { - header('Content-Type: application/json'); - - $action = $_POST['action'] ?? $_GET['action']; - - // ─────────────────────────────────────────────────────── - // REGISTER - // ─────────────────────────────────────────────────────── - if ($action === 'register') { - $username = trim($_POST['username'] ?? ''); - $birthdate = trim($_POST['birthdate'] ?? ''); - $agreed_terms = isset($_POST['agreed_terms']) && $_POST['agreed_terms'] === 'true'; - - // Validierung - if (empty($username) || empty($birthdate)) { - echo json_encode(['success' => false, 'error' => 'Username und Geburtsdatum erforderlich']); - exit; - } - - if (!$agreed_terms) { - echo json_encode(['success' => false, 'error' => 'Bitte akzeptiere die Nutzungsbedingungen']); - exit; - } - - if (strlen($username) < 3 || strlen($username) > 15) { - echo json_encode(['success' => false, 'error' => 'Username muss 3-15 Zeichen lang sein']); - exit; - } - - if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) { - echo json_encode(['success' => false, 'error' => 'Nur Buchstaben, Zahlen und _ erlaubt']); - exit; - } - - // Verbotene Usernamen - $forbidden = ['admin', 'moderator', 'support', 'system', 'root']; - if (in_array(strtolower($username), $forbidden)) { - echo json_encode(['success' => false, 'error' => 'Dieser Username ist nicht erlaubt']); - exit; - } - - // Alter prüfen - $age = calculateAge($birthdate); - if ($age < 13) { - echo json_encode(['success' => false, 'error' => 'Du musst mindestens 13 Jahre alt sein']); - logSecurityEvent(null, 'REGISTER_UNDERAGE', "Username: $username, Age: $age"); - exit; - } - - $ageGroup = getAgeGroup($birthdate); - - $db = getDB(); - - // Generate unique user_id - $userId = generateUserId(); - $attempts = 0; - while ($attempts < 10) { - $stmt = $db->prepare('SELECT COUNT(*) as count FROM users WHERE user_id = :user_id'); - $stmt->bindValue(':user_id', $userId, SQLITE3_TEXT); - $result = $stmt->execute(); - $row = $result->fetchArray(SQLITE3_ASSOC); - - if ($row['count'] == 0) break; - - $userId = generateUserId(); - $attempts++; - } - - // Create user - $stmt = $db->prepare(' - INSERT INTO users (username, user_id, birthdate, age_group) - VALUES (:username, :user_id, :birthdate, :age_group) - '); - $stmt->bindValue(':username', $username, SQLITE3_TEXT); - $stmt->bindValue(':user_id', $userId, SQLITE3_TEXT); - $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; - $_SESSION['age_group'] = $ageGroup; - $_SESSION['birthdate'] = $birthdate; - - updateOnlineStatus($dbUserId); - logSecurityEvent($dbUserId, 'REGISTER', "Age group: $ageGroup"); - - echo json_encode([ - 'success' => true, - 'user_id' => $dbUserId, - 'username' => $username, - 'display_name' => $username . '#' . $userId, - 'age_group' => $ageGroup - ]); - exit; - } - - // ─────────────────────────────────────────────────────── - // LOGIN - // ─────────────────────────────────────────────────────── - if ($action === 'login') { - $username = trim($_POST['username'] ?? ''); - $birthdate = trim($_POST['birthdate'] ?? ''); - $forceLogin = in_array(($_POST['force_login'] ?? '0'), ['1', 'true', 'TRUE'], true); - - if ($username === '' || $birthdate === '') { - echo json_encode(['success' => false, 'error' => 'Bitte gib Username und Geburtsdatum ein.']); - exit; - } - - $db = getDB(); - $stmt = $db->prepare(' - SELECT id, username, user_id as display_id, birthdate, age_group, is_banned, ban_reason - FROM users - WHERE LOWER(username) = LOWER(:username) - LIMIT 1 - '); - $stmt->bindValue(':username', $username, SQLITE3_TEXT); - $result = $stmt->execute(); - $user = $result->fetchArray(SQLITE3_ASSOC); - - if (!$user) { - echo json_encode(['success' => false, 'error' => 'Account wurde nicht gefunden.']); - exit; - } - - if ((int)$user['is_banned'] === 1) { - $reason = $user['ban_reason'] ? (string)$user['ban_reason'] : 'Verstoß gegen Regeln'; - echo json_encode(['success' => false, 'error' => 'Dein Account ist gesperrt: ' . $reason]); - exit; - } - - if ($user['birthdate'] !== $birthdate) { - logSecurityEvent($user['id'], 'LOGIN_FAILED', 'Falsches Geburtsdatum'); - echo json_encode(['success' => false, 'error' => 'Daten stimmen nicht überein.']); - exit; - } - - $sessionResult = startUserSession($user['id'], $forceLogin); - if (!$sessionResult['allowed']) { - $response = [ - 'success' => false, - 'error' => $sessionResult['error'] ?? 'Anmeldung nicht möglich.' - ]; - - if (!empty($sessionResult['can_force'])) { - $response['can_force'] = true; - } - - echo json_encode($response); - exit; - } - - if ($forceLogin) { - logSecurityEvent($user['id'], 'LOGIN_FORCE', 'Sitzung übernommen'); - } - - $_SESSION['user_id'] = $user['id']; - $_SESSION['username'] = $user['username']; - $_SESSION['user_display_id'] = $user['display_id']; - $_SESSION['age_group'] = $user['age_group']; - $_SESSION['birthdate'] = $user['birthdate']; - - updateOnlineStatus($user['id']); - logSecurityEvent($user['id'], 'LOGIN', 'Erfolgreiche Anmeldung'); - - echo json_encode([ - 'success' => true, - 'user_id' => $user['id'], - 'display_name' => $user['username'] . '#' . $user['display_id'], - 'age_group' => $user['age_group'] - ]); - exit; - } - - // ─────────────────────────────────────────────────────── - // ADMIN LOGIN - // ─────────────────────────────────────────────────────── - if ($action === 'admin_login') { - $username = trim($_POST['username'] ?? ''); - $password = trim($_POST['password'] ?? ''); - - $db = getDB(); - $stmt = $db->prepare('SELECT * FROM admins WHERE username = :username'); - $stmt->bindValue(':username', $username, SQLITE3_TEXT); - $result = $stmt->execute(); - $admin = $result->fetchArray(SQLITE3_ASSOC); - - if ($admin && password_verify($password, $admin['password_hash'])) { - $_SESSION['is_admin'] = true; - $_SESSION['admin_id'] = $admin['id']; - $_SESSION['admin_username'] = $admin['username']; - - logSecurityEvent(null, 'ADMIN_LOGIN', "Admin: $username"); - - echo json_encode(['success' => true]); - } else { - logSecurityEvent(null, 'ADMIN_LOGIN_FAILED', "Username: $username"); - echo json_encode(['success' => false, 'error' => 'Ungültige Zugangsdaten']); - } - exit; - } - - // ─────────────────────────────────────────────────────── - // LOGOUT - // ─────────────────────────────────────────────────────── - if ($action === 'logout') { - if (isLoggedIn()) { - $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) - // ─────────────────────────────────────────────────────── - if ($action === 'ping') { - updateOnlineStatus(getCurrentUserId()); - cleanupOldData(); - echo json_encode(['success' => true]); - exit; - } - - // ─────────────────────────────────────────────────────── - // GET USERS - // ─────────────────────────────────────────────────────── - if ($action === 'get_users') { - $db = getDB(); - $currentUserId = getCurrentUserId(); - $currentAgeGroup = getCurrentAgeGroup(); - - $query = ' - SELECT - u.id, - u.username, - u.user_id as display_id, - u.age_group, - u.last_seen, - CASE - WHEN os.last_ping IS NOT NULL - AND (julianday("now") - julianday(os.last_ping)) * 86400 < ' . ONLINE_TIMEOUT_SECONDS . ' - THEN 1 - ELSE 0 - END as is_online, - ( - SELECT COUNT(*) - FROM messages - WHERE from_user_id = u.id - AND to_user_id = :current_user_id - AND is_read = 0 - ) as unread_count, - ( - SELECT COUNT(*) - FROM blocks - WHERE blocker_id = :current_user_id - AND blocked_id = u.id - ) as is_blocked_by_me, - ( - SELECT COUNT(*) - FROM blocks - WHERE blocker_id = u.id - AND blocked_id = :current_user_id - ) as has_blocked_me - FROM users u - LEFT JOIN online_status os ON u.id = os.user_id - WHERE u.id != :current_user_id - AND u.is_banned = 0 - '; - - if ($currentAgeGroup === 'U18') { - $query .= ' AND u.age_group = :allowed_group'; - } else { - $query .= ' AND u.age_group != :blocked_group'; - } - - $query .= ' ORDER BY is_online DESC, u.username ASC'; - - $stmt = $db->prepare($query); - $stmt->bindValue(':current_user_id', $currentUserId, SQLITE3_INTEGER); - - if ($currentAgeGroup === 'U18') { - $stmt->bindValue(':allowed_group', 'U18', SQLITE3_TEXT); - } else { - $stmt->bindValue(':blocked_group', 'U18', SQLITE3_TEXT); - } - $result = $stmt->execute(); - - $users = []; - while ($row = $result->fetchArray(SQLITE3_ASSOC)) { - // Don't show users who blocked me or I blocked - if ($row['is_blocked_by_me'] > 0 || $row['has_blocked_me'] > 0) { - continue; - } - - $users[] = [ - 'id' => $row['id'], - 'username' => $row['username'], - 'display_id' => $row['display_id'], - 'display_name' => $row['username'] . '#' . $row['display_id'], - 'age_group' => $row['age_group'], - 'is_online' => $row['is_online'], - 'unread_count' => $row['unread_count'] - ]; - } - - echo json_encode(['success' => true, 'users' => $users]); - exit; - } - - // ─────────────────────────────────────────────────────── - // GET MESSAGES - // ─────────────────────────────────────────────────────── - if ($action === 'get_messages') { - $otherUserId = intval($_GET['user_id'] ?? 0); - - if ($otherUserId <= 0) { - echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); - exit; - } - - // Check if blocked - if (isBlocked(getCurrentUserId(), $otherUserId)) { - echo json_encode(['success' => false, 'error' => 'Chat nicht verfügbar']); - exit; - } - - $db = getDB(); - $currentUserId = getCurrentUserId(); - $currentAgeGroup = getCurrentAgeGroup(); - - $stmt = $db->prepare('SELECT age_group FROM users WHERE id = :user_id AND is_banned = 0'); - $stmt->bindValue(':user_id', $otherUserId, SQLITE3_INTEGER); - $result = $stmt->execute(); - $otherUser = $result->fetchArray(SQLITE3_ASSOC); - - if (!$otherUser) { - echo json_encode(['success' => false, 'error' => 'Benutzer nicht gefunden']); - exit; - } - - if (!canUsersChatByAge($currentAgeGroup, $otherUser['age_group'])) { - logSecurityEvent($currentUserId, 'AGE_RESTRICTION_BLOCKED', "GET_MESSAGES -> User $otherUserId"); - echo json_encode(['success' => false, 'error' => 'Chat zwischen Altersgruppen nicht erlaubt']); - exit; - } - - $query = ' - 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 = []; - while ($row = $result->fetchArray(SQLITE3_ASSOC)) { - $messages[] = [ - 'id' => $row['id'], - 'from_user_id' => $row['from_user_id'], - 'to_user_id' => $row['to_user_id'], - 'message' => $row['message'], - 'timestamp' => $row['timestamp'], - 'is_read' => $row['is_read'], - 'is_flagged' => $row['is_flagged'], - 'attachment_url' => $row['attachment_path'] ?: null, - 'attachment_type' => $row['attachment_type'] ?: null, - 'attachment_size' => $row['attachment_size'] !== null ? (int)$row['attachment_size'] : null, - 'from_username' => $row['from_username'], - 'from_display_name' => $row['from_username'] . '#' . $row['from_display_id'] - ]; - } - - echo json_encode(['success' => true, 'messages' => $messages]); - exit; - } - - // ─────────────────────────────────────────────────────── - // SEND MESSAGE - // ─────────────────────────────────────────────────────── - if ($action === 'send_message') { - $toUserId = intval($_POST['to_user_id'] ?? 0); - $message = trim($_POST['message'] ?? ''); - $hasAttachment = isset($_FILES['attachment']) && ($_FILES['attachment']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE; - - if ($toUserId <= 0) { - echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); - exit; - } - - if (!$hasAttachment && $message === '') { - echo json_encode(['success' => false, 'error' => 'Nachricht oder Bild erforderlich']); - exit; - } - - if (strlen($message) > 1000) { - echo json_encode(['success' => false, 'error' => 'Nachricht zu lang (max 1000 Zeichen)']); - exit; - } - - $attachmentFile = $hasAttachment ? $_FILES['attachment'] : null; - $attachmentMime = null; - $attachmentSize = null; - - if ($hasAttachment && $attachmentFile) { - if (!is_uploaded_file($attachmentFile['tmp_name'])) { - echo json_encode(['success' => false, 'error' => 'Ungültiger Datei-Upload']); - exit; - } - - if ($attachmentFile['error'] !== UPLOAD_ERR_OK) { - echo json_encode(['success' => false, 'error' => 'Bild konnte nicht hochgeladen werden']); - exit; - } - - if ($attachmentFile['size'] > MAX_ATTACHMENT_SIZE) { - echo json_encode(['success' => false, 'error' => 'Bild ist zu groß (max. 200 KB)']); - exit; - } - - $attachmentSize = (int)$attachmentFile['size']; - - $mime = null; - if (function_exists('finfo_open')) { - $finfo = finfo_open(FILEINFO_MIME_TYPE); - if ($finfo) { - $mime = finfo_file($finfo, $attachmentFile['tmp_name']); - finfo_close($finfo); - } - } - if (!$mime && function_exists('mime_content_type')) { - $mime = mime_content_type($attachmentFile['tmp_name']); - } - if (!$mime && isset($attachmentFile['type'])) { - $mime = $attachmentFile['type']; - } - - $mime = strtolower((string)$mime); - if (!in_array($mime, ['image/jpeg', 'image/pjpeg', 'image/jpg'], true)) { - echo json_encode(['success' => false, 'error' => 'Nur JPG-Bilder sind erlaubt']); - exit; - } - - $imageInfo = @getimagesize($attachmentFile['tmp_name']); - if ($imageInfo === false || !in_array($imageInfo[2], [IMAGETYPE_JPEG], true)) { - echo json_encode(['success' => false, 'error' => 'Bilddatei konnte nicht verifiziert werden']); - exit; - } - - $attachmentMime = 'image/jpeg'; - } - - // Check if blocked - if (isBlocked(getCurrentUserId(), $toUserId)) { - echo json_encode(['success' => false, 'error' => 'Nachricht kann nicht gesendet werden']); - exit; - } - - $db = getDB(); - $currentUserId = getCurrentUserId(); - $currentAgeGroup = getCurrentAgeGroup(); - - $stmt = $db->prepare('SELECT age_group FROM users WHERE id = :user_id AND is_banned = 0'); - $stmt->bindValue(':user_id', $toUserId, SQLITE3_INTEGER); - $result = $stmt->execute(); - $targetUser = $result->fetchArray(SQLITE3_ASSOC); - - if (!$targetUser) { - echo json_encode(['success' => false, 'error' => 'Empfänger nicht gefunden']); - exit; - } - - if (!canUsersChatByAge($currentAgeGroup, $targetUser['age_group'])) { - logSecurityEvent($currentUserId, 'AGE_RESTRICTION_BLOCKED', "SEND_MESSAGE -> User $toUserId"); - echo json_encode(['success' => false, 'error' => 'Nachrichten zwischen Altersgruppen nicht erlaubt']); - exit; - } - - // Rate Limiting - $rateLimitCheck = checkRateLimit($currentUserId, $currentAgeGroup); - if (!$rateLimitCheck['allowed']) { - logSecurityEvent($currentUserId, 'RATE_LIMIT_EXCEEDED', $rateLimitCheck['reason']); - echo json_encode(['success' => false, 'error' => $rateLimitCheck['reason']]); - exit; - } - - if ($message !== '') { - // Keyword Blacklist - $keywordCheck = checkKeywordBlacklist($message); - if ($keywordCheck['blocked']) { - logSecurityEvent($currentUserId, 'KEYWORD_BLOCKED', "Keyword: {$keywordCheck['keyword']}"); - echo json_encode([ - 'success' => false, - 'error' => 'Deine Nachricht enthält nicht erlaubte Inhalte', - 'details' => 'Verbotenes Wort erkannt: ' . $keywordCheck['keyword'] - ]); - exit; - } - - // Profanity Filter - $profanityCheck = checkProfanityFilter($message); - if ($profanityCheck['blocked']) { - logSecurityEvent($currentUserId, 'PROFANITY_BLOCKED', "Word: {$profanityCheck['word']}"); - echo json_encode([ - 'success' => false, - 'error' => 'Deine Nachricht enthält Schimpfwörter', - 'details' => 'Bitte verwende eine angemessene Sprache' - ]); - exit; - } - - // Link Filter - $linkCheck = checkLinkFilter($message); - if ($linkCheck['blocked']) { - logSecurityEvent($currentUserId, 'LINK_BLOCKED', "Message: $message"); - echo json_encode([ - 'success' => false, - 'error' => 'Links sind nicht erlaubt', - 'details' => 'Aus Sicherheitsgründen können keine URLs gesendet werden' - ]); - exit; - } - } - - // Auto-Flagging (verdächtige Muster) - $isFlagged = 0; - $flagReason = ''; - - if ($message !== '') { - if (preg_match('/(.)\1{5,}/', $message)) { - $isFlagged = 1; - $flagReason = 'Repeated characters'; - } - - if (strlen($message) > 20 && $message === strtoupper($message)) { - $isFlagged = 1; - $flagReason = 'All caps'; - } - - $emojiCount = preg_match_all('/[\x{1F600}-\x{1F64F}]/u', $message); - if ($emojiCount > 10) { - $isFlagged = 1; - $flagReason = 'Excessive emojis'; - } - } - - $attachmentPath = null; - if ($hasAttachment && $attachmentFile) { - $randomName = bin2hex(random_bytes(16)) . '.jpg'; - $destination = rtrim(UPLOAD_DIR, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $randomName; - - if (!move_uploaded_file($attachmentFile['tmp_name'], $destination)) { - echo json_encode(['success' => false, 'error' => 'Bild konnte nicht gespeichert werden']); - exit; - } - - $attachmentPath = 'uploads/' . $randomName; - } - - // Insert message - $stmt = $db->prepare(' - INSERT INTO messages (from_user_id, to_user_id, message, is_flagged, flag_reason, attachment_path, attachment_type, attachment_size) - VALUES (:from_user_id, :to_user_id, :message, :is_flagged, :flag_reason, :attachment_path, :attachment_type, :attachment_size) - '); - $stmt->bindValue(':from_user_id', $currentUserId, SQLITE3_INTEGER); - $stmt->bindValue(':to_user_id', $toUserId, SQLITE3_INTEGER); - $stmt->bindValue(':message', $message, SQLITE3_TEXT); - $stmt->bindValue(':is_flagged', $isFlagged, SQLITE3_INTEGER); - $stmt->bindValue(':flag_reason', $flagReason, SQLITE3_TEXT); - if ($attachmentPath) { - $stmt->bindValue(':attachment_path', $attachmentPath, SQLITE3_TEXT); - $stmt->bindValue(':attachment_type', $attachmentMime, SQLITE3_TEXT); - $stmt->bindValue(':attachment_size', $attachmentSize, SQLITE3_INTEGER); - } else { - $stmt->bindValue(':attachment_path', null, SQLITE3_NULL); - $stmt->bindValue(':attachment_type', null, SQLITE3_NULL); - $stmt->bindValue(':attachment_size', null, SQLITE3_NULL); - } - $stmt->execute(); - - $messageId = $db->lastInsertRowID(); - - // Log rate limit - logRateLimit($currentUserId); - - if ($isFlagged) { - logSecurityEvent($currentUserId, 'MESSAGE_FLAGGED', "Reason: $flagReason, Message ID: $messageId"); - } - - if ($attachmentPath) { - logSecurityEvent($currentUserId, 'ATTACHMENT_UPLOADED', "Message ID: $messageId, Size: $attachmentSize"); - } - - echo json_encode([ - 'success' => true, - 'message_id' => $messageId, - 'timestamp' => date('Y-m-d H:i:s'), - 'attachment_url' => $attachmentPath - ]); - exit; - } - - // ─────────────────────────────────────────────────────── - // MARK AS READ - // ─────────────────────────────────────────────────────── - if ($action === 'mark_read') { - $otherUserId = intval($_POST['user_id'] ?? 0); - - if ($otherUserId <= 0) { - echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); - exit; - } - - $db = getDB(); - $currentUserId = getCurrentUserId(); - $currentAgeGroup = getCurrentAgeGroup(); - - $stmt = $db->prepare('SELECT age_group FROM users WHERE id = :user_id AND is_banned = 0'); - $stmt->bindValue(':user_id', $otherUserId, SQLITE3_INTEGER); - $result = $stmt->execute(); - $otherUser = $result->fetchArray(SQLITE3_ASSOC); - - if (!$otherUser) { - echo json_encode(['success' => false, 'error' => 'Benutzer nicht gefunden']); - exit; - } - - if (!canUsersChatByAge($currentAgeGroup, $otherUser['age_group'])) { - logSecurityEvent($currentUserId, 'AGE_RESTRICTION_BLOCKED', "MARK_READ -> User $otherUserId"); - echo json_encode(['success' => false, 'error' => 'Aktion zwischen Altersgruppen nicht erlaubt']); - exit; - } - - $stmt = $db->prepare(' - UPDATE messages - SET is_read = 1 - WHERE from_user_id = :other_user_id - AND to_user_id = :current_user_id - AND is_read = 0 - '); - $stmt->bindValue(':other_user_id', $otherUserId, SQLITE3_INTEGER); - $stmt->bindValue(':current_user_id', $currentUserId, SQLITE3_INTEGER); - $stmt->execute(); - - echo json_encode(['success' => true]); - exit; - } - - // ─────────────────────────────────────────────────────── - // BLOCK USER - // ─────────────────────────────────────────────────────── - if ($action === 'block_user') { - $blockedUserId = intval($_POST['user_id'] ?? 0); - - if ($blockedUserId <= 0) { - echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); - exit; - } - - $db = getDB(); - $currentUserId = getCurrentUserId(); - - $stmt = $db->prepare(' - INSERT OR IGNORE INTO blocks (blocker_id, blocked_id) - VALUES (:blocker_id, :blocked_id) - '); - $stmt->bindValue(':blocker_id', $currentUserId, SQLITE3_INTEGER); - $stmt->bindValue(':blocked_id', $blockedUserId, SQLITE3_INTEGER); - $stmt->execute(); - - logSecurityEvent($currentUserId, 'USER_BLOCKED', "Blocked user ID: $blockedUserId"); - - echo json_encode(['success' => true]); - exit; - } - - // ─────────────────────────────────────────────────────── - // UNBLOCK USER - // ─────────────────────────────────────────────────────── - if ($action === 'unblock_user') { - $blockedUserId = intval($_POST['user_id'] ?? 0); - - if ($blockedUserId <= 0) { - echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); - exit; - } - - $db = getDB(); - $currentUserId = getCurrentUserId(); - - $stmt = $db->prepare(' - DELETE FROM blocks - WHERE blocker_id = :blocker_id - AND blocked_id = :blocked_id - '); - $stmt->bindValue(':blocker_id', $currentUserId, SQLITE3_INTEGER); - $stmt->bindValue(':blocked_id', $blockedUserId, SQLITE3_INTEGER); - $stmt->execute(); - - logSecurityEvent($currentUserId, 'USER_UNBLOCKED', "Unblocked user ID: $blockedUserId"); - - echo json_encode(['success' => true]); - exit; - } - - // ─────────────────────────────────────────────────────── - // REPORT USER - // ─────────────────────────────────────────────────────── - if ($action === 'report_user') { - $reportedUserId = intval($_POST['user_id'] ?? 0); - $reason = trim($_POST['reason'] ?? ''); - $messageId = intval($_POST['message_id'] ?? 0); - - if ($reportedUserId <= 0) { - echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); - exit; - } - - if (empty($reason)) { - echo json_encode(['success' => false, 'error' => 'Bitte gib einen Grund an']); - exit; - } - - $db = getDB(); - $currentUserId = getCurrentUserId(); - - $stmt = $db->prepare(' - INSERT INTO reports (reporter_id, reported_user_id, reason, message_id) - VALUES (:reporter_id, :reported_user_id, :reason, :message_id) - '); - $stmt->bindValue(':reporter_id', $currentUserId, SQLITE3_INTEGER); - $stmt->bindValue(':reported_user_id', $reportedUserId, SQLITE3_INTEGER); - $stmt->bindValue(':reason', $reason, SQLITE3_TEXT); - $stmt->bindValue(':message_id', $messageId > 0 ? $messageId : null, SQLITE3_INTEGER); - $stmt->execute(); - - logSecurityEvent($currentUserId, 'USER_REPORTED', "Reported user ID: $reportedUserId, Reason: $reason"); - - echo json_encode(['success' => true, 'message' => 'Meldung wurde erfolgreich gesendet']); - exit; - } - - // ─────────────────────────────────────────────────────── - // GET BLOCKED USERS - // ─────────────────────────────────────────────────────── - if ($action === 'get_blocked_users') { - $db = getDB(); - $currentUserId = getCurrentUserId(); - - $query = ' - SELECT - u.id, - u.username, - u.user_id as display_id, - b.timestamp as blocked_at - FROM blocks b - JOIN users u ON b.blocked_id = u.id - WHERE b.blocker_id = :current_user_id - ORDER BY b.timestamp DESC - '; - - $stmt = $db->prepare($query); - $stmt->bindValue(':current_user_id', $currentUserId, SQLITE3_INTEGER); - $result = $stmt->execute(); - - $blockedUsers = []; - while ($row = $result->fetchArray(SQLITE3_ASSOC)) { - $blockedUsers[] = [ - 'id' => $row['id'], - 'display_name' => $row['username'] . '#' . $row['display_id'], - 'blocked_at' => $row['blocked_at'] - ]; - } - - echo json_encode(['success' => true, 'blocked_users' => $blockedUsers]); - exit; - } - - // ═══════════════════════════════════════════════════════════ - // ADMIN ACTIONS - // ═══════════════════════════════════════════════════════════ - - if (!isAdmin()) { - echo json_encode(['success' => false, 'error' => 'Admin-Rechte erforderlich']); - exit; - } - - // ─────────────────────────────────────────────────────── - // ADMIN: GET REPORTS - // ─────────────────────────────────────────────────────── - if ($action === 'admin_get_reports') { - $db = getDB(); - - $query = ' - SELECT - r.id, - r.reason, - r.timestamp, - r.status, - r.message_id, - reporter.username as reporter_name, - reporter.user_id as reporter_display_id, - reported.username as reported_name, - reported.user_id as reported_display_id, - reported.id as reported_user_id, - m.message as message_content - FROM reports r - JOIN users reporter ON r.reporter_id = reporter.id - JOIN users reported ON r.reported_user_id = reported.id - LEFT JOIN messages m ON r.message_id = m.id - WHERE r.status = "pending" - ORDER BY r.timestamp DESC - LIMIT 50 - '; - - $result = $db->query($query); - - $reports = []; - while ($row = $result->fetchArray(SQLITE3_ASSOC)) { - $reports[] = [ - 'id' => $row['id'], - 'reporter' => $row['reporter_name'] . '#' . $row['reporter_display_id'], - 'reported' => $row['reported_name'] . '#' . $row['reported_display_id'], - 'reported_user_id' => $row['reported_user_id'], - 'reason' => $row['reason'], - 'message' => $row['message_content'], - 'timestamp' => $row['timestamp'], - 'status' => $row['status'] - ]; - } - - echo json_encode(['success' => true, 'reports' => $reports]); - exit; - } - - // ─────────────────────────────────────────────────────── - // ADMIN: GET FLAGGED MESSAGES - // ─────────────────────────────────────────────────────── - if ($action === 'admin_get_flagged') { - $db = getDB(); - - $query = ' - SELECT - m.id, - m.message, - m.flag_reason, - m.timestamp, - u.username, - u.user_id as display_id, - u.id as user_db_id - FROM messages m - JOIN users u ON m.from_user_id = u.id - WHERE m.is_flagged = 1 - ORDER BY m.timestamp DESC - LIMIT 50 - '; - - $result = $db->query($query); - - $flagged = []; - while ($row = $result->fetchArray(SQLITE3_ASSOC)) { - $flagged[] = [ - 'id' => $row['id'], - 'user' => $row['username'] . '#' . $row['display_id'], - 'user_id' => $row['user_db_id'], - 'message' => $row['message'], - 'reason' => $row['flag_reason'], - 'timestamp' => $row['timestamp'] - ]; - } - - echo json_encode(['success' => true, 'flagged' => $flagged]); - exit; - } - - // ─────────────────────────────────────────────────────── - // ADMIN: GET STATISTICS - // ─────────────────────────────────────────────────────── - if ($action === 'admin_get_stats') { - $db = getDB(); - - // Total users - $result = $db->query('SELECT COUNT(*) as count FROM users WHERE is_banned = 0'); - $totalUsers = $result->fetchArray(SQLITE3_ASSOC)['count']; - - // U18 users - $result = $db->query('SELECT COUNT(*) as count FROM users WHERE age_group = "U18" AND is_banned = 0'); - $u18Users = $result->fetchArray(SQLITE3_ASSOC)['count']; - - // O18 users - $result = $db->query('SELECT COUNT(*) as count FROM users WHERE age_group = "O18" AND is_banned = 0'); - $o18Users = $result->fetchArray(SQLITE3_ASSOC)['count']; - - // Online users - $result = $db->query(' - SELECT COUNT(*) as count - FROM online_status - WHERE (julianday("now") - julianday(last_ping)) * 86400 < ' . ONLINE_TIMEOUT_SECONDS - ); - $onlineUsers = $result->fetchArray(SQLITE3_ASSOC)['count']; - - // Total messages (24h) - $result = $db->query('SELECT COUNT(*) as count FROM messages'); - $totalMessages = $result->fetchArray(SQLITE3_ASSOC)['count']; - - // Flagged messages - $result = $db->query('SELECT COUNT(*) as count FROM messages WHERE is_flagged = 1'); - $flaggedMessages = $result->fetchArray(SQLITE3_ASSOC)['count']; - - // Pending reports - $result = $db->query('SELECT COUNT(*) as count FROM reports WHERE status = "pending"'); - $pendingReports = $result->fetchArray(SQLITE3_ASSOC)['count']; - - // Banned users - $result = $db->query('SELECT COUNT(*) as count FROM users WHERE is_banned = 1'); - $bannedUsers = $result->fetchArray(SQLITE3_ASSOC)['count']; - - echo json_encode([ - 'success' => true, - 'stats' => [ - 'total_users' => $totalUsers, - 'u18_users' => $u18Users, - 'o18_users' => $o18Users, - 'online_users' => $onlineUsers, - 'total_messages' => $totalMessages, - 'flagged_messages' => $flaggedMessages, - 'pending_reports' => $pendingReports, - 'banned_users' => $bannedUsers - ] - ]); - exit; - } - - if ($action === 'admin_get_banned_users') { - $db = getDB(); - - $result = $db->query(' - SELECT id, username, user_id as display_id, ban_reason, last_seen - FROM users - WHERE is_banned = 1 - ORDER BY last_seen DESC - LIMIT 100 - '); - - $banned = []; - while ($row = $result->fetchArray(SQLITE3_ASSOC)) { - $banned[] = [ - 'id' => $row['id'], - 'display_name' => $row['username'] . '#' . $row['display_id'], - 'reason' => $row['ban_reason'] ?? 'keine Angabe', - 'last_seen' => $row['last_seen'] - ]; - } - - echo json_encode(['success' => true, 'banned' => $banned]); - exit; - } - - // ─────────────────────────────────────────────────────── - // ADMIN: BAN USER - // ─────────────────────────────────────────────────────── - if ($action === 'admin_ban_user') { - $userId = intval($_POST['user_id'] ?? 0); - $reason = trim($_POST['reason'] ?? 'Verstoß gegen Nutzungsbedingungen'); - - if ($userId <= 0) { - echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); - exit; - } - - $db = getDB(); - $stmt = $db->prepare(' - UPDATE users - SET is_banned = 1, ban_reason = :reason - WHERE id = :user_id - '); - $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); - $stmt->bindValue(':reason', $reason, SQLITE3_TEXT); - $stmt->execute(); - - logSecurityEvent(null, 'ADMIN_BAN_USER', "User ID: $userId, Reason: $reason"); - - echo json_encode(['success' => true, 'message' => 'User wurde gesperrt']); - exit; - } - - // ─────────────────────────────────────────────────────── - // ADMIN: UNBAN USER - // ─────────────────────────────────────────────────────── - if ($action === 'admin_unban_user') { - $userId = intval($_POST['user_id'] ?? 0); - - if ($userId <= 0) { - echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); - exit; - } - - $db = getDB(); - $stmt = $db->prepare(' - UPDATE users - SET is_banned = 0, ban_reason = NULL - WHERE id = :user_id - '); - $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); - $stmt->execute(); - - logSecurityEvent(null, 'ADMIN_UNBAN_USER', "User ID: $userId"); - - echo json_encode(['success' => true, 'message' => 'Sperre wurde aufgehoben']); - exit; - } - - // ─────────────────────────────────────────────────────── - // ADMIN: RESOLVE REPORT - // ─────────────────────────────────────────────────────── - if ($action === 'admin_resolve_report') { - $reportId = intval($_POST['report_id'] ?? 0); - $action_taken = trim($_POST['action_taken'] ?? 'resolved'); - - if ($reportId <= 0) { - echo json_encode(['success' => false, 'error' => 'Ungültige Report-ID']); - exit; - } - - $db = getDB(); - $stmt = $db->prepare(' - UPDATE reports - SET status = :status - WHERE id = :report_id - '); - $stmt->bindValue(':report_id', $reportId, SQLITE3_INTEGER); - $stmt->bindValue(':status', $action_taken, SQLITE3_TEXT); - $stmt->execute(); - - logSecurityEvent(null, 'ADMIN_RESOLVE_REPORT', "Report ID: $reportId, Action: $action_taken"); - - echo json_encode(['success' => true, 'message' => 'Report wurde bearbeitet']); - exit; - } - - // ─────────────────────────────────────────────────────── - // ADMIN: DELETE MESSAGE - // ─────────────────────────────────────────────────────── - if ($action === 'admin_delete_message') { - $messageId = intval($_POST['message_id'] ?? 0); - - if ($messageId <= 0) { - echo json_encode(['success' => false, 'error' => 'Ungültige Message-ID']); - exit; - } - - $db = getDB(); - $stmt = $db->prepare('DELETE FROM messages WHERE id = :message_id'); - $stmt->bindValue(':message_id', $messageId, SQLITE3_INTEGER); - $stmt->execute(); - - logSecurityEvent(null, 'ADMIN_DELETE_MESSAGE', "Message ID: $messageId"); - - echo json_encode(['success' => true, 'message' => 'Nachricht wurde gelöscht']); - exit; - } - - if ($action === 'poll_updates') { - if (!isLoggedIn()) { - echo json_encode(['success' => false, 'error' => 'Nicht angemeldet']); - exit; - } - - if (!validateActiveSession()) { - echo json_encode(['success' => false, 'error' => 'Sitzung ungültig']); - exit; - } - - $lastMessageId = intval($_POST['last_message_id'] ?? $_GET['last_message_id'] ?? 0); - - $db = getDB(); - $currentUserId = getCurrentUserId(); - $currentAgeGroup = getCurrentAgeGroup(); - - touchUserSession($currentUserId); - - $stmt = $db->prepare(' - SELECT - m.id, - m.from_user_id, - m.to_user_id, - m.message, - m.timestamp, - m.attachment_path, - m.attachment_type, - m.attachment_size, - uf.username as from_username, - uf.user_id as from_display_id, - uf.age_group as from_age_group, - ut.age_group as to_age_group - FROM messages m - JOIN users uf ON m.from_user_id = uf.id - JOIN users ut ON m.to_user_id = ut.id - WHERE m.id > :last_message_id - AND (m.to_user_id = :current_user_id OR m.from_user_id = :current_user_id) - AND NOT EXISTS ( - SELECT 1 FROM blocks - WHERE (blocker_id = :current_user_id AND blocked_id = m.from_user_id) - OR (blocker_id = m.from_user_id AND blocked_id = :current_user_id) - ) - ORDER BY m.id ASC - '); - $stmt->bindValue(':last_message_id', $lastMessageId, SQLITE3_INTEGER); - $stmt->bindValue(':current_user_id', $currentUserId, SQLITE3_INTEGER); - $result = $stmt->execute(); - - $messages = []; - $maxId = $lastMessageId; - - while ($row = $result->fetchArray(SQLITE3_ASSOC)) { - $otherAgeGroup = $row['from_user_id'] === $currentUserId ? $row['to_age_group'] : $row['from_age_group']; - - if (!canUsersChatByAge($currentAgeGroup, $otherAgeGroup)) { - continue; - } - - $maxId = max($maxId, (int)$row['id']); - - $messages[] = [ - 'id' => $row['id'], - 'from_user_id' => $row['from_user_id'], - 'to_user_id' => $row['to_user_id'], - 'message' => $row['message'], - 'timestamp' => $row['timestamp'], - 'attachment_url' => $row['attachment_path'] ?: null, - 'attachment_type' => $row['attachment_type'] ?: null, - 'attachment_size' => $row['attachment_size'] !== null ? (int)$row['attachment_size'] : null, - 'from_username' => $row['from_username'], - 'from_display_name' => $row['from_username'] . '#' . $row['from_display_id'] - ]; - } - - echo json_encode([ - 'success' => true, - 'messages' => $messages, - 'last_message_id' => $maxId - ]); - exit; - } - - echo json_encode(['success' => false, 'error' => 'Unbekannte Aktion']); - exit; -} - -// ═══════════════════════════════════════════════════════════ -// SSE STREAM (ECHTZEIT) -// ═══════════════════════════════════════════════════════════ - -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) { - @ob_end_flush(); - } - - echo ": connected\n\n"; - echo "retry: " . SSE_RETRY_MS . "\n\n"; - flush(); - - $lastPingTime = time(); - - // ✅ ENDLOSSCHLEIFE HINZUFÜGEN! - while (true) { - if (connection_aborted()) { - break; - } - - $db = getDB(); - - $stmt = $db->prepare(' - SELECT - m.id, - m.from_user_id, - m.to_user_id, - m.message, - m.timestamp, - m.attachment_path, - m.attachment_type, - m.attachment_size, - uf.username as from_username, - uf.user_id as from_display_id, - uf.age_group as from_age_group, - ut.age_group as to_age_group - FROM messages m - JOIN users uf ON m.from_user_id = uf.id - JOIN users ut ON m.to_user_id = ut.id - WHERE m.id > :last_message_id - AND (m.to_user_id = :current_user_id OR m.from_user_id = :current_user_id) - AND NOT EXISTS ( - SELECT 1 FROM blocks - WHERE (blocker_id = :current_user_id AND blocked_id = m.from_user_id) - OR (blocker_id = m.from_user_id AND blocked_id = :current_user_id) - ) - ORDER BY m.id ASC - '); - $stmt->bindValue(':last_message_id', $lastMessageId, SQLITE3_INTEGER); - $stmt->bindValue(':current_user_id', $currentUserId, SQLITE3_INTEGER); - $result = $stmt->execute(); - - $messages = []; - while ($row = $result->fetchArray(SQLITE3_ASSOC)) { - $otherAgeGroup = $row['from_user_id'] === $currentUserId ? $row['to_age_group'] : $row['from_age_group']; - - if (!canUsersChatByAge($currentAgeGroup, $otherAgeGroup)) { - continue; - } - - $messages[] = [ - 'id' => $row['id'], - 'from_user_id' => $row['from_user_id'], - 'to_user_id' => $row['to_user_id'], - 'message' => $row['message'], - 'timestamp' => $row['timestamp'], - 'attachment_url' => $row['attachment_path'] ?: null, - 'attachment_type' => $row['attachment_type'] ?: null, - 'attachment_size' => $row['attachment_size'] !== null ? (int)$row['attachment_size'] : null, - 'from_username' => $row['from_username'], - 'from_display_name' => $row['from_username'] . '#' . $row['from_display_id'] - ]; - - $lastMessageId = max($lastMessageId, (int)$row['id']); - } - - if (!empty($messages)) { - echo "data: " . json_encode(['type' => 'messages', 'messages' => $messages]) . "\n\n"; - flush(); - } - - // Ping alle 15 Sekunden - if (time() - $lastPingTime >= 15) { - echo "data: " . json_encode(['type' => 'ping']) . "\n\n"; - flush(); - $lastPingTime = time(); - touchUserSession($currentUserId); - } - - // Kurze Pause, um CPU zu schonen - usleep(500000); // 0.5 Sekunden - } - - exit; -} - - -// ═══════════════════════════════════════════════════════════ -// HTML OUTPUT -// ═══════════════════════════════════════════════════════════ -?> - - - - - - - 💬 Secure Private Chat - - - - - - -
-
-

🔐 Admin-Dashboard

- -
- -
- -
- -
-
-
-

🚨 Offene Meldungen

-
-
-
Lade Meldungen…
-
-
- -
-

🚩 Markierte Nachrichten

-
-
Lade Nachrichten…
-
-
- -
-

🚫 Gesperrte Nutzer

-
-
Lade Nutzer…
-
-
-
-
- - - - - - -
-

💬 Secure Private Chat

-

Sicherer Chat mit Altersverifikation

- -
- - -
- -
-
- -
-
- - -
- -
- - -
- -
-

⚠️ Wichtige Regeln

-
    -
  • Gib NIEMALS persönliche Daten weiter (Adresse, Telefon, etc.)
  • -
  • Treffe dich NICHT mit Fremden
  • -
  • Bleibe respektvoll und freundlich
  • -
  • Keine Links oder externe Kontakte teilen
  • -
  • Bei verdächtigem Verhalten: Melde den User!
  • -
-
- -
- - -
- - -
- - - - -
- - - -
- -
-

💬 Secure Private Chat

- -
- - - - - -
-
-
💬
-
Wähle einen Benutzer aus der Liste
-
- -
-
- -
- - -
- -
- -
-
- - - -
- - -
- -
-
-
- - - - - From ab20136bac5eba5889e9cb3b838f299c05deb7fe Mon Sep 17 00:00:00 2001 From: Metacube Date: Tue, 4 Nov 2025 10:04:40 +0100 Subject: [PATCH 07/10] Create chat.php --- chat.php | 4570 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 4570 insertions(+) create mode 100644 chat.php diff --git a/chat.php b/chat.php new file mode 100644 index 0000000..6f013ca --- /dev/null +++ b/chat.php @@ -0,0 +1,4570 @@ +busyTimeout(5000); + } + + if (!$initialized) { + // Users Table (mit Geburtsdatum und User-ID) + $db->exec(' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + user_id TEXT UNIQUE NOT NULL, + birthdate DATE NOT NULL, + age_group TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + is_banned INTEGER DEFAULT 0, + ban_reason TEXT + ) + '); + + // Messages Table + $db->exec(' + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_user_id INTEGER NOT NULL, + to_user_id INTEGER NOT NULL, + message TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + is_read INTEGER DEFAULT 0, + is_flagged INTEGER DEFAULT 0, + flag_reason TEXT, + attachment_path TEXT, + attachment_type TEXT, + attachment_size INTEGER, + FOREIGN KEY (from_user_id) REFERENCES users(id), + FOREIGN KEY (to_user_id) REFERENCES users(id) + ) + '); + + // Ensure attachment columns exist for older installations + $messagesInfo = $db->query('PRAGMA table_info(messages)'); + $messageColumns = []; + while ($column = $messagesInfo->fetchArray(SQLITE3_ASSOC)) { + $messageColumns[$column['name']] = true; + } + if (!isset($messageColumns['attachment_path'])) { + $db->exec('ALTER TABLE messages ADD COLUMN attachment_path TEXT'); + } + if (!isset($messageColumns['attachment_type'])) { + $db->exec('ALTER TABLE messages ADD COLUMN attachment_type TEXT'); + } + if (!isset($messageColumns['attachment_size'])) { + $db->exec('ALTER TABLE messages ADD COLUMN attachment_size INTEGER'); + } + + // Online Status Table + $db->exec(' + CREATE TABLE IF NOT EXISTS online_status ( + user_id INTEGER PRIMARY KEY, + last_ping DATETIME DEFAULT CURRENT_TIMESTAMP, + 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 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reporter_id INTEGER NOT NULL, + reported_user_id INTEGER NOT NULL, + reason TEXT NOT NULL, + message_id INTEGER, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + status TEXT DEFAULT "pending", + FOREIGN KEY (reporter_id) REFERENCES users(id), + FOREIGN KEY (reported_user_id) REFERENCES users(id), + FOREIGN KEY (message_id) REFERENCES messages(id) + ) + '); + + // Blocks Table + $db->exec(' + CREATE TABLE IF NOT EXISTS blocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + blocker_id INTEGER NOT NULL, + blocked_id INTEGER NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (blocker_id) REFERENCES users(id), + FOREIGN KEY (blocked_id) REFERENCES users(id), + UNIQUE(blocker_id, blocked_id) + ) + '); + + // Rate Limiting Table + $db->exec(' + CREATE TABLE IF NOT EXISTS rate_limits ( + user_id INTEGER NOT NULL, + action_type TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + '); + + // Logs Table (für Behörden) + $db->exec(' + CREATE TABLE IF NOT EXISTS security_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + action TEXT NOT NULL, + details TEXT, + ip_address TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + '); + + // Admin Table + $db->exec(' + CREATE TABLE IF NOT EXISTS admins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + '); + + // Create default admin if not exists + $stmt = $db->prepare('SELECT COUNT(*) as count FROM admins WHERE username = :username'); + $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); + $result = $stmt->execute(); + $row = $result->fetchArray(SQLITE3_ASSOC); + + if ($row['count'] == 0) { + $stmt = $db->prepare('INSERT INTO admins (username, password_hash) VALUES (:username, :password)'); + $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); + $stmt->bindValue(':password', ADMIN_PASSWORD, SQLITE3_TEXT); + $stmt->execute(); + } + + // Create indexes + $db->exec('CREATE INDEX IF NOT EXISTS idx_messages_users ON messages(from_user_id, to_user_id)'); + $db->exec('CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)'); + $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)'); + + $initialized = true; + } + + return $db; +} + +// ═══════════════════════════════════════════════════════════ +// SECURITY FUNCTIONS +// ═══════════════════════════════════════════════════════════ + +function generateUserId() { + return str_pad(mt_rand(1000, 9999), 4, '0', STR_PAD_LEFT); +} + +function calculateAge($birthdate) { + $birth = new DateTime($birthdate); + $today = new DateTime(); + return $birth->diff($today)->y; +} + +function getAgeGroup($birthdate) { + $age = calculateAge($birthdate); + return $age < 18 ? 'U18' : 'O18'; +} + +function resolveStoredAgeGroup($storedAgeGroup, $birthdate) { + $storedAgeGroup = is_string($storedAgeGroup) ? strtoupper(trim($storedAgeGroup)) : ''; + + if ($storedAgeGroup === 'U18' || $storedAgeGroup === 'O18') { + return $storedAgeGroup; + } + + if (!empty($birthdate)) { + return getAgeGroup($birthdate); + } + + return 'O18'; +} + +function checkKeywordBlacklist($message) { + global $KEYWORD_BLACKLIST; + + $message_lower = mb_strtolower($message, 'UTF-8'); + + foreach ($KEYWORD_BLACKLIST as $keyword) { + if (stripos($message_lower, $keyword) !== false) { + return ['blocked' => true, 'keyword' => $keyword]; + } + } + + return ['blocked' => false]; +} + +function checkProfanityFilter($message) { + global $PROFANITY_FILTER; + + $message_lower = mb_strtolower($message, 'UTF-8'); + + foreach ($PROFANITY_FILTER as $word) { + if (stripos($message_lower, $word) !== false) { + return ['blocked' => true, 'word' => $word]; + } + } + + return ['blocked' => false]; +} + +function checkLinkFilter($message) { + $patterns = [ + '/https?:\/\//i', + '/www\./i', + '/[a-z0-9-]+\.(com|de|ch|net|org|info)/i' + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $message)) { + return ['blocked' => true]; + } + } + + return ['blocked' => false]; +} + +function checkRateLimit($userId, $ageGroup) { + $db = getDB(); + + // Check messages per minute + $stmt = $db->prepare(" + SELECT COUNT(*) as count + FROM rate_limits + WHERE user_id = :user_id + AND action_type = 'message' + AND timestamp > datetime('now', '-1 minute') + "); + $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); + $result = $stmt->execute(); + $row = $result->fetchArray(SQLITE3_ASSOC); + + if ($row['count'] >= MAX_MESSAGES_PER_MINUTE) { + return ['allowed' => false, 'reason' => 'Zu viele Nachrichten pro Minute (max ' . MAX_MESSAGES_PER_MINUTE . ')']; + } + + // Check messages per hour + $stmt = $db->prepare(" + SELECT COUNT(*) as count + FROM rate_limits + WHERE user_id = :user_id + AND action_type = 'message' + AND timestamp > datetime('now', '-1 hour') + "); + $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); + $result = $stmt->execute(); + $row = $result->fetchArray(SQLITE3_ASSOC); + + if ($row['count'] >= MAX_MESSAGES_PER_HOUR) { + return ['allowed' => false, 'reason' => 'Zu viele Nachrichten pro Stunde (max ' . MAX_MESSAGES_PER_HOUR . ')']; + } + + // Check messages per day for U18 + if ($ageGroup === 'U18') { + $stmt = $db->prepare(" + SELECT COUNT(*) as count + FROM rate_limits + WHERE user_id = :user_id + AND action_type = 'message' + AND timestamp > datetime('now', '-1 day') + "); + $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); + $result = $stmt->execute(); + $row = $result->fetchArray(SQLITE3_ASSOC); + + if ($row['count'] >= MAX_MESSAGES_PER_DAY_U18) { + return ['allowed' => false, 'reason' => 'Tages-Limit erreicht (max ' . MAX_MESSAGES_PER_DAY_U18 . ' für U18)']; + } + } + + return ['allowed' => true]; +} + +function logRateLimit($userId) { + $db = getDB(); + $stmt = $db->prepare("INSERT INTO rate_limits (user_id, action_type) VALUES (:user_id, 'message')"); + $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); + $stmt->execute(); +} + +function logSecurityEvent($userId, $action, $details = '') { + $db = getDB(); + $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + + $stmt = $db->prepare(' + INSERT INTO security_logs (user_id, action, details, ip_address) + VALUES (:user_id, :action, :details, :ip) + '); + $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); + $stmt->bindValue(':action', $action, SQLITE3_TEXT); + $stmt->bindValue(':details', $details, SQLITE3_TEXT); + $stmt->bindValue(':ip', $ip, SQLITE3_TEXT); + $stmt->execute(); +} + +function canUsersChatByAge($ageGroupA, $ageGroupB) { + if (!$ageGroupA || !$ageGroupB) { + return false; + } + + // Wenn einer minderjährig ist, müssen beide minderjährig sein + if ($ageGroupA === 'U18' || $ageGroupB === 'U18') { + return $ageGroupA === 'U18' && $ageGroupB === 'U18'; + } + + // Volljährige dürfen miteinander chatten + return true; +} + +function isBlocked($userId, $otherUserId) { + $db = getDB(); + $stmt = $db->prepare(' + SELECT COUNT(*) as count + FROM blocks + WHERE (blocker_id = :user1 AND blocked_id = :user2) + OR (blocker_id = :user2 AND blocked_id = :user1) + '); + $stmt->bindValue(':user1', $userId, SQLITE3_INTEGER); + $stmt->bindValue(':user2', $otherUserId, SQLITE3_INTEGER); + $result = $stmt->execute(); + $row = $result->fetchArray(SQLITE3_ASSOC); + + return $row['count'] > 0; +} + +function cleanupOldData() { + $db = getDB(); + + // Delete old messages + $hours = MESSAGE_RETENTION_HOURS; + $attachmentResult = $db->query("SELECT attachment_path FROM messages WHERE attachment_path IS NOT NULL AND timestamp < datetime('now', '-{$hours} hours')"); + while ($attachmentRow = $attachmentResult->fetchArray(SQLITE3_ASSOC)) { + $relativePath = $attachmentRow['attachment_path'] ?? ''; + if (!$relativePath) { + continue; + } + + $normalizedPath = str_replace('\\', '/', $relativePath); + if (strpos($normalizedPath, 'uploads/') !== 0) { + continue; + } + + $fullPath = __DIR__ . '/' . $normalizedPath; + if (is_file($fullPath)) { + @unlink($fullPath); + } + } + + $db->exec("DELETE FROM messages WHERE timestamp < datetime('now', '-{$hours} hours')"); + + // Delete old rate limits + $db->exec("DELETE FROM rate_limits WHERE timestamp < datetime('now', '-1 day')"); + + // 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')"); +} + +// ═══════════════════════════════════════════════════════════ +// SESSION & AUTH +// ═══════════════════════════════════════════════════════════ + +session_start(); +$isAdminPage = isset($_GET['admin']); + +function isLoggedIn() { + return isset($_SESSION['user_id']) && isset($_SESSION['username']); +} + +function isAdmin() { + return isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true; +} + +function getCurrentUserId() { + return $_SESSION['user_id'] ?? null; +} + +function getCurrentUsername() { + return $_SESSION['username'] ?? null; +} + +function getCurrentUserDisplayName() { + if (!isLoggedIn()) return null; + return $_SESSION['username'] . '#' . $_SESSION['user_display_id']; +} + +function getCurrentAgeGroup() { + return $_SESSION['age_group'] ?? null; +} + +function updateOnlineStatus($userId) { + $db = getDB(); + $stmt = $db->prepare(' + INSERT OR REPLACE INTO online_status (user_id, last_ping) + VALUES (:user_id, CURRENT_TIMESTAMP) + '); + $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, $force = false) { + 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 && !$force && !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. Übernimm die Sitzung nur, wenn du wirklich ausgeloggt bist.', + 'can_force' => true + ]; + } + } + + if ($force && $existing) { + $stmt = $db->prepare('DELETE FROM user_sessions WHERE user_id = :user_id'); + $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); + $stmt->execute(); + } + + $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; +} + +// ═══════════════════════════════════════════════════════════ +// AJAX API HANDLER +// ═══════════════════════════════════════════════════════════ + +if (isset($_POST['action']) || isset($_GET['action'])) { + header('Content-Type: application/json'); + + $action = $_POST['action'] ?? $_GET['action']; + + // ─────────────────────────────────────────────────────── + // REGISTER + // ─────────────────────────────────────────────────────── + if ($action === 'register') { + $username = trim($_POST['username'] ?? ''); + $birthdate = trim($_POST['birthdate'] ?? ''); + $agreed_terms = isset($_POST['agreed_terms']) && $_POST['agreed_terms'] === 'true'; + + // Validierung + if (empty($username) || empty($birthdate)) { + echo json_encode(['success' => false, 'error' => 'Username und Geburtsdatum erforderlich']); + exit; + } + + if (!$agreed_terms) { + echo json_encode(['success' => false, 'error' => 'Bitte akzeptiere die Nutzungsbedingungen']); + exit; + } + + if (strlen($username) < 3 || strlen($username) > 15) { + echo json_encode(['success' => false, 'error' => 'Username muss 3-15 Zeichen lang sein']); + exit; + } + + if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) { + echo json_encode(['success' => false, 'error' => 'Nur Buchstaben, Zahlen und _ erlaubt']); + exit; + } + + // Verbotene Usernamen + $forbidden = ['admin', 'moderator', 'support', 'system', 'root']; + if (in_array(strtolower($username), $forbidden)) { + echo json_encode(['success' => false, 'error' => 'Dieser Username ist nicht erlaubt']); + exit; + } + + // Alter prüfen + $age = calculateAge($birthdate); + if ($age < 13) { + echo json_encode(['success' => false, 'error' => 'Du musst mindestens 13 Jahre alt sein']); + logSecurityEvent(null, 'REGISTER_UNDERAGE', "Username: $username, Age: $age"); + exit; + } + + $ageGroup = getAgeGroup($birthdate); + + $db = getDB(); + + // Generate unique user_id + $userId = generateUserId(); + $attempts = 0; + while ($attempts < 10) { + $stmt = $db->prepare('SELECT COUNT(*) as count FROM users WHERE user_id = :user_id'); + $stmt->bindValue(':user_id', $userId, SQLITE3_TEXT); + $result = $stmt->execute(); + $row = $result->fetchArray(SQLITE3_ASSOC); + + if ($row['count'] == 0) break; + + $userId = generateUserId(); + $attempts++; + } + + // Create user + $stmt = $db->prepare(' + INSERT INTO users (username, user_id, birthdate, age_group) + VALUES (:username, :user_id, :birthdate, :age_group) + '); + $stmt->bindValue(':username', $username, SQLITE3_TEXT); + $stmt->bindValue(':user_id', $userId, SQLITE3_TEXT); + $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; + $_SESSION['age_group'] = $ageGroup; + $_SESSION['birthdate'] = $birthdate; + + updateOnlineStatus($dbUserId); + logSecurityEvent($dbUserId, 'REGISTER', "Age group: $ageGroup"); + + echo json_encode([ + 'success' => true, + 'user_id' => $dbUserId, + 'username' => $username, + 'display_name' => $username . '#' . $userId, + 'age_group' => $ageGroup + ]); + exit; + } + + // ─────────────────────────────────────────────────────── + // LOGIN + // ─────────────────────────────────────────────────────── + if ($action === 'login') { + $username = trim($_POST['username'] ?? ''); + $birthdate = trim($_POST['birthdate'] ?? ''); + $forceLogin = in_array(($_POST['force_login'] ?? '0'), ['1', 'true', 'TRUE'], true); + + if ($username === '' || $birthdate === '') { + echo json_encode(['success' => false, 'error' => 'Bitte gib Username und Geburtsdatum ein.']); + exit; + } + + $db = getDB(); + $stmt = $db->prepare(' + SELECT id, username, user_id as display_id, birthdate, age_group, is_banned, ban_reason + FROM users + WHERE LOWER(username) = LOWER(:username) + LIMIT 1 + '); + $stmt->bindValue(':username', $username, SQLITE3_TEXT); + $result = $stmt->execute(); + $user = $result->fetchArray(SQLITE3_ASSOC); + + if (!$user) { + echo json_encode(['success' => false, 'error' => 'Account wurde nicht gefunden.']); + exit; + } + + if ((int)$user['is_banned'] === 1) { + $reason = $user['ban_reason'] ? (string)$user['ban_reason'] : 'Verstoß gegen Regeln'; + echo json_encode(['success' => false, 'error' => 'Dein Account ist gesperrt: ' . $reason]); + exit; + } + + if ($user['birthdate'] !== $birthdate) { + logSecurityEvent($user['id'], 'LOGIN_FAILED', 'Falsches Geburtsdatum'); + echo json_encode(['success' => false, 'error' => 'Daten stimmen nicht überein.']); + exit; + } + + $sessionResult = startUserSession($user['id'], $forceLogin); + if (!$sessionResult['allowed']) { + $response = [ + 'success' => false, + 'error' => $sessionResult['error'] ?? 'Anmeldung nicht möglich.' + ]; + + if (!empty($sessionResult['can_force'])) { + $response['can_force'] = true; + } + + echo json_encode($response); + exit; + } + + if ($forceLogin) { + logSecurityEvent($user['id'], 'LOGIN_FORCE', 'Sitzung übernommen'); + } + + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['user_display_id'] = $user['display_id']; + $_SESSION['age_group'] = $user['age_group']; + $_SESSION['birthdate'] = $user['birthdate']; + + updateOnlineStatus($user['id']); + logSecurityEvent($user['id'], 'LOGIN', 'Erfolgreiche Anmeldung'); + + echo json_encode([ + 'success' => true, + 'user_id' => $user['id'], + 'display_name' => $user['username'] . '#' . $user['display_id'], + 'age_group' => $user['age_group'] + ]); + exit; + } + + // ─────────────────────────────────────────────────────── + // ADMIN LOGIN + // ─────────────────────────────────────────────────────── + if ($action === 'admin_login') { + $username = trim($_POST['username'] ?? ''); + $password = trim($_POST['password'] ?? ''); + + $db = getDB(); + $stmt = $db->prepare('SELECT * FROM admins WHERE username = :username'); + $stmt->bindValue(':username', $username, SQLITE3_TEXT); + $result = $stmt->execute(); + $admin = $result->fetchArray(SQLITE3_ASSOC); + + if ($admin && password_verify($password, $admin['password_hash'])) { + $_SESSION['is_admin'] = true; + $_SESSION['admin_id'] = $admin['id']; + $_SESSION['admin_username'] = $admin['username']; + + logSecurityEvent(null, 'ADMIN_LOGIN', "Admin: $username"); + + echo json_encode(['success' => true]); + } else { + logSecurityEvent(null, 'ADMIN_LOGIN_FAILED', "Username: $username"); + echo json_encode(['success' => false, 'error' => 'Ungültige Zugangsdaten']); + } + exit; + } + + // ─────────────────────────────────────────────────────── + // LOGOUT + // ─────────────────────────────────────────────────────── + if ($action === 'logout') { + if (isLoggedIn()) { + $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) + // ─────────────────────────────────────────────────────── + if ($action === 'ping') { + updateOnlineStatus(getCurrentUserId()); + cleanupOldData(); + echo json_encode(['success' => true]); + exit; + } + + // ─────────────────────────────────────────────────────── + // GET USERS + // ─────────────────────────────────────────────────────── + if ($action === 'get_users') { + $db = getDB(); + $currentUserId = getCurrentUserId(); + $currentAgeGroup = getCurrentAgeGroup(); + + $query = ' + SELECT + u.id, + u.username, + u.user_id as display_id, + u.birthdate, + u.age_group, + u.last_seen, + CASE + WHEN os.last_ping IS NOT NULL + AND (julianday("now") - julianday(os.last_ping)) * 86400 < ' . ONLINE_TIMEOUT_SECONDS . ' + THEN 1 + ELSE 0 + END as is_online, + ( + SELECT COUNT(*) + FROM messages + WHERE from_user_id = u.id + AND to_user_id = :current_user_id + AND is_read = 0 + ) as unread_count, + ( + SELECT COUNT(*) + FROM blocks + WHERE blocker_id = :current_user_id + AND blocked_id = u.id + ) as is_blocked_by_me, + ( + SELECT COUNT(*) + FROM blocks + WHERE blocker_id = u.id + AND blocked_id = :current_user_id + ) as has_blocked_me + FROM users u + LEFT JOIN online_status os ON u.id = os.user_id + WHERE u.id != :current_user_id + AND u.is_banned = 0 + '; + + if ($currentAgeGroup === 'U18') { + $query .= ' AND (u.age_group = :allowed_group)'; + } else { + $query .= ' AND (u.age_group != :blocked_group OR u.age_group IS NULL)'; + } + + $query .= ' ORDER BY is_online DESC, u.username ASC'; + + $stmt = $db->prepare($query); + $stmt->bindValue(':current_user_id', $currentUserId, SQLITE3_INTEGER); + + if ($currentAgeGroup === 'U18') { + $stmt->bindValue(':allowed_group', 'U18', SQLITE3_TEXT); + } else { + $stmt->bindValue(':blocked_group', 'U18', SQLITE3_TEXT); + } + $result = $stmt->execute(); + + $users = []; + $rawCount = 0; + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $rawCount++; + + $resolvedAgeGroup = resolveStoredAgeGroup($row['age_group'] ?? '', $row['birthdate'] ?? ''); + + if (!canUsersChatByAge($currentAgeGroup, $resolvedAgeGroup)) { + continue; + } + + // Don't show users who blocked me or I blocked + if ($row['is_blocked_by_me'] > 0 || $row['has_blocked_me'] > 0) { + continue; + } + + $users[] = [ + 'id' => $row['id'], + 'username' => $row['username'], + 'display_id' => $row['display_id'], + 'display_name' => $row['username'] . '#' . $row['display_id'], + 'age_group' => $resolvedAgeGroup, + 'is_online' => $row['is_online'], + 'unread_count' => $row['unread_count'] + ]; + } + + if ($rawCount > 0 && count($users) === 0) { + logSecurityEvent( + $currentUserId, + 'GET_USERS_FILTERED_EMPTY', + sprintf( + 'Raw: %d | AgeGroup: %s', + $rawCount, + $currentAgeGroup + ) + ); + } + + echo json_encode([ + 'success' => true, + 'users' => $users, + 'diagnostics' => [ + 'raw_count' => $rawCount, + 'filtered_count' => count($users), + 'current_age_group' => $currentAgeGroup + ] + ]); + exit; + } + + // ─────────────────────────────────────────────────────── + // GET MESSAGES + // ─────────────────────────────────────────────────────── + if ($action === 'get_messages') { + $otherUserId = intval($_GET['user_id'] ?? 0); + + if ($otherUserId <= 0) { + echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); + exit; + } + + // Check if blocked + if (isBlocked(getCurrentUserId(), $otherUserId)) { + echo json_encode(['success' => false, 'error' => 'Chat nicht verfügbar']); + exit; + } + + $db = getDB(); + $currentUserId = getCurrentUserId(); + $currentAgeGroup = getCurrentAgeGroup(); + + $stmt = $db->prepare('SELECT age_group FROM users WHERE id = :user_id AND is_banned = 0'); + $stmt->bindValue(':user_id', $otherUserId, SQLITE3_INTEGER); + $result = $stmt->execute(); + $otherUser = $result->fetchArray(SQLITE3_ASSOC); + + if (!$otherUser) { + echo json_encode(['success' => false, 'error' => 'Benutzer nicht gefunden']); + exit; + } + + if (!canUsersChatByAge($currentAgeGroup, $otherUser['age_group'])) { + logSecurityEvent($currentUserId, 'AGE_RESTRICTION_BLOCKED', "GET_MESSAGES -> User $otherUserId"); + echo json_encode(['success' => false, 'error' => 'Chat zwischen Altersgruppen nicht erlaubt']); + exit; + } + + $query = ' + SELECT * FROM ( + SELECT + m.id, + m.from_user_id, + m.to_user_id, + m.message, + m.timestamp, + m.is_read, + m.is_flagged, + m.attachment_path, + m.attachment_type, + m.attachment_size, + 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 = []; + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $messages[] = [ + 'id' => $row['id'], + 'from_user_id' => $row['from_user_id'], + 'to_user_id' => $row['to_user_id'], + 'message' => $row['message'], + 'timestamp' => $row['timestamp'], + 'is_read' => $row['is_read'], + 'is_flagged' => $row['is_flagged'], + 'attachment_url' => $row['attachment_path'] ?: null, + 'attachment_type' => $row['attachment_type'] ?: null, + 'attachment_size' => $row['attachment_size'] !== null ? (int)$row['attachment_size'] : null, + 'from_username' => $row['from_username'], + 'from_display_name' => $row['from_username'] . '#' . $row['from_display_id'] + ]; + } + + echo json_encode(['success' => true, 'messages' => $messages]); + exit; + } + + // ─────────────────────────────────────────────────────── + // SEND MESSAGE + // ─────────────────────────────────────────────────────── + if ($action === 'send_message') { + $toUserId = intval($_POST['to_user_id'] ?? 0); + $message = trim($_POST['message'] ?? ''); + $hasAttachment = isset($_FILES['attachment']) && ($_FILES['attachment']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE; + + if ($toUserId <= 0) { + echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); + exit; + } + + if (!$hasAttachment && $message === '') { + echo json_encode(['success' => false, 'error' => 'Nachricht oder Bild erforderlich']); + exit; + } + + if (strlen($message) > 1000) { + echo json_encode(['success' => false, 'error' => 'Nachricht zu lang (max 1000 Zeichen)']); + exit; + } + + $attachmentFile = $hasAttachment ? $_FILES['attachment'] : null; + $attachmentMime = null; + $attachmentSize = null; + + if ($hasAttachment && $attachmentFile) { + if (!is_uploaded_file($attachmentFile['tmp_name'])) { + echo json_encode(['success' => false, 'error' => 'Ungültiger Datei-Upload']); + exit; + } + + if ($attachmentFile['error'] !== UPLOAD_ERR_OK) { + echo json_encode(['success' => false, 'error' => 'Bild konnte nicht hochgeladen werden']); + exit; + } + + if ($attachmentFile['size'] > MAX_ATTACHMENT_SIZE) { + echo json_encode(['success' => false, 'error' => 'Bild ist zu groß (max. 200 KB)']); + exit; + } + + $attachmentSize = (int)$attachmentFile['size']; + + $mime = null; + if (function_exists('finfo_open')) { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + if ($finfo) { + $mime = finfo_file($finfo, $attachmentFile['tmp_name']); + finfo_close($finfo); + } + } + if (!$mime && function_exists('mime_content_type')) { + $mime = mime_content_type($attachmentFile['tmp_name']); + } + if (!$mime && isset($attachmentFile['type'])) { + $mime = $attachmentFile['type']; + } + + $mime = strtolower((string)$mime); + if (!in_array($mime, ['image/jpeg', 'image/pjpeg', 'image/jpg'], true)) { + echo json_encode(['success' => false, 'error' => 'Nur JPG-Bilder sind erlaubt']); + exit; + } + + $imageInfo = @getimagesize($attachmentFile['tmp_name']); + if ($imageInfo === false || !in_array($imageInfo[2], [IMAGETYPE_JPEG], true)) { + echo json_encode(['success' => false, 'error' => 'Bilddatei konnte nicht verifiziert werden']); + exit; + } + + $attachmentMime = 'image/jpeg'; + } + + // Check if blocked + if (isBlocked(getCurrentUserId(), $toUserId)) { + echo json_encode(['success' => false, 'error' => 'Nachricht kann nicht gesendet werden']); + exit; + } + + $db = getDB(); + $currentUserId = getCurrentUserId(); + $currentAgeGroup = getCurrentAgeGroup(); + + $stmt = $db->prepare('SELECT age_group FROM users WHERE id = :user_id AND is_banned = 0'); + $stmt->bindValue(':user_id', $toUserId, SQLITE3_INTEGER); + $result = $stmt->execute(); + $targetUser = $result->fetchArray(SQLITE3_ASSOC); + + if (!$targetUser) { + echo json_encode(['success' => false, 'error' => 'Empfänger nicht gefunden']); + exit; + } + + if (!canUsersChatByAge($currentAgeGroup, $targetUser['age_group'])) { + logSecurityEvent($currentUserId, 'AGE_RESTRICTION_BLOCKED', "SEND_MESSAGE -> User $toUserId"); + echo json_encode(['success' => false, 'error' => 'Nachrichten zwischen Altersgruppen nicht erlaubt']); + exit; + } + + // Rate Limiting + $rateLimitCheck = checkRateLimit($currentUserId, $currentAgeGroup); + if (!$rateLimitCheck['allowed']) { + logSecurityEvent($currentUserId, 'RATE_LIMIT_EXCEEDED', $rateLimitCheck['reason']); + echo json_encode(['success' => false, 'error' => $rateLimitCheck['reason']]); + exit; + } + + if ($message !== '') { + // Keyword Blacklist + $keywordCheck = checkKeywordBlacklist($message); + if ($keywordCheck['blocked']) { + logSecurityEvent($currentUserId, 'KEYWORD_BLOCKED', "Keyword: {$keywordCheck['keyword']}"); + echo json_encode([ + 'success' => false, + 'error' => 'Deine Nachricht enthält nicht erlaubte Inhalte', + 'details' => 'Verbotenes Wort erkannt: ' . $keywordCheck['keyword'] + ]); + exit; + } + + // Profanity Filter + $profanityCheck = checkProfanityFilter($message); + if ($profanityCheck['blocked']) { + logSecurityEvent($currentUserId, 'PROFANITY_BLOCKED', "Word: {$profanityCheck['word']}"); + echo json_encode([ + 'success' => false, + 'error' => 'Deine Nachricht enthält Schimpfwörter', + 'details' => 'Bitte verwende eine angemessene Sprache' + ]); + exit; + } + + // Link Filter + $linkCheck = checkLinkFilter($message); + if ($linkCheck['blocked']) { + logSecurityEvent($currentUserId, 'LINK_BLOCKED', "Message: $message"); + echo json_encode([ + 'success' => false, + 'error' => 'Links sind nicht erlaubt', + 'details' => 'Aus Sicherheitsgründen können keine URLs gesendet werden' + ]); + exit; + } + } + + // Auto-Flagging (verdächtige Muster) + $isFlagged = 0; + $flagReason = ''; + + if ($message !== '') { + if (preg_match('/(.)\1{5,}/', $message)) { + $isFlagged = 1; + $flagReason = 'Repeated characters'; + } + + if (strlen($message) > 20 && $message === strtoupper($message)) { + $isFlagged = 1; + $flagReason = 'All caps'; + } + + $emojiCount = preg_match_all('/[\x{1F600}-\x{1F64F}]/u', $message); + if ($emojiCount > 10) { + $isFlagged = 1; + $flagReason = 'Excessive emojis'; + } + } + + $attachmentPath = null; + if ($hasAttachment && $attachmentFile) { + $randomName = bin2hex(random_bytes(16)) . '.jpg'; + $destination = rtrim(UPLOAD_DIR, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $randomName; + + if (!move_uploaded_file($attachmentFile['tmp_name'], $destination)) { + echo json_encode(['success' => false, 'error' => 'Bild konnte nicht gespeichert werden']); + exit; + } + + $attachmentPath = 'uploads/' . $randomName; + } + + // Insert message + $stmt = $db->prepare(' + INSERT INTO messages (from_user_id, to_user_id, message, is_flagged, flag_reason, attachment_path, attachment_type, attachment_size) + VALUES (:from_user_id, :to_user_id, :message, :is_flagged, :flag_reason, :attachment_path, :attachment_type, :attachment_size) + '); + $stmt->bindValue(':from_user_id', $currentUserId, SQLITE3_INTEGER); + $stmt->bindValue(':to_user_id', $toUserId, SQLITE3_INTEGER); + $stmt->bindValue(':message', $message, SQLITE3_TEXT); + $stmt->bindValue(':is_flagged', $isFlagged, SQLITE3_INTEGER); + $stmt->bindValue(':flag_reason', $flagReason, SQLITE3_TEXT); + if ($attachmentPath) { + $stmt->bindValue(':attachment_path', $attachmentPath, SQLITE3_TEXT); + $stmt->bindValue(':attachment_type', $attachmentMime, SQLITE3_TEXT); + $stmt->bindValue(':attachment_size', $attachmentSize, SQLITE3_INTEGER); + } else { + $stmt->bindValue(':attachment_path', null, SQLITE3_NULL); + $stmt->bindValue(':attachment_type', null, SQLITE3_NULL); + $stmt->bindValue(':attachment_size', null, SQLITE3_NULL); + } + $stmt->execute(); + + $messageId = $db->lastInsertRowID(); + + // Log rate limit + logRateLimit($currentUserId); + + if ($isFlagged) { + logSecurityEvent($currentUserId, 'MESSAGE_FLAGGED', "Reason: $flagReason, Message ID: $messageId"); + } + + if ($attachmentPath) { + logSecurityEvent($currentUserId, 'ATTACHMENT_UPLOADED', "Message ID: $messageId, Size: $attachmentSize"); + } + + echo json_encode([ + 'success' => true, + 'message_id' => $messageId, + 'timestamp' => date('Y-m-d H:i:s'), + 'attachment_url' => $attachmentPath + ]); + exit; + } + + // ─────────────────────────────────────────────────────── + // MARK AS READ + // ─────────────────────────────────────────────────────── + if ($action === 'mark_read') { + $otherUserId = intval($_POST['user_id'] ?? 0); + + if ($otherUserId <= 0) { + echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); + exit; + } + + $db = getDB(); + $currentUserId = getCurrentUserId(); + $currentAgeGroup = getCurrentAgeGroup(); + + $stmt = $db->prepare('SELECT age_group FROM users WHERE id = :user_id AND is_banned = 0'); + $stmt->bindValue(':user_id', $otherUserId, SQLITE3_INTEGER); + $result = $stmt->execute(); + $otherUser = $result->fetchArray(SQLITE3_ASSOC); + + if (!$otherUser) { + echo json_encode(['success' => false, 'error' => 'Benutzer nicht gefunden']); + exit; + } + + if (!canUsersChatByAge($currentAgeGroup, $otherUser['age_group'])) { + logSecurityEvent($currentUserId, 'AGE_RESTRICTION_BLOCKED', "MARK_READ -> User $otherUserId"); + echo json_encode(['success' => false, 'error' => 'Aktion zwischen Altersgruppen nicht erlaubt']); + exit; + } + + $stmt = $db->prepare(' + UPDATE messages + SET is_read = 1 + WHERE from_user_id = :other_user_id + AND to_user_id = :current_user_id + AND is_read = 0 + '); + $stmt->bindValue(':other_user_id', $otherUserId, SQLITE3_INTEGER); + $stmt->bindValue(':current_user_id', $currentUserId, SQLITE3_INTEGER); + $stmt->execute(); + + echo json_encode(['success' => true]); + exit; + } + + // ─────────────────────────────────────────────────────── + // BLOCK USER + // ─────────────────────────────────────────────────────── + if ($action === 'block_user') { + $blockedUserId = intval($_POST['user_id'] ?? 0); + + if ($blockedUserId <= 0) { + echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); + exit; + } + + $db = getDB(); + $currentUserId = getCurrentUserId(); + + $stmt = $db->prepare(' + INSERT OR IGNORE INTO blocks (blocker_id, blocked_id) + VALUES (:blocker_id, :blocked_id) + '); + $stmt->bindValue(':blocker_id', $currentUserId, SQLITE3_INTEGER); + $stmt->bindValue(':blocked_id', $blockedUserId, SQLITE3_INTEGER); + $stmt->execute(); + + logSecurityEvent($currentUserId, 'USER_BLOCKED', "Blocked user ID: $blockedUserId"); + + echo json_encode(['success' => true]); + exit; + } + + // ─────────────────────────────────────────────────────── + // UNBLOCK USER + // ─────────────────────────────────────────────────────── + if ($action === 'unblock_user') { + $blockedUserId = intval($_POST['user_id'] ?? 0); + + if ($blockedUserId <= 0) { + echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); + exit; + } + + $db = getDB(); + $currentUserId = getCurrentUserId(); + + $stmt = $db->prepare(' + DELETE FROM blocks + WHERE blocker_id = :blocker_id + AND blocked_id = :blocked_id + '); + $stmt->bindValue(':blocker_id', $currentUserId, SQLITE3_INTEGER); + $stmt->bindValue(':blocked_id', $blockedUserId, SQLITE3_INTEGER); + $stmt->execute(); + + logSecurityEvent($currentUserId, 'USER_UNBLOCKED', "Unblocked user ID: $blockedUserId"); + + echo json_encode(['success' => true]); + exit; + } + + // ─────────────────────────────────────────────────────── + // REPORT USER + // ─────────────────────────────────────────────────────── + if ($action === 'report_user') { + $reportedUserId = intval($_POST['user_id'] ?? 0); + $reason = trim($_POST['reason'] ?? ''); + $messageId = intval($_POST['message_id'] ?? 0); + + if ($reportedUserId <= 0) { + echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); + exit; + } + + if (empty($reason)) { + echo json_encode(['success' => false, 'error' => 'Bitte gib einen Grund an']); + exit; + } + + $db = getDB(); + $currentUserId = getCurrentUserId(); + + $stmt = $db->prepare(' + INSERT INTO reports (reporter_id, reported_user_id, reason, message_id) + VALUES (:reporter_id, :reported_user_id, :reason, :message_id) + '); + $stmt->bindValue(':reporter_id', $currentUserId, SQLITE3_INTEGER); + $stmt->bindValue(':reported_user_id', $reportedUserId, SQLITE3_INTEGER); + $stmt->bindValue(':reason', $reason, SQLITE3_TEXT); + $stmt->bindValue(':message_id', $messageId > 0 ? $messageId : null, SQLITE3_INTEGER); + $stmt->execute(); + + logSecurityEvent($currentUserId, 'USER_REPORTED', "Reported user ID: $reportedUserId, Reason: $reason"); + + echo json_encode(['success' => true, 'message' => 'Meldung wurde erfolgreich gesendet']); + exit; + } + + // ─────────────────────────────────────────────────────── + // GET BLOCKED USERS + // ─────────────────────────────────────────────────────── + if ($action === 'get_blocked_users') { + $db = getDB(); + $currentUserId = getCurrentUserId(); + + $query = ' + SELECT + u.id, + u.username, + u.user_id as display_id, + b.timestamp as blocked_at + FROM blocks b + JOIN users u ON b.blocked_id = u.id + WHERE b.blocker_id = :current_user_id + ORDER BY b.timestamp DESC + '; + + $stmt = $db->prepare($query); + $stmt->bindValue(':current_user_id', $currentUserId, SQLITE3_INTEGER); + $result = $stmt->execute(); + + $blockedUsers = []; + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $blockedUsers[] = [ + 'id' => $row['id'], + 'display_name' => $row['username'] . '#' . $row['display_id'], + 'blocked_at' => $row['blocked_at'] + ]; + } + + echo json_encode(['success' => true, 'blocked_users' => $blockedUsers]); + exit; + } + + // ═══════════════════════════════════════════════════════════ + // ADMIN ACTIONS + // ═══════════════════════════════════════════════════════════ + + if (!isAdmin()) { + echo json_encode(['success' => false, 'error' => 'Admin-Rechte erforderlich']); + exit; + } + + // ─────────────────────────────────────────────────────── + // ADMIN: GET REPORTS + // ─────────────────────────────────────────────────────── + if ($action === 'admin_get_reports') { + $db = getDB(); + + $query = ' + SELECT + r.id, + r.reason, + r.timestamp, + r.status, + r.message_id, + reporter.username as reporter_name, + reporter.user_id as reporter_display_id, + reported.username as reported_name, + reported.user_id as reported_display_id, + reported.id as reported_user_id, + m.message as message_content + FROM reports r + JOIN users reporter ON r.reporter_id = reporter.id + JOIN users reported ON r.reported_user_id = reported.id + LEFT JOIN messages m ON r.message_id = m.id + WHERE r.status = "pending" + ORDER BY r.timestamp DESC + LIMIT 50 + '; + + $result = $db->query($query); + + $reports = []; + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $reports[] = [ + 'id' => $row['id'], + 'reporter' => $row['reporter_name'] . '#' . $row['reporter_display_id'], + 'reported' => $row['reported_name'] . '#' . $row['reported_display_id'], + 'reported_user_id' => $row['reported_user_id'], + 'reason' => $row['reason'], + 'message' => $row['message_content'], + 'timestamp' => $row['timestamp'], + 'status' => $row['status'] + ]; + } + + echo json_encode(['success' => true, 'reports' => $reports]); + exit; + } + + // ─────────────────────────────────────────────────────── + // ADMIN: GET FLAGGED MESSAGES + // ─────────────────────────────────────────────────────── + if ($action === 'admin_get_flagged') { + $db = getDB(); + + $query = ' + SELECT + m.id, + m.message, + m.flag_reason, + m.timestamp, + u.username, + u.user_id as display_id, + u.id as user_db_id + FROM messages m + JOIN users u ON m.from_user_id = u.id + WHERE m.is_flagged = 1 + ORDER BY m.timestamp DESC + LIMIT 50 + '; + + $result = $db->query($query); + + $flagged = []; + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $flagged[] = [ + 'id' => $row['id'], + 'user' => $row['username'] . '#' . $row['display_id'], + 'user_id' => $row['user_db_id'], + 'message' => $row['message'], + 'reason' => $row['flag_reason'], + 'timestamp' => $row['timestamp'] + ]; + } + + echo json_encode(['success' => true, 'flagged' => $flagged]); + exit; + } + + // ─────────────────────────────────────────────────────── + // ADMIN: GET STATISTICS + // ─────────────────────────────────────────────────────── + if ($action === 'admin_get_stats') { + $db = getDB(); + + // Total users + $result = $db->query('SELECT COUNT(*) as count FROM users WHERE is_banned = 0'); + $totalUsers = $result->fetchArray(SQLITE3_ASSOC)['count']; + + // U18 users + $result = $db->query('SELECT COUNT(*) as count FROM users WHERE age_group = "U18" AND is_banned = 0'); + $u18Users = $result->fetchArray(SQLITE3_ASSOC)['count']; + + // O18 users + $result = $db->query('SELECT COUNT(*) as count FROM users WHERE age_group = "O18" AND is_banned = 0'); + $o18Users = $result->fetchArray(SQLITE3_ASSOC)['count']; + + // Online users + $result = $db->query(' + SELECT COUNT(*) as count + FROM online_status + WHERE (julianday("now") - julianday(last_ping)) * 86400 < ' . ONLINE_TIMEOUT_SECONDS + ); + $onlineUsers = $result->fetchArray(SQLITE3_ASSOC)['count']; + + // Total messages (24h) + $result = $db->query('SELECT COUNT(*) as count FROM messages'); + $totalMessages = $result->fetchArray(SQLITE3_ASSOC)['count']; + + // Flagged messages + $result = $db->query('SELECT COUNT(*) as count FROM messages WHERE is_flagged = 1'); + $flaggedMessages = $result->fetchArray(SQLITE3_ASSOC)['count']; + + // Pending reports + $result = $db->query('SELECT COUNT(*) as count FROM reports WHERE status = "pending"'); + $pendingReports = $result->fetchArray(SQLITE3_ASSOC)['count']; + + // Banned users + $result = $db->query('SELECT COUNT(*) as count FROM users WHERE is_banned = 1'); + $bannedUsers = $result->fetchArray(SQLITE3_ASSOC)['count']; + + echo json_encode([ + 'success' => true, + 'stats' => [ + 'total_users' => $totalUsers, + 'u18_users' => $u18Users, + 'o18_users' => $o18Users, + 'online_users' => $onlineUsers, + 'total_messages' => $totalMessages, + 'flagged_messages' => $flaggedMessages, + 'pending_reports' => $pendingReports, + 'banned_users' => $bannedUsers + ] + ]); + exit; + } + + if ($action === 'admin_get_banned_users') { + $db = getDB(); + + $result = $db->query(' + SELECT id, username, user_id as display_id, ban_reason, last_seen + FROM users + WHERE is_banned = 1 + ORDER BY last_seen DESC + LIMIT 100 + '); + + $banned = []; + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $banned[] = [ + 'id' => $row['id'], + 'display_name' => $row['username'] . '#' . $row['display_id'], + 'reason' => $row['ban_reason'] ?? 'keine Angabe', + 'last_seen' => $row['last_seen'] + ]; + } + + echo json_encode(['success' => true, 'banned' => $banned]); + exit; + } + + // ─────────────────────────────────────────────────────── + // ADMIN: BAN USER + // ─────────────────────────────────────────────────────── + if ($action === 'admin_ban_user') { + $userId = intval($_POST['user_id'] ?? 0); + $reason = trim($_POST['reason'] ?? 'Verstoß gegen Nutzungsbedingungen'); + + if ($userId <= 0) { + echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); + exit; + } + + $db = getDB(); + $stmt = $db->prepare(' + UPDATE users + SET is_banned = 1, ban_reason = :reason + WHERE id = :user_id + '); + $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); + $stmt->bindValue(':reason', $reason, SQLITE3_TEXT); + $stmt->execute(); + + logSecurityEvent(null, 'ADMIN_BAN_USER', "User ID: $userId, Reason: $reason"); + + echo json_encode(['success' => true, 'message' => 'User wurde gesperrt']); + exit; + } + + // ─────────────────────────────────────────────────────── + // ADMIN: UNBAN USER + // ─────────────────────────────────────────────────────── + if ($action === 'admin_unban_user') { + $userId = intval($_POST['user_id'] ?? 0); + + if ($userId <= 0) { + echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); + exit; + } + + $db = getDB(); + $stmt = $db->prepare(' + UPDATE users + SET is_banned = 0, ban_reason = NULL + WHERE id = :user_id + '); + $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); + $stmt->execute(); + + logSecurityEvent(null, 'ADMIN_UNBAN_USER', "User ID: $userId"); + + echo json_encode(['success' => true, 'message' => 'Sperre wurde aufgehoben']); + exit; + } + + // ─────────────────────────────────────────────────────── + // ADMIN: RESOLVE REPORT + // ─────────────────────────────────────────────────────── + if ($action === 'admin_resolve_report') { + $reportId = intval($_POST['report_id'] ?? 0); + $action_taken = trim($_POST['action_taken'] ?? 'resolved'); + + if ($reportId <= 0) { + echo json_encode(['success' => false, 'error' => 'Ungültige Report-ID']); + exit; + } + + $db = getDB(); + $stmt = $db->prepare(' + UPDATE reports + SET status = :status + WHERE id = :report_id + '); + $stmt->bindValue(':report_id', $reportId, SQLITE3_INTEGER); + $stmt->bindValue(':status', $action_taken, SQLITE3_TEXT); + $stmt->execute(); + + logSecurityEvent(null, 'ADMIN_RESOLVE_REPORT', "Report ID: $reportId, Action: $action_taken"); + + echo json_encode(['success' => true, 'message' => 'Report wurde bearbeitet']); + exit; + } + + // ─────────────────────────────────────────────────────── + // ADMIN: DELETE MESSAGE + // ─────────────────────────────────────────────────────── + if ($action === 'admin_delete_message') { + $messageId = intval($_POST['message_id'] ?? 0); + + if ($messageId <= 0) { + echo json_encode(['success' => false, 'error' => 'Ungültige Message-ID']); + exit; + } + + $db = getDB(); + $stmt = $db->prepare('DELETE FROM messages WHERE id = :message_id'); + $stmt->bindValue(':message_id', $messageId, SQLITE3_INTEGER); + $stmt->execute(); + + logSecurityEvent(null, 'ADMIN_DELETE_MESSAGE', "Message ID: $messageId"); + + echo json_encode(['success' => true, 'message' => 'Nachricht wurde gelöscht']); + exit; + } + + if ($action === 'poll_updates') { + if (!isLoggedIn()) { + echo json_encode(['success' => false, 'error' => 'Nicht angemeldet']); + exit; + } + + if (!validateActiveSession()) { + echo json_encode(['success' => false, 'error' => 'Sitzung ungültig']); + exit; + } + + $lastMessageId = intval($_POST['last_message_id'] ?? $_GET['last_message_id'] ?? 0); + + $db = getDB(); + $currentUserId = getCurrentUserId(); + $currentAgeGroup = getCurrentAgeGroup(); + + touchUserSession($currentUserId); + + $stmt = $db->prepare(' + SELECT + m.id, + m.from_user_id, + m.to_user_id, + m.message, + m.timestamp, + m.attachment_path, + m.attachment_type, + m.attachment_size, + uf.username as from_username, + uf.user_id as from_display_id, + uf.age_group as from_age_group, + ut.age_group as to_age_group + FROM messages m + JOIN users uf ON m.from_user_id = uf.id + JOIN users ut ON m.to_user_id = ut.id + WHERE m.id > :last_message_id + AND (m.to_user_id = :current_user_id OR m.from_user_id = :current_user_id) + AND NOT EXISTS ( + SELECT 1 FROM blocks + WHERE (blocker_id = :current_user_id AND blocked_id = m.from_user_id) + OR (blocker_id = m.from_user_id AND blocked_id = :current_user_id) + ) + ORDER BY m.id ASC + '); + $stmt->bindValue(':last_message_id', $lastMessageId, SQLITE3_INTEGER); + $stmt->bindValue(':current_user_id', $currentUserId, SQLITE3_INTEGER); + $result = $stmt->execute(); + + $messages = []; + $maxId = $lastMessageId; + + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $otherAgeGroup = $row['from_user_id'] === $currentUserId ? $row['to_age_group'] : $row['from_age_group']; + + if (!canUsersChatByAge($currentAgeGroup, $otherAgeGroup)) { + continue; + } + + $maxId = max($maxId, (int)$row['id']); + + $messages[] = [ + 'id' => $row['id'], + 'from_user_id' => $row['from_user_id'], + 'to_user_id' => $row['to_user_id'], + 'message' => $row['message'], + 'timestamp' => $row['timestamp'], + 'attachment_url' => $row['attachment_path'] ?: null, + 'attachment_type' => $row['attachment_type'] ?: null, + 'attachment_size' => $row['attachment_size'] !== null ? (int)$row['attachment_size'] : null, + 'from_username' => $row['from_username'], + 'from_display_name' => $row['from_username'] . '#' . $row['from_display_id'] + ]; + } + + echo json_encode([ + 'success' => true, + 'messages' => $messages, + 'last_message_id' => $maxId + ]); + exit; + } + + echo json_encode(['success' => false, 'error' => 'Unbekannte Aktion']); + exit; +} + +// ═══════════════════════════════════════════════════════════ +// SSE STREAM (ECHTZEIT) +// ═══════════════════════════════════════════════════════════ + +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) { + @ob_end_flush(); + } + + echo ": connected\n\n"; + echo "retry: " . SSE_RETRY_MS . "\n\n"; + flush(); + + $lastPingTime = time(); + + while (true) { + if (connection_aborted()) { + break; + } + + $db = getDB(); + + $stmt = $db->prepare(' + SELECT + m.id, + m.from_user_id, + m.to_user_id, + m.message, + m.timestamp, + m.attachment_path, + m.attachment_type, + m.attachment_size, + uf.username as from_username, + uf.user_id as from_display_id, + uf.age_group as from_age_group, + ut.age_group as to_age_group + FROM messages m + JOIN users uf ON m.from_user_id = uf.id + JOIN users ut ON m.to_user_id = ut.id + WHERE m.id > :last_message_id + AND (m.to_user_id = :current_user_id OR m.from_user_id = :current_user_id) + AND NOT EXISTS ( + SELECT 1 FROM blocks + WHERE (blocker_id = :current_user_id AND blocked_id = m.from_user_id) + OR (blocker_id = m.from_user_id AND blocked_id = :current_user_id) + ) + ORDER BY m.id ASC + '); + $stmt->bindValue(':last_message_id', $lastMessageId, SQLITE3_INTEGER); + $stmt->bindValue(':current_user_id', $currentUserId, SQLITE3_INTEGER); + $result = $stmt->execute(); + + $messages = []; + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $otherAgeGroup = $row['from_user_id'] === $currentUserId ? $row['to_age_group'] : $row['from_age_group']; + + if (!canUsersChatByAge($currentAgeGroup, $otherAgeGroup)) { + continue; + } + + $messages[] = [ + 'id' => $row['id'], + 'from_user_id' => $row['from_user_id'], + 'to_user_id' => $row['to_user_id'], + 'message' => $row['message'], + 'timestamp' => $row['timestamp'], + 'attachment_url' => $row['attachment_path'] ?: null, + 'attachment_type' => $row['attachment_type'] ?: null, + 'attachment_size' => $row['attachment_size'] !== null ? (int)$row['attachment_size'] : null, + 'from_username' => $row['from_username'], + 'from_display_name' => $row['from_username'] . '#' . $row['from_display_id'] + ]; + + $lastMessageId = max($lastMessageId, (int)$row['id']); + } + + if (!empty($messages)) { + echo "data: " . json_encode(['type' => 'messages', 'messages' => $messages]) . "\n\n"; + flush(); + } + + if (time() - $lastPingTime >= 15) { + echo "data: " . json_encode(['type' => 'ping']) . "\n\n"; + flush(); + $lastPingTime = time(); + touchUserSession($currentUserId); + } + + usleep(500000); + } + + exit; +} + +// ═══════════════════════════════════════════════════════════ +// HTML OUTPUT +// ═══════════════════════════════════════════════════════════ +?> + + + + + + + 💬 Secure Private Chat + + + + + + +
+
+

🔐 Admin-Dashboard

+ +
+ +
+ +
+ +
+
+
+

🚨 Offene Meldungen

+
+
+
Lade Meldungen…
+
+
+ +
+

🚩 Markierte Nachrichten

+
+
Lade Nachrichten…
+
+
+ +
+

🚫 Gesperrte Nutzer

+
+
Lade Nutzer…
+
+
+
+
+ + + + + + +
+

💬 Secure Private Chat

+

Sicherer Chat mit Altersverifikation

+ +
+ + +
+ +
+
+ +
+
+ + +
+ +
+ + +
+ +
+

⚠️ Wichtige Regeln

+
    +
  • Gib NIEMALS persönliche Daten weiter (Adresse, Telefon, etc.)
  • +
  • Treffe dich NICHT mit Fremden
  • +
  • Bleibe respektvoll und freundlich
  • +
  • Keine Links oder externe Kontakte teilen
  • +
  • Bei verdächtigem Verhalten: Melde den User!
  • +
+
+ +
+ + +
+ + +
+ + + + +
+ + + +
+ +
+

💬 Secure Private Chat

+ +
+ + + + + +
+
+
💬
+
Wähle einen Benutzer aus der Liste
+
+ +
+
+ +
+ + +
+ +
+ +
+
+ + + +
+ + +
+ +
+
+
+ + + + + From 4a64b3a342972c0d20f13301e9b6b9fa398911e1 Mon Sep 17 00:00:00 2001 From: Metacube Date: Tue, 4 Nov 2025 10:04:55 +0100 Subject: [PATCH 08/10] Update chat.php From 994d16d7445098459efc5dc87150df15f7e4ba59 Mon Sep 17 00:00:00 2001 From: Metacube Date: Tue, 4 Nov 2025 10:17:38 +0100 Subject: [PATCH 09/10] Make chat filters optional and improve fallback connectivity --- chat.php | 478 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 428 insertions(+), 50 deletions(-) diff --git a/chat.php b/chat.php index 6f013ca..eeb1b28 100644 --- a/chat.php +++ b/chat.php @@ -217,6 +217,17 @@ function getDB() { ) '); + // Settings Table (Feature-Flags) + $db->exec(' + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + '); + + initializeDefaultSettings($db); + // Create default admin if not exists $stmt = $db->prepare('SELECT COUNT(*) as count FROM admins WHERE username = :username'); $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); @@ -277,6 +288,115 @@ function resolveStoredAgeGroup($storedAgeGroup, $birthdate) { return 'O18'; } +function initializeDefaultSettings(SQLite3 $db) { + static $defaultsInitialized = false; + + if ($defaultsInitialized) { + return; + } + + $defaultsInitialized = true; + + $defaults = [ + 'age_filter_enabled' => '0', + 'keyword_filter_enabled' => '0', + 'profanity_filter_enabled' => '0', + 'link_filter_enabled' => '0', + ]; + + $stmt = $db->prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (:key, :value)'); + + foreach ($defaults as $key => $value) { + $stmt->bindValue(':key', $key, SQLITE3_TEXT); + $stmt->bindValue(':value', $value, SQLITE3_TEXT); + $stmt->execute(); + } +} + +function settingsCache($forceReload = false) { + static $cache = null; + + if ($forceReload) { + $cache = null; + } + + if ($cache === null) { + $cache = []; + $db = getDB(); + $result = $db->query('SELECT key, value FROM settings'); + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $cache[$row['key']] = $row['value']; + } + } + + return $cache; +} + +function getSetting($key, $default = null) { + $cache = settingsCache(); + return array_key_exists($key, $cache) ? $cache[$key] : $default; +} + +function setSettingValue($key, $value) { + $db = getDB(); + $stmt = $db->prepare(' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (:key, :value, CURRENT_TIMESTAMP) + '); + $stmt->bindValue(':key', $key, SQLITE3_TEXT); + $stmt->bindValue(':value', $value, SQLITE3_TEXT); + $stmt->execute(); + + settingsCache(true); +} + +function normalizeBooleanFlag($value) { + if (is_bool($value)) { + return $value; + } + + if (is_int($value)) { + return $value === 1; + } + + $stringValue = is_string($value) ? strtolower(trim($value)) : ''; + if ($stringValue === '') { + return false; + } + + return in_array($stringValue, ['1', 'true', 'yes', 'on'], true); +} + +function isFeatureEnabled($settingKey, $default = false) { + $defaultValue = $default ? '1' : '0'; + return normalizeBooleanFlag(getSetting($settingKey, $defaultValue)); +} + +function isAgeFilterEnabled() { + return isFeatureEnabled('age_filter_enabled', false); +} + +function isKeywordFilterEnabled() { + return isFeatureEnabled('keyword_filter_enabled', false); +} + +function isProfanityFilterEnabled() { + return isFeatureEnabled('profanity_filter_enabled', false); +} + +function isLinkFilterEnabled() { + return isFeatureEnabled('link_filter_enabled', false); +} + +function getFeatureSettings() { + return [ + 'age_filter_enabled' => isAgeFilterEnabled(), + 'keyword_filter_enabled' => isKeywordFilterEnabled(), + 'profanity_filter_enabled' => isProfanityFilterEnabled(), + 'link_filter_enabled' => isLinkFilterEnabled(), + ]; +} + function checkKeywordBlacklist($message) { global $KEYWORD_BLACKLIST; @@ -400,6 +520,10 @@ function logSecurityEvent($userId, $action, $details = '') { } function canUsersChatByAge($ageGroupA, $ageGroupB) { + if (!isAgeFilterEnabled()) { + return true; + } + if (!$ageGroupA || !$ageGroupB) { return false; } @@ -895,6 +1019,8 @@ if (isset($_POST['action']) || isset($_GET['action'])) { $currentUserId = getCurrentUserId(); $currentAgeGroup = getCurrentAgeGroup(); + $ageFilterEnabled = isAgeFilterEnabled(); + $query = ' SELECT u.id, @@ -934,10 +1060,12 @@ if (isset($_POST['action']) || isset($_GET['action'])) { AND u.is_banned = 0 '; - if ($currentAgeGroup === 'U18') { - $query .= ' AND (u.age_group = :allowed_group)'; - } else { - $query .= ' AND (u.age_group != :blocked_group OR u.age_group IS NULL)'; + if ($ageFilterEnabled) { + if ($currentAgeGroup === 'U18') { + $query .= ' AND (u.age_group = :allowed_group)'; + } else { + $query .= ' AND (u.age_group != :blocked_group OR u.age_group IS NULL)'; + } } $query .= ' ORDER BY is_online DESC, u.username ASC'; @@ -945,10 +1073,12 @@ if (isset($_POST['action']) || isset($_GET['action'])) { $stmt = $db->prepare($query); $stmt->bindValue(':current_user_id', $currentUserId, SQLITE3_INTEGER); - if ($currentAgeGroup === 'U18') { - $stmt->bindValue(':allowed_group', 'U18', SQLITE3_TEXT); - } else { - $stmt->bindValue(':blocked_group', 'U18', SQLITE3_TEXT); + if ($ageFilterEnabled) { + if ($currentAgeGroup === 'U18') { + $stmt->bindValue(':allowed_group', 'U18', SQLITE3_TEXT); + } else { + $stmt->bindValue(':blocked_group', 'U18', SQLITE3_TEXT); + } } $result = $stmt->execute(); @@ -984,9 +1114,10 @@ if (isset($_POST['action']) || isset($_GET['action'])) { $currentUserId, 'GET_USERS_FILTERED_EMPTY', sprintf( - 'Raw: %d | AgeGroup: %s', + 'Raw: %d | AgeGroup: %s | AgeFilter: %s', $rawCount, - $currentAgeGroup + $currentAgeGroup, + $ageFilterEnabled ? 'on' : 'off' ) ); } @@ -997,7 +1128,8 @@ if (isset($_POST['action']) || isset($_GET['action'])) { 'diagnostics' => [ 'raw_count' => $rawCount, 'filtered_count' => count($users), - 'current_age_group' => $currentAgeGroup + 'current_age_group' => $currentAgeGroup, + 'feature_flags' => getFeatureSettings() ] ]); exit; @@ -1205,40 +1337,46 @@ if (isset($_POST['action']) || isset($_GET['action'])) { } if ($message !== '') { - // Keyword Blacklist - $keywordCheck = checkKeywordBlacklist($message); - if ($keywordCheck['blocked']) { - logSecurityEvent($currentUserId, 'KEYWORD_BLOCKED', "Keyword: {$keywordCheck['keyword']}"); - echo json_encode([ - 'success' => false, - 'error' => 'Deine Nachricht enthält nicht erlaubte Inhalte', - 'details' => 'Verbotenes Wort erkannt: ' . $keywordCheck['keyword'] - ]); - exit; + if (isKeywordFilterEnabled()) { + // Keyword Blacklist + $keywordCheck = checkKeywordBlacklist($message); + if ($keywordCheck['blocked']) { + logSecurityEvent($currentUserId, 'KEYWORD_BLOCKED', "Keyword: {$keywordCheck['keyword']}"); + echo json_encode([ + 'success' => false, + 'error' => 'Deine Nachricht enthält nicht erlaubte Inhalte', + 'details' => 'Verbotenes Wort erkannt: ' . $keywordCheck['keyword'] + ]); + exit; + } } - // Profanity Filter - $profanityCheck = checkProfanityFilter($message); - if ($profanityCheck['blocked']) { - logSecurityEvent($currentUserId, 'PROFANITY_BLOCKED', "Word: {$profanityCheck['word']}"); - echo json_encode([ - 'success' => false, - 'error' => 'Deine Nachricht enthält Schimpfwörter', - 'details' => 'Bitte verwende eine angemessene Sprache' - ]); - exit; + if (isProfanityFilterEnabled()) { + // Profanity Filter + $profanityCheck = checkProfanityFilter($message); + if ($profanityCheck['blocked']) { + logSecurityEvent($currentUserId, 'PROFANITY_BLOCKED', "Word: {$profanityCheck['word']}"); + echo json_encode([ + 'success' => false, + 'error' => 'Deine Nachricht enthält Schimpfwörter', + 'details' => 'Bitte verwende eine angemessene Sprache' + ]); + exit; + } } - // Link Filter - $linkCheck = checkLinkFilter($message); - if ($linkCheck['blocked']) { - logSecurityEvent($currentUserId, 'LINK_BLOCKED', "Message: $message"); - echo json_encode([ - 'success' => false, - 'error' => 'Links sind nicht erlaubt', - 'details' => 'Aus Sicherheitsgründen können keine URLs gesendet werden' - ]); - exit; + if (isLinkFilterEnabled()) { + // Link Filter + $linkCheck = checkLinkFilter($message); + if ($linkCheck['blocked']) { + logSecurityEvent($currentUserId, 'LINK_BLOCKED', "Message: $message"); + echo json_encode([ + 'success' => false, + 'error' => 'Links sind nicht erlaubt', + 'details' => 'Aus Sicherheitsgründen können keine URLs gesendet werden' + ]); + exit; + } } } @@ -1638,19 +1776,51 @@ if (isset($_POST['action']) || isset($_GET['action'])) { echo json_encode([ 'success' => true, 'stats' => [ - 'total_users' => $totalUsers, - 'u18_users' => $u18Users, - 'o18_users' => $o18Users, - 'online_users' => $onlineUsers, - 'total_messages' => $totalMessages, - 'flagged_messages' => $flaggedMessages, - 'pending_reports' => $pendingReports, - 'banned_users' => $bannedUsers + 'total_users' => (int)$totalUsers, + 'u18_users' => (int)$u18Users, + 'o18_users' => (int)$o18Users, + 'online_users' => (int)$onlineUsers, + 'total_messages' => (int)$totalMessages, + 'flagged_messages' => (int)$flaggedMessages, + 'pending_reports' => (int)$pendingReports, + 'banned_users' => (int)$bannedUsers ] ]); exit; } + if ($action === 'admin_get_settings') { + echo json_encode([ + 'success' => true, + 'settings' => getFeatureSettings() + ]); + exit; + } + + if ($action === 'admin_update_settings') { + $allowedKeys = [ + 'age_filter_enabled', + 'keyword_filter_enabled', + 'profanity_filter_enabled', + 'link_filter_enabled' + ]; + + $updatedValues = []; + foreach ($allowedKeys as $key) { + $value = normalizeBooleanFlag($_POST[$key] ?? '0') ? '1' : '0'; + setSettingValue($key, $value); + $updatedValues[$key] = $value; + } + + logSecurityEvent(null, 'ADMIN_UPDATE_SETTINGS', json_encode($updatedValues)); + + echo json_encode([ + 'success' => true, + 'settings' => getFeatureSettings() + ]); + exit; + } + if ($action === 'admin_get_banned_users') { $db = getDB(); @@ -2336,6 +2506,48 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') { color: var(--sun-700); } + .admin-settings-form { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 10px; + } + + .admin-settings-toggle { + display: flex; + align-items: flex-start; + gap: 12px; + } + + .admin-settings-toggle input { + margin-top: 4px; + width: 20px; + height: 20px; + cursor: pointer; + } + + .admin-settings-toggle strong { + display: block; + font-size: 15px; + color: var(--sun-800); + } + + .admin-settings-description { + font-size: 13px; + color: rgba(120, 53, 15, 0.75); + margin-top: 2px; + } + + .admin-settings-status { + margin-top: 12px; + font-size: 13px; + color: #2563eb; + } + + .admin-settings-status.error { + color: #dc2626; + } + .admin-table-wrapper { overflow-x: auto; } @@ -3012,6 +3224,51 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') {
+
+
+

⚙️ Sicherheitsfilter

+
+
+ + + + +
+
+
+

🚨 Offene Meldungen

@@ -3233,6 +3490,9 @@ const adminStatsGrid = document.getElementById('adminStatsGrid'); const adminReportsContainer = document.getElementById('adminReportsContainer'); const adminFlaggedContainer = document.getElementById('adminFlaggedContainer'); const adminBannedContainer = document.getElementById('adminBannedContainer'); +const adminSettingsForm = document.getElementById('adminSettingsForm'); +const adminSettingsStatus = document.getElementById('adminSettingsStatus'); +let adminSettingsMessageTimer = null; function adminEscapeHtml(text) { const div = document.createElement('div'); @@ -3258,6 +3518,113 @@ async function adminFetch(action, payload = {}) { return response.json(); } +function parseAdminBoolean(value) { + if (value === true || value === false) { + return value; + } + + if (typeof value === 'number') { + return value === 1; + } + + if (typeof value === 'string') { + return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()); + } + + return false; +} + +function setAdminSettingsStatus(message, isError = false) { + if (!adminSettingsStatus) { + return; + } + + if (adminSettingsMessageTimer) { + clearTimeout(adminSettingsMessageTimer); + adminSettingsMessageTimer = null; + } + + adminSettingsStatus.textContent = message || ''; + adminSettingsStatus.classList.toggle('error', Boolean(isError && message)); + + if (message && !isError) { + adminSettingsMessageTimer = setTimeout(() => { + if (adminSettingsStatus.textContent === message) { + adminSettingsStatus.textContent = ''; + } + }, 2500); + } +} + +function applyAdminSettings(settings) { + if (!adminSettingsForm || !settings) { + return; + } + + const keys = ['age_filter_enabled', 'keyword_filter_enabled', 'profanity_filter_enabled', 'link_filter_enabled']; + keys.forEach((key) => { + const input = adminSettingsForm.elements.namedItem(key); + if (input) { + input.checked = parseAdminBoolean(settings[key]); + } + }); +} + +async function loadAdminSettings() { + if (!adminSettingsForm) { + return; + } + + try { + const response = await fetch(buildUrl({ action: 'admin_get_settings' }), { credentials: 'same-origin' }); + if (!response.ok) { + throw new Error('Einstellungen konnten nicht geladen werden.'); + } + + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || 'Einstellungen konnten nicht geladen werden.'); + } + + applyAdminSettings(result.settings || {}); + setAdminSettingsStatus(''); + } catch (error) { + setAdminSettingsStatus(error.message || 'Einstellungen konnten nicht geladen werden.', true); + } +} + +async function saveAdminSettings() { + if (!adminSettingsForm) { + return; + } + + const payload = {}; + const keys = ['age_filter_enabled', 'keyword_filter_enabled', 'profanity_filter_enabled', 'link_filter_enabled']; + keys.forEach((key) => { + const input = adminSettingsForm.elements.namedItem(key); + if (input) { + payload[key] = input.checked ? '1' : '0'; + } + }); + + try { + setAdminSettingsStatus('Speichere…'); + const result = await adminFetch('admin_update_settings', payload); + if (!result.success) { + throw new Error(result.error || 'Speichern fehlgeschlagen.'); + } + + applyAdminSettings(result.settings || {}); + setAdminSettingsStatus('Einstellungen gespeichert.'); + } catch (error) { + setAdminSettingsStatus(error.message || 'Speichern fehlgeschlagen.', true); + } +} + +adminSettingsForm?.addEventListener('change', () => { + saveAdminSettings(); +}); + function renderAdminStats(stats) { if (!stats) { adminStatsGrid.innerHTML = '
Keine Statistiken verfügbar.
'; @@ -3495,7 +3862,8 @@ async function refreshAdminData() { loadAdminStats(), loadAdminReports(), loadAdminFlagged(), - loadAdminBanned() + loadAdminBanned(), + loadAdminSettings() ]); } @@ -4561,6 +4929,16 @@ chatInputEl?.addEventListener('input', function() { loadUsers(); startSSE(); +attemptSSEProbe() + .then((canUse) => { + if (!canUse) { + console.info('SSE nicht verfügbar – wechsle auf Polling-Fallback.'); + enablePollingFallback(); + } + }) + .catch(() => { + enablePollingFallback(); + }); setInterval(loadUsers, 30000); startConnectivityWatchdog(); From c01dd3dfc45dc761322614014d410aa6d748c5d7 Mon Sep 17 00:00:00 2001 From: Metacube Date: Tue, 4 Nov 2025 10:53:04 +0100 Subject: [PATCH 10/10] Improve user list resilience and logout handling --- chat.php | 570 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 502 insertions(+), 68 deletions(-) diff --git a/chat.php b/chat.php index 6f013ca..e22f2e8 100644 --- a/chat.php +++ b/chat.php @@ -217,6 +217,17 @@ function getDB() { ) '); + // Settings Table (Feature-Flags) + $db->exec(' + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + '); + + initializeDefaultSettings($db); + // Create default admin if not exists $stmt = $db->prepare('SELECT COUNT(*) as count FROM admins WHERE username = :username'); $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); @@ -277,6 +288,115 @@ function resolveStoredAgeGroup($storedAgeGroup, $birthdate) { return 'O18'; } +function initializeDefaultSettings(SQLite3 $db) { + static $defaultsInitialized = false; + + if ($defaultsInitialized) { + return; + } + + $defaultsInitialized = true; + + $defaults = [ + 'age_filter_enabled' => '0', + 'keyword_filter_enabled' => '0', + 'profanity_filter_enabled' => '0', + 'link_filter_enabled' => '0', + ]; + + $stmt = $db->prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (:key, :value)'); + + foreach ($defaults as $key => $value) { + $stmt->bindValue(':key', $key, SQLITE3_TEXT); + $stmt->bindValue(':value', $value, SQLITE3_TEXT); + $stmt->execute(); + } +} + +function settingsCache($forceReload = false) { + static $cache = null; + + if ($forceReload) { + $cache = null; + } + + if ($cache === null) { + $cache = []; + $db = getDB(); + $result = $db->query('SELECT key, value FROM settings'); + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $cache[$row['key']] = $row['value']; + } + } + + return $cache; +} + +function getSetting($key, $default = null) { + $cache = settingsCache(); + return array_key_exists($key, $cache) ? $cache[$key] : $default; +} + +function setSettingValue($key, $value) { + $db = getDB(); + $stmt = $db->prepare(' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (:key, :value, CURRENT_TIMESTAMP) + '); + $stmt->bindValue(':key', $key, SQLITE3_TEXT); + $stmt->bindValue(':value', $value, SQLITE3_TEXT); + $stmt->execute(); + + settingsCache(true); +} + +function normalizeBooleanFlag($value) { + if (is_bool($value)) { + return $value; + } + + if (is_int($value)) { + return $value === 1; + } + + $stringValue = is_string($value) ? strtolower(trim($value)) : ''; + if ($stringValue === '') { + return false; + } + + return in_array($stringValue, ['1', 'true', 'yes', 'on'], true); +} + +function isFeatureEnabled($settingKey, $default = false) { + $defaultValue = $default ? '1' : '0'; + return normalizeBooleanFlag(getSetting($settingKey, $defaultValue)); +} + +function isAgeFilterEnabled() { + return isFeatureEnabled('age_filter_enabled', false); +} + +function isKeywordFilterEnabled() { + return isFeatureEnabled('keyword_filter_enabled', false); +} + +function isProfanityFilterEnabled() { + return isFeatureEnabled('profanity_filter_enabled', false); +} + +function isLinkFilterEnabled() { + return isFeatureEnabled('link_filter_enabled', false); +} + +function getFeatureSettings() { + return [ + 'age_filter_enabled' => isAgeFilterEnabled(), + 'keyword_filter_enabled' => isKeywordFilterEnabled(), + 'profanity_filter_enabled' => isProfanityFilterEnabled(), + 'link_filter_enabled' => isLinkFilterEnabled(), + ]; +} + function checkKeywordBlacklist($message) { global $KEYWORD_BLACKLIST; @@ -400,6 +520,10 @@ function logSecurityEvent($userId, $action, $details = '') { } function canUsersChatByAge($ageGroupA, $ageGroupB) { + if (!isAgeFilterEnabled()) { + return true; + } + if (!$ageGroupA || !$ageGroupB) { return false; } @@ -895,6 +1019,8 @@ if (isset($_POST['action']) || isset($_GET['action'])) { $currentUserId = getCurrentUserId(); $currentAgeGroup = getCurrentAgeGroup(); + $ageFilterEnabled = isAgeFilterEnabled(); + $query = ' SELECT u.id, @@ -934,10 +1060,12 @@ if (isset($_POST['action']) || isset($_GET['action'])) { AND u.is_banned = 0 '; - if ($currentAgeGroup === 'U18') { - $query .= ' AND (u.age_group = :allowed_group)'; - } else { - $query .= ' AND (u.age_group != :blocked_group OR u.age_group IS NULL)'; + if ($ageFilterEnabled) { + if ($currentAgeGroup === 'U18') { + $query .= ' AND (u.age_group = :allowed_group)'; + } else { + $query .= ' AND (u.age_group != :blocked_group OR u.age_group IS NULL)'; + } } $query .= ' ORDER BY is_online DESC, u.username ASC'; @@ -945,10 +1073,12 @@ if (isset($_POST['action']) || isset($_GET['action'])) { $stmt = $db->prepare($query); $stmt->bindValue(':current_user_id', $currentUserId, SQLITE3_INTEGER); - if ($currentAgeGroup === 'U18') { - $stmt->bindValue(':allowed_group', 'U18', SQLITE3_TEXT); - } else { - $stmt->bindValue(':blocked_group', 'U18', SQLITE3_TEXT); + if ($ageFilterEnabled) { + if ($currentAgeGroup === 'U18') { + $stmt->bindValue(':allowed_group', 'U18', SQLITE3_TEXT); + } else { + $stmt->bindValue(':blocked_group', 'U18', SQLITE3_TEXT); + } } $result = $stmt->execute(); @@ -984,9 +1114,10 @@ if (isset($_POST['action']) || isset($_GET['action'])) { $currentUserId, 'GET_USERS_FILTERED_EMPTY', sprintf( - 'Raw: %d | AgeGroup: %s', + 'Raw: %d | AgeGroup: %s | AgeFilter: %s', $rawCount, - $currentAgeGroup + $currentAgeGroup, + $ageFilterEnabled ? 'on' : 'off' ) ); } @@ -997,7 +1128,8 @@ if (isset($_POST['action']) || isset($_GET['action'])) { 'diagnostics' => [ 'raw_count' => $rawCount, 'filtered_count' => count($users), - 'current_age_group' => $currentAgeGroup + 'current_age_group' => $currentAgeGroup, + 'feature_flags' => getFeatureSettings() ] ]); exit; @@ -1205,40 +1337,46 @@ if (isset($_POST['action']) || isset($_GET['action'])) { } if ($message !== '') { - // Keyword Blacklist - $keywordCheck = checkKeywordBlacklist($message); - if ($keywordCheck['blocked']) { - logSecurityEvent($currentUserId, 'KEYWORD_BLOCKED', "Keyword: {$keywordCheck['keyword']}"); - echo json_encode([ - 'success' => false, - 'error' => 'Deine Nachricht enthält nicht erlaubte Inhalte', - 'details' => 'Verbotenes Wort erkannt: ' . $keywordCheck['keyword'] - ]); - exit; + if (isKeywordFilterEnabled()) { + // Keyword Blacklist + $keywordCheck = checkKeywordBlacklist($message); + if ($keywordCheck['blocked']) { + logSecurityEvent($currentUserId, 'KEYWORD_BLOCKED', "Keyword: {$keywordCheck['keyword']}"); + echo json_encode([ + 'success' => false, + 'error' => 'Deine Nachricht enthält nicht erlaubte Inhalte', + 'details' => 'Verbotenes Wort erkannt: ' . $keywordCheck['keyword'] + ]); + exit; + } } - // Profanity Filter - $profanityCheck = checkProfanityFilter($message); - if ($profanityCheck['blocked']) { - logSecurityEvent($currentUserId, 'PROFANITY_BLOCKED', "Word: {$profanityCheck['word']}"); - echo json_encode([ - 'success' => false, - 'error' => 'Deine Nachricht enthält Schimpfwörter', - 'details' => 'Bitte verwende eine angemessene Sprache' - ]); - exit; + if (isProfanityFilterEnabled()) { + // Profanity Filter + $profanityCheck = checkProfanityFilter($message); + if ($profanityCheck['blocked']) { + logSecurityEvent($currentUserId, 'PROFANITY_BLOCKED', "Word: {$profanityCheck['word']}"); + echo json_encode([ + 'success' => false, + 'error' => 'Deine Nachricht enthält Schimpfwörter', + 'details' => 'Bitte verwende eine angemessene Sprache' + ]); + exit; + } } - // Link Filter - $linkCheck = checkLinkFilter($message); - if ($linkCheck['blocked']) { - logSecurityEvent($currentUserId, 'LINK_BLOCKED', "Message: $message"); - echo json_encode([ - 'success' => false, - 'error' => 'Links sind nicht erlaubt', - 'details' => 'Aus Sicherheitsgründen können keine URLs gesendet werden' - ]); - exit; + if (isLinkFilterEnabled()) { + // Link Filter + $linkCheck = checkLinkFilter($message); + if ($linkCheck['blocked']) { + logSecurityEvent($currentUserId, 'LINK_BLOCKED', "Message: $message"); + echo json_encode([ + 'success' => false, + 'error' => 'Links sind nicht erlaubt', + 'details' => 'Aus Sicherheitsgründen können keine URLs gesendet werden' + ]); + exit; + } } } @@ -1638,19 +1776,51 @@ if (isset($_POST['action']) || isset($_GET['action'])) { echo json_encode([ 'success' => true, 'stats' => [ - 'total_users' => $totalUsers, - 'u18_users' => $u18Users, - 'o18_users' => $o18Users, - 'online_users' => $onlineUsers, - 'total_messages' => $totalMessages, - 'flagged_messages' => $flaggedMessages, - 'pending_reports' => $pendingReports, - 'banned_users' => $bannedUsers + 'total_users' => (int)$totalUsers, + 'u18_users' => (int)$u18Users, + 'o18_users' => (int)$o18Users, + 'online_users' => (int)$onlineUsers, + 'total_messages' => (int)$totalMessages, + 'flagged_messages' => (int)$flaggedMessages, + 'pending_reports' => (int)$pendingReports, + 'banned_users' => (int)$bannedUsers ] ]); exit; } + if ($action === 'admin_get_settings') { + echo json_encode([ + 'success' => true, + 'settings' => getFeatureSettings() + ]); + exit; + } + + if ($action === 'admin_update_settings') { + $allowedKeys = [ + 'age_filter_enabled', + 'keyword_filter_enabled', + 'profanity_filter_enabled', + 'link_filter_enabled' + ]; + + $updatedValues = []; + foreach ($allowedKeys as $key) { + $value = normalizeBooleanFlag($_POST[$key] ?? '0') ? '1' : '0'; + setSettingValue($key, $value); + $updatedValues[$key] = $value; + } + + logSecurityEvent(null, 'ADMIN_UPDATE_SETTINGS', json_encode($updatedValues)); + + echo json_encode([ + 'success' => true, + 'settings' => getFeatureSettings() + ]); + exit; + } + if ($action === 'admin_get_banned_users') { $db = getDB(); @@ -2336,6 +2506,48 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') { color: var(--sun-700); } + .admin-settings-form { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 10px; + } + + .admin-settings-toggle { + display: flex; + align-items: flex-start; + gap: 12px; + } + + .admin-settings-toggle input { + margin-top: 4px; + width: 20px; + height: 20px; + cursor: pointer; + } + + .admin-settings-toggle strong { + display: block; + font-size: 15px; + color: var(--sun-800); + } + + .admin-settings-description { + font-size: 13px; + color: rgba(120, 53, 15, 0.75); + margin-top: 2px; + } + + .admin-settings-status { + margin-top: 12px; + font-size: 13px; + color: #2563eb; + } + + .admin-settings-status.error { + color: #dc2626; + } + .admin-table-wrapper { overflow-x: auto; } @@ -2981,6 +3193,14 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') { color: #c2410c; } + .user-list-error-banner { + margin: 12px 16px; + border-radius: 10px; + background: rgba(255, 237, 213, 0.85); + border: 1px solid rgba(251, 146, 60, 0.35); + padding: 12px 14px; + } + .chat-state-message.hidden { display: none; } @@ -3012,6 +3232,51 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') {
+
+
+

⚙️ Sicherheitsfilter

+
+
+ + + + +
+
+
+

🚨 Offene Meldungen

@@ -3233,6 +3498,9 @@ const adminStatsGrid = document.getElementById('adminStatsGrid'); const adminReportsContainer = document.getElementById('adminReportsContainer'); const adminFlaggedContainer = document.getElementById('adminFlaggedContainer'); const adminBannedContainer = document.getElementById('adminBannedContainer'); +const adminSettingsForm = document.getElementById('adminSettingsForm'); +const adminSettingsStatus = document.getElementById('adminSettingsStatus'); +let adminSettingsMessageTimer = null; function adminEscapeHtml(text) { const div = document.createElement('div'); @@ -3258,6 +3526,113 @@ async function adminFetch(action, payload = {}) { return response.json(); } +function parseAdminBoolean(value) { + if (value === true || value === false) { + return value; + } + + if (typeof value === 'number') { + return value === 1; + } + + if (typeof value === 'string') { + return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()); + } + + return false; +} + +function setAdminSettingsStatus(message, isError = false) { + if (!adminSettingsStatus) { + return; + } + + if (adminSettingsMessageTimer) { + clearTimeout(adminSettingsMessageTimer); + adminSettingsMessageTimer = null; + } + + adminSettingsStatus.textContent = message || ''; + adminSettingsStatus.classList.toggle('error', Boolean(isError && message)); + + if (message && !isError) { + adminSettingsMessageTimer = setTimeout(() => { + if (adminSettingsStatus.textContent === message) { + adminSettingsStatus.textContent = ''; + } + }, 2500); + } +} + +function applyAdminSettings(settings) { + if (!adminSettingsForm || !settings) { + return; + } + + const keys = ['age_filter_enabled', 'keyword_filter_enabled', 'profanity_filter_enabled', 'link_filter_enabled']; + keys.forEach((key) => { + const input = adminSettingsForm.elements.namedItem(key); + if (input) { + input.checked = parseAdminBoolean(settings[key]); + } + }); +} + +async function loadAdminSettings() { + if (!adminSettingsForm) { + return; + } + + try { + const response = await fetch(buildUrl({ action: 'admin_get_settings' }), { credentials: 'same-origin' }); + if (!response.ok) { + throw new Error('Einstellungen konnten nicht geladen werden.'); + } + + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || 'Einstellungen konnten nicht geladen werden.'); + } + + applyAdminSettings(result.settings || {}); + setAdminSettingsStatus(''); + } catch (error) { + setAdminSettingsStatus(error.message || 'Einstellungen konnten nicht geladen werden.', true); + } +} + +async function saveAdminSettings() { + if (!adminSettingsForm) { + return; + } + + const payload = {}; + const keys = ['age_filter_enabled', 'keyword_filter_enabled', 'profanity_filter_enabled', 'link_filter_enabled']; + keys.forEach((key) => { + const input = adminSettingsForm.elements.namedItem(key); + if (input) { + payload[key] = input.checked ? '1' : '0'; + } + }); + + try { + setAdminSettingsStatus('Speichere…'); + const result = await adminFetch('admin_update_settings', payload); + if (!result.success) { + throw new Error(result.error || 'Speichern fehlgeschlagen.'); + } + + applyAdminSettings(result.settings || {}); + setAdminSettingsStatus('Einstellungen gespeichert.'); + } catch (error) { + setAdminSettingsStatus(error.message || 'Speichern fehlgeschlagen.', true); + } +} + +adminSettingsForm?.addEventListener('change', () => { + saveAdminSettings(); +}); + function renderAdminStats(stats) { if (!stats) { adminStatsGrid.innerHTML = '
Keine Statistiken verfügbar.
'; @@ -3495,7 +3870,8 @@ async function refreshAdminData() { loadAdminStats(), loadAdminReports(), loadAdminFlagged(), - loadAdminBanned() + loadAdminBanned(), + loadAdminSettings() ]); } @@ -3991,9 +4367,42 @@ async function loadUsers() { } catch (error) { console.error('Nutzerliste konnte nicht geladen werden:', error); state.isLoadingUsers = false; - if (userListEl) { - userListEl.innerHTML = '
Nutzerliste konnte nicht geladen werden.
'; + + const message = (error && error.message) ? error.message : 'Nutzerliste konnte nicht geladen werden.'; + if (/nicht\s+eingeloggt/i.test(message) || /sitzung/i.test(message)) { + window.location.href = basePath; + return; } + + if (userListEl) { + if (!Array.isArray(state.users) || state.users.length === 0) { + userListEl.innerHTML = ''; + const errorBox = document.createElement('div'); + errorBox.className = 'error-state user-list-error-banner'; + errorBox.textContent = message; + userListEl.appendChild(errorBox); + } else { + const existingBanner = userListEl.querySelector('.user-list-error-banner'); + if (existingBanner) { + existingBanner.remove(); + } + const banner = document.createElement('div'); + banner.className = 'error-state user-list-error-banner'; + banner.textContent = message; + userListEl.prepend(banner); + setTimeout(() => { + if (banner.parentNode) { + banner.remove(); + } + }, 5000); + } + } + + setTimeout(() => { + if (!state.isLoadingUsers) { + loadUsers(); + } + }, 5000); } } @@ -4022,27 +4431,29 @@ function renderUserList() { return; } - const offlineLimit = 5; - const onlineUsers = []; - const offlineUsers = []; + const prioritizedUsers = filtered.slice(); - filtered.forEach(user => { - if (user.is_online) { - onlineUsers.push(user); - } else { - offlineUsers.push(user); + if (state.selectedUserId && !prioritizedUsers.some(user => Number(user.id) === Number(state.selectedUserId))) { + const selectedUser = users.find(user => Number(user.id) === Number(state.selectedUserId)); + if (selectedUser && selectedUser.display_name.toLowerCase().includes(searchTerm)) { + prioritizedUsers.push(selectedUser); } - }); - - const limitedUsers = onlineUsers.concat(offlineUsers.slice(0, offlineLimit)); + } + const seen = new Set(); const fragment = document.createDocumentFragment(); - limitedUsers.forEach(user => { + prioritizedUsers.forEach(user => { + const userId = Number(user.id); + if (seen.has(userId)) { + return; + } + seen.add(userId); + 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.className = 'user-item' + (userId === Number(state.selectedUserId) ? ' active' : ''); + item.dataset.userId = String(userId); item.dataset.displayName = user.display_name; const avatar = document.createElement('div'); @@ -4550,8 +4961,21 @@ userSearchInput?.addEventListener('input', () => renderUserList()); document.getElementById('logoutBtn')?.addEventListener('click', async () => { const formData = new FormData(); formData.append('action', 'logout'); - await postFormData(formData); - window.location.reload(); + + try { + const response = await postFormData(formData); + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + const result = await response.json().catch(() => null); + if (result && result.success === false) { + throw new Error(result.error || 'Logout fehlgeschlagen.'); + } + } + } catch (error) { + console.error('Logout fehlgeschlagen:', error); + } finally { + window.location.href = basePath; + } }); chatInputEl?.addEventListener('input', function() { @@ -4561,6 +4985,16 @@ chatInputEl?.addEventListener('input', function() { loadUsers(); startSSE(); +attemptSSEProbe() + .then((canUse) => { + if (!canUse) { + console.info('SSE nicht verfügbar – wechsle auf Polling-Fallback.'); + enablePollingFallback(); + } + }) + .catch(() => { + enablePollingFallback(); + }); setInterval(loadUsers, 30000); startConnectivityWatchdog();