handleSSE($_GET['user_id']); exit; } // Clean up old viewers (inactive for more than 10 seconds) function cleanupViewers() { global $viewersFile; $viewers = file_exists($viewersFile) ? json_decode(file_get_contents($viewersFile), true) : []; $currentTime = time(); $activeViewers = []; foreach ($viewers as $viewer) { if ($currentTime - $viewer['last_seen'] < 10) { $activeViewers[] = $viewer; } } file_put_contents($viewersFile, json_encode($activeViewers)); return count($activeViewers); } // Handle API requests for stream status if (isset($_GET['api']) && $_GET['api'] === 'stream_status') { header('Content-Type: application/json'); header('Cache-Control: no-cache, no-store, must-revalidate'); header('Pragma: no-cache'); header('Expires: 0'); $corsOrigins = Config::get('cors.allowed_origins', []); if (in_array($_SERVER['HTTP_ORIGIN'] ?? '', $corsOrigins)) { header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']); } $streamUrl = $streamBaseUrl . '/stream.m3u8'; $online = false; if (Security::checkRateLimit(Security::getClientIP(), 'stream_status', 10, 60)) { $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'header' => "User-Agent: Mozilla/5.0\r\n", 'timeout' => 5 // Quick check ] ]); $content = @file_get_contents($streamUrl, false, $context); Security::logSecurityEvent('stream_status_check', ['online' => $online]); if ($content !== false && !empty($content)) { // Check if it looks like a valid m3u8 if (str_starts_with($content, '#EXTM3U')) { $online = true; } } } else { Security::logSecurityEvent('stream_status_rate_limited'); } echo json_encode(['online' => $online]); exit; } // Handle proxy requests for the stream if (isset($_GET['proxy']) && $_GET['proxy'] === 'stream') { // Check rate limiting if (!Security::checkRateLimit(Security::getClientIP(), 'proxy_stream')) { Security::logSecurityEvent('proxy_stream_rate_limited'); http_response_code(429); echo json_encode(['error' => 'Too many requests. Please try again later.']); exit; } $streamUrl = $streamBaseUrl . '/stream.m3u8'; // Set appropriate headers for m3u8 content header('Content-Type: application/vnd.apple.mpegurl'); header('Cache-Control: no-cache, no-store, must-revalidate'); header('Pragma: no-cache'); header('Expires: 0'); $corsOrigins = Config::get('cors.allowed_origins', []); if (in_array($_SERVER['HTTP_ORIGIN'] ?? '', $corsOrigins)) { header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']); header('Access-Control-Allow-Methods: GET, OPTIONS'); header('Access-Control-Allow-Headers: Range'); } // Fetch and output the m3u8 content $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'header' => "User-Agent: Mozilla/5.0\r\n", 'timeout' => 10 ] ]); $content = @file_get_contents($streamUrl, false, $context); if ($content !== false) { // Parse and update the m3u8 content to use our proxy for .ts segments $lines = explode("\n", $content); $updatedContent = []; foreach ($lines as $line) { $line = trim($line); if (!empty($line) && !str_starts_with($line, '#')) { // This is a .ts segment URL if (strpos($line, 'http') === 0) { // Absolute URL - validate it first if (Security::isValidStreamUrl($line)) { $updatedContent[] = '?proxy=segment&url=' . urlencode($line); } } else { // Relative URL $segmentUrl = $streamBaseUrl . '/' . $line; if (Security::isValidStreamUrl($segmentUrl)) { $updatedContent[] = '?proxy=segment&url=' . urlencode($segmentUrl); } } } else { $updatedContent[] = $line; } } Security::logSecurityEvent('proxy_stream_success'); echo implode("\n", $updatedContent); } else { Security::logSecurityEvent('proxy_stream_failed', ['url' => $streamUrl]); http_response_code(500); echo "Failed to fetch stream"; } exit; } // Handle proxy requests for .ts segments if (isset($_GET['proxy']) && $_GET['proxy'] === 'segment' && isset($_GET['url'])) { // Check rate limiting if (!Security::checkRateLimit(Security::getClientIP(), 'proxy_segment')) { Security::logSecurityEvent('proxy_segment_rate_limited'); http_response_code(429); exit; } $segmentUrl = Security::sanitizeInput($_GET['url'], 'url'); // Validate URL to prevent SSRF attacks if (!$segmentUrl || !Security::isValidStreamUrl($segmentUrl)) { Security::logSecurityEvent('proxy_segment_invalid_url', ['url' => $_GET['url'] ?? '']); http_response_code(403); echo "Invalid segment URL"; exit; } header('Content-Type: video/mp2t'); header('Cache-Control: public, max-age=3600'); // Cache segments for 1 hour $corsOrigins = Config::get('cors.allowed_origins', []); if (in_array($_SERVER['HTTP_ORIGIN'] ?? '', $corsOrigins)) { header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']); } $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'header' => "User-Agent: Mozilla/5.0\r\n", 'timeout' => 10 ] ]); $content = @file_get_contents($segmentUrl, false, $context); if ($content !== false) { Security::logSecurityEvent('proxy_segment_success'); echo $content; } else { Security::logSecurityEvent('proxy_segment_failed', ['url' => $segmentUrl]); http_response_code(500); echo "Failed to fetch segment"; } exit; } // Handle chat actions if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { header('Content-Type: application/json'); $action = $_POST['action']; // Validate the action parameter if (!preg_match('/^[a-z_]+$/', $action)) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Invalid action parameter']); Security::logSecurityEvent('invalid_chat_action', ['action' => $action]); exit; } // Admin-only actions if (in_array($action, ['delete_message', 'clear_chat', 'ban_user'])) { if (!$isAdmin) { http_response_code(403); echo json_encode(['success' => false, 'error' => 'Admin access required']); Security::logSecurityEvent('unauthorized_admin_action', ['action' => $action, 'ip' => Security::getClientIP()]); exit; } // Rate limiting for admin actions if (!Security::checkRateLimit(Security::getClientIP(), 'admin_actions', 10, 60)) { http_response_code(429); echo json_encode(['success' => false, 'error' => 'Too many admin actions. Please wait.']); Security::logSecurityEvent('admin_rate_limited'); exit; } } // Handle different actions switch ($action) { case 'delete_message': if (!isset($_POST['message_id']) || !preg_match('/^[a-zA-Z0-9]+$/', $_POST['message_id'])) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Invalid message ID']); exit; } $messageIdToDelete = $_POST['message_id']; $messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : []; $filteredMessages = array_filter($messages, function($msg) use ($messageIdToDelete) { return $msg['id'] !== $messageIdToDelete; }); $filteredMessages = array_values($filteredMessages); file_put_contents($chatFile, json_encode($filteredMessages)); Security::logSecurityEvent('message_deleted', ['message_id' => $messageIdToDelete]); echo json_encode(['success' => true]); exit; case 'clear_chat': file_put_contents($chatFile, json_encode([])); Security::logSecurityEvent('chat_cleared'); echo json_encode(['success' => true]); exit; case 'ban_user': if (!isset($_POST['user_id'])) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'User ID required']); exit; } $bannedUsers = file_exists($bannedFile) ? json_decode(file_get_contents($bannedFile), true) : []; if (!in_array($_POST['user_id'], $bannedUsers)) { $bannedUsers[] = $_POST['user_id']; file_put_contents($bannedFile, json_encode($bannedUsers)); } Security::logSecurityEvent('user_banned', ['user_id' => $_POST['user_id']]); echo json_encode(['success' => true]); exit; case 'heartbeat': // Validate heartbeat data $heartbeatValidation = Validation::validateHeartbeat($_POST); if (!$heartbeatValidation['valid']) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Invalid heartbeat data']); exit; } $userId = $_SESSION['user_id']; $nickname = $heartbeatValidation['validated']['nickname'] ?? 'Anonymous'; $viewers = file_exists($viewersFile) ? json_decode(file_get_contents($viewersFile), true) : []; // Update or add viewer $found = false; foreach ($viewers as &$viewer) { if ($viewer['user_id'] === $userId) { $viewer['last_seen'] = time(); $viewer['nickname'] = $nickname; $viewer['is_admin'] = $isAdmin; $found = true; break; } } if (!$found) { $viewers[] = [ 'user_id' => $userId, 'nickname' => $nickname, 'last_seen' => time(), 'is_admin' => $isAdmin ]; } file_put_contents($viewersFile, json_encode($viewers)); $viewerCount = cleanupViewers(); echo json_encode(['success' => true, 'viewer_count' => $viewerCount]); exit; case 'send': if (!isset($_POST['message']) || !isset($_POST['nickname'])) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Message and nickname required']); exit; } // Rate limiting for message sending if (!Security::checkRateLimit($_SESSION['user_id'], 'send_message', 5, 60)) { echo json_encode(['success' => false, 'error' => 'Too many messages. Please wait before sending another.']); exit; } // Validate message data $messageValidation = Validation::validateMessageSend($_POST); if (!$messageValidation['valid']) { echo json_encode(['success' => false, 'error' => 'Validation failed: ' . implode(', ', array_values($messageValidation['errors']))]); exit; } $userId = $_SESSION['user_id']; $nickname = $messageValidation['validated']['nickname']; $message = $messageValidation['validated']['message']; // Check if user is banned $bannedUsers = file_exists($bannedFile) ? json_decode(file_get_contents($bannedFile), true) : []; if (in_array($userId, $bannedUsers)) { echo json_encode(['success' => false, 'error' => 'You are banned from chat']); exit; } $messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : []; $newMessage = [ 'id' => Security::generateSecureToken(8), // More secure than uniqid() 'user_id' => $userId, 'nickname' => htmlspecialchars($nickname, ENT_QUOTES, 'UTF-8'), 'message' => htmlspecialchars($message, ENT_QUOTES, 'UTF-8'), 'timestamp' => time(), 'time' => date('M j, H:i'), 'is_admin' => $isAdmin ]; $messages[] = $newMessage; // Keep only last N messages if (count($messages) > $maxMessages) { $messages = array_slice($messages, -$maxMessages); } file_put_contents($chatFile, json_encode($messages)); Security::logSecurityEvent('message_sent', ['message_id' => $newMessage['id']]); echo json_encode(['success' => true, 'message' => $newMessage]); exit; case 'fetch': $lastId = isset($_POST['last_id']) ? trim($_POST['last_id']) : ''; $messages = file_exists($chatFile) ? json_decode(file_get_contents($chatFile), true) : []; // Find new messages only $newMessages = []; $foundLast = empty($lastId); foreach ($messages as $msg) { if ($foundLast) { $newMessages[] = $msg; } if ($msg['id'] === $lastId) { $foundLast = true; } } // If lastId wasn't found, return all messages (initial load or refresh) if (!$foundLast && !empty($lastId)) { $newMessages = $messages; } // Get viewer count $viewerCount = cleanupViewers(); // Determine if we should send all messages (for initial load or after admin actions) $sendAllMessages = empty($lastId) || !$foundLast; echo json_encode([ 'success' => true, 'messages' => $newMessages, 'all_messages' => $sendAllMessages ? $messages : null, 'message_count' => count($messages), 'viewer_count' => $viewerCount, 'is_admin' => $isAdmin ]); exit; case 'get_user_id': echo json_encode(['success' => true, 'user_id' => $_SESSION['user_id'], 'is_admin' => $isAdmin]); exit; default: http_response_code(400); echo json_encode(['success' => false, 'error' => 'Unknown action']); Security::logSecurityEvent('unknown_chat_action', ['action' => $action]); exit; } } ?>