getMessage()); } $action = $_POST['action'] ?? $_GET['action'] ?? ''; match ($action) { 'send' => sendTextMessage(), 'send_voice' => sendVoiceMessage(), 'recording' => setRecordingStatus(), 'poll' => pollMessages(), 'history' => archiveDays(), default => jsonResponse(['error' => '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 sendTextMessage(): never { $user = authRequired(); $message = trim((string)($_POST['message'] ?? '')); $max = (int)getConfigVal('chat.max_message_length', 500); if ($message === '') jsonResponse(['error' => 'Empty message'], 400); if (strlen($message) > $max) jsonResponse(['error' => "Message too long (max $max chars)"], 400); $created = time(); $db = getDB(); $db->prepare("INSERT INTO messages (user_id,username,color,message,created_at,day_key) VALUES (?,?,?,?,?,?)")->execute([ $user['id'], $user['username'], $user['color'], $message, $created, dayKey(), ]); trackEvent('message_text', '/api/messages.php', (int)$user['id']); jsonResponse(['success' => true, 'message' => messagePayload([ 'id' => $db->lastInsertId(), 'username' => $user['username'], 'color' => $user['color'], 'message' => $message, 'message_type' => 'text', 'created_at' => $created, 'day_key' => dayKey(), ])]); } function sendVoiceMessage(): never { $user = authRequired(); if (!getConfigVal('voice.enabled', true) || !featureValue($user, 'voice_messages', true)) { jsonResponse(['error' => 'Your plan does not include voice messages'], 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(); ensureWritableDirectory($dir, 'Voice storage'); $filename = bin2hex(random_bytes(20)) . '.' . $allowed[$mime]; if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) { jsonResponse(['error' => 'Could not store voice clip'], 500); } $created = time(); $storedMime = $mime === 'video/webm' ? 'audio/webm' : $mime; $db = getDB(); try { $db->prepare("INSERT INTO messages (user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key) VALUES (?,?,?,?,'voice',?,?,?,?,?)")->execute([ $user['id'], $user['username'], $user['color'], '[Voice clip]', $filename, $storedMime, $duration, $created, dayKey(), ]); } catch (Throwable $e) { deleteVoiceFile($filename); throw $e; } $db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?") ->execute([$user['id']]); trackEvent('message_voice', '/api/messages.php', (int)$user['id']); jsonResponse(['success' => true, 'message' => messagePayload([ 'id' => $db->lastInsertId(), 'username' => $user['username'], 'color' => $user['color'], 'message' => '[Voice clip]', 'message_type' => 'voice', 'voice_file' => $filename, 'voice_mime' => $storedMime, 'voice_duration' => $duration, 'created_at' => $created, 'day_key' => dayKey(), ])]); } function setRecordingStatus(): never { $user = authRequired(); if (!featureValue($user, 'recording_status', false)) { jsonResponse(['error' => 'Your plan does not include recording indicators'], 403); } $active = filter_var($_POST['active'] ?? false, FILTER_VALIDATE_BOOLEAN); $db = getDB(); if ($active) { $db->prepare("INSERT INTO recording_status (channel_key,user_id,expires_at) VALUES ('public',?,?) ON CONFLICT(channel_key,user_id) DO UPDATE SET expires_at=excluded.expires_at") ->execute([$user['id'], time() + 10]); } else { $db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?") ->execute([$user['id']]); } jsonResponse(['success' => true]); } function pollMessages(): never { $user = authRequired(); $since = max(0, (int)($_GET['since'] ?? 0)); $limit = max(1, min(250, (int)getConfigVal('chat.messages_per_page', 100))); $db = getDB(); if ($since === 0) { $stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? ORDER BY id DESC LIMIT ?"); $stmt->execute([dayKey(), $limit]); $rows = array_reverse($stmt->fetchAll()); $hasMore = false; } else { $stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? AND id>? ORDER BY id LIMIT ?"); $stmt->execute([dayKey(), $since, $limit + 1]); $rows = $stmt->fetchAll(); $hasMore = count($rows) > $limit; if ($hasMore) $rows = array_slice($rows, 0, $limit); } $cursor = $since; foreach ($rows as $row) $cursor = max($cursor, (int)$row['id']); $cutoff = time() - 300; $onlineStmt = $db->prepare("SELECT COUNT(*) FROM sessions WHERE last_active>?"); $onlineStmt->execute([$cutoff]); $recording = []; $db->prepare("DELETE FROM recording_status WHERE expires_at<=?")->execute([time()]); if (featureValue($user, 'recording_status', false)) { $recordStmt = $db->prepare("SELECT u.id,u.username,u.color FROM recording_status r JOIN users u ON u.id=r.user_id WHERE r.channel_key='public' AND r.expires_at>? AND r.user_id!=?"); $recordStmt->execute([time(), $user['id']]); $recording = $recordStmt->fetchAll(); } jsonResponse([ 'messages' => array_map('messagePayload', $rows), 'cursor' => $cursor, 'has_more' => $hasMore, 'online' => (int)$onlineStmt->fetchColumn(), 'recording' => $recording, 'server_time' => time(), ]); } function archiveDays(): never { $user = authRequired(); $rows = personalArchiveRows($user, '', 1000); $days = array_values(array_unique(array_column($rows, 'day_key'))); rsort($days); jsonResponse(['days' => $days]); }