'Unknown action'], 400); } function messagePayload(array $row): array { return [ 'id' => (int)$row['id'], 'username' => $row['username'], 'color' => $row['color'], 'message' => $row['message'], 'message_type' => $row['message_type'] ?? 'text', 'voice_url' => !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null, 'voice_mime' => $row['voice_mime'] ?? null, 'voice_duration' => isset($row['voice_duration']) ? (float)$row['voice_duration'] : null, 'created_at' => (int)$row['created_at'], 'day_key' => $row['day_key'] ?? dayKey(), ]; } function handleSend(): never { $session = authRequired(); $db = getDB(); $message = trim($_POST['message'] ?? ''); $maxLen = getConfigVal('chat.max_message_length', 500); if (!$message) jsonResponse(['error' => 'Empty message'], 400); if (strlen($message) > $maxLen) { jsonResponse(['error' => "Message too long (max {$maxLen} chars)"], 400); } $dayKey = dayKey(); $stmt = $db->prepare(" INSERT INTO messages (user_id, username, color, message, created_at, day_key) VALUES (?, ?, ?, ?, ?, ?) "); $stmt->execute([ $session['uid'], $session['username'], $session['color'], $message, time(), $dayKey ]); $id = $db->lastInsertId(); jsonResponse(['success' => true, 'message' => messagePayload([ 'id' => $id, 'username' => $session['username'], 'color' => $session['color'], 'message' => $message, 'message_type' => 'text', 'created_at' => time(), 'day_key' => $dayKey, ])]); } function handleSendVoice(): never { $session = authRequired(); if (!getConfigVal('voice.enabled', true)) { jsonResponse(['error' => 'Voice clips are disabled'], 403); } if (empty($_FILES['voice']) || !is_array($_FILES['voice'])) { jsonResponse(['error' => 'No voice clip uploaded'], 400); } $file = $_FILES['voice']; if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { jsonResponse(['error' => 'Voice upload failed'], 400); } $maxBytes = (int)getConfigVal('voice.max_upload_bytes', 12582912); if (($file['size'] ?? 0) < 1 || $file['size'] > $maxBytes) { jsonResponse(['error' => 'Voice clip exceeds the upload limit'], 400); } $duration = (float)($_POST['duration'] ?? 0); $maxSeconds = max(1, (int)getConfigVal('voice.max_duration_seconds', 60)); if ($duration <= 0 || $duration > $maxSeconds + 1) { jsonResponse(['error' => "Voice clip must be {$maxSeconds} seconds or less"], 400); } $mime = class_exists('finfo') ? (string)(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']) : ''; $header = (string)file_get_contents($file['tmp_name'], false, null, 0, 12); if (str_starts_with($header, "\x1A\x45\xDF\xA3")) { $mime = 'audio/webm'; } elseif (str_starts_with($header, 'RIFF') && substr($header, 8, 4) === 'WAVE') { $mime = 'audio/wav'; } $allowed = [ 'audio/webm' => 'webm', 'video/webm' => 'webm', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/wave' => 'wav', ]; if (!isset($allowed[$mime])) { jsonResponse(['error' => 'Voice clips must be WAV or WebM'], 400); } $dir = voiceUploadDir(); if (!is_dir($dir) && !mkdir($dir, 0755, true)) { jsonResponse(['error' => 'Voice storage is unavailable'], 500); } $filename = bin2hex(random_bytes(20)) . '.' . $allowed[$mime]; if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) { jsonResponse(['error' => 'Could not store voice clip'], 500); } $db = getDB(); $created = time(); $dayKey = dayKey(); try { $stmt = $db->prepare(" INSERT INTO messages (user_id, username, color, message, message_type, voice_file, voice_mime, voice_duration, created_at, day_key) VALUES (?, ?, ?, ?, 'voice', ?, ?, ?, ?, ?) "); $stmt->execute([ $session['uid'], $session['username'], $session['color'], '[Voice clip]', $filename, $mime === 'video/webm' ? 'audio/webm' : $mime, $duration, $created, $dayKey ]); } catch (Throwable $e) { deleteVoiceFile($filename); throw $e; } jsonResponse(['success' => true, 'message' => messagePayload([ 'id' => $db->lastInsertId(), 'username' => $session['username'], 'color' => $session['color'], 'message' => '[Voice clip]', 'message_type' => 'voice', 'voice_file' => $filename, 'voice_mime' => $mime === 'video/webm' ? 'audio/webm' : $mime, 'voice_duration' => $duration, 'created_at' => $created, 'day_key' => $dayKey, ])]); } function handlePoll(): never { $session = authRequired(); $db = getDB(); $since = (int)($_GET['since'] ?? 0); $dayKey = dayKey(); $limit = getConfigVal('chat.messages_per_page', 100); if ($since === 0) { // Initial load — get last N messages for today $stmt = $db->prepare(" SELECT id, username, color, message, message_type, voice_file, voice_mime, voice_duration, created_at, day_key FROM messages WHERE day_key = ? ORDER BY created_at DESC, id DESC LIMIT ? "); $stmt->execute([$dayKey, $limit]); $rows = array_reverse($stmt->fetchAll(PDO::FETCH_ASSOC)); } else { // Poll for new messages since last id $stmt = $db->prepare(" SELECT id, username, color, message, message_type, voice_file, voice_mime, voice_duration, created_at, day_key FROM messages WHERE day_key = ? AND id > ? ORDER BY created_at ASC, id ASC LIMIT ? "); $stmt->execute([$dayKey, $since, $limit]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); } // Cast types $messages = array_map('messagePayload', $rows); // Get online user count (active in last 5 minutes) $cutoff5min = time() - 300; $onlineStmt = $db->prepare("SELECT COUNT(*) FROM sessions WHERE last_active > ?"); $onlineStmt->execute([$cutoff5min]); $onlineCount = (int)$onlineStmt->fetchColumn(); jsonResponse([ 'messages' => $messages, 'online' => $onlineCount, 'day_key' => $dayKey, 'server_time' => time(), ]); } function handleHistory(): never { // Return list of archived days $archiveDir = ROOT_DIR . '/' . getConfigVal('archive.archive_dir', 'archive'); $days = []; if (is_dir($archiveDir)) { $years = glob($archiveDir . '/*', GLOB_ONLYDIR); foreach ($years as $yearDir) { $year = basename($yearDir); $months = glob($yearDir . '/*', GLOB_ONLYDIR); foreach ($months as $monthDir) { $month = basename($monthDir); $files = glob($monthDir . '/*.sqlite'); foreach ($files as $file) { $day = basename($file, '.sqlite'); $days[] = "$year-$month-$day"; } } } } rsort($days); jsonResponse(['days' => $days]); }