getMessage()); } $action = $_POST['action'] ?? $_GET['action'] ?? ''; switch ($action) { case 'send': sendTextMessage(); case 'send_voice': sendVoiceMessage(); case 'recording': setRecordingStatus(); case 'poll': pollMessages(); case 'history': archiveDays(); default: jsonResponse(['error' => 'Unknown action'], 400); } function messagePayload(array $row): array { return [ 'id' => (int)$row['id'], 'group_id' => isset($row['group_id']) && $row['group_id'] !== null ? (int)$row['group_id'] : null, '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 requestedGroup(array $user): ?int { $groupId = normalizeGroupId($_POST['group_id'] ?? $_GET['group_id'] ?? null); if ($groupId !== null && !groupsAvailableForUser($user)) { $error = groupsEnabled() ? 'Private groups are not available on your tier' : 'Private groups are currently disabled'; jsonResponse([ 'error' => $error, 'groups_enabled' => false, 'groups_platform_enabled' => groupsEnabled(), ], 403); } requireGroupAccess($user, $groupId); return $groupId; } function sendTextMessage(): never { $user = authRequired(); $groupId = requestedGroup($user); $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,group_id,username,color,message,created_at,day_key) VALUES (?,?,?,?,?,?,?)")->execute([ $user['id'], $groupId, $user['username'], $user['color'], $message, $created, dayKey(), ]); if ($groupId !== null) { $db->prepare("UPDATE chat_groups SET updated_at=? WHERE id=?")->execute([$created, $groupId]); } trackEvent('message_text', '/api/messages.php', (int)$user['id']); jsonResponse(['success' => true, 'message' => messagePayload([ 'id' => $db->lastInsertId(), 'group_id' => $groupId, 'username' => $user['username'], 'color' => $user['color'], 'message' => $message, 'message_type' => 'text', 'created_at' => $created, 'day_key' => dayKey(), ])]); } function sendVoiceMessage(): never { $user = authRequired(); $groupId = requestedGroup($user); 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,group_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key) VALUES (?,?,?,?,?,'voice',?,?,?,?,?)")->execute([ $user['id'], $groupId, $user['username'], $user['color'], '[Voice clip]', $filename, $storedMime, $duration, $created, dayKey(), ]); if ($groupId !== null) { $db->prepare("UPDATE chat_groups SET updated_at=? WHERE id=?")->execute([$created, $groupId]); } } catch (Throwable $e) { deleteVoiceFile($filename); throw $e; } getDB()->prepare("DELETE FROM recording_status WHERE group_key=? AND user_id=?") ->execute([groupKey($groupId), $user['id']]); trackEvent('message_voice', '/api/messages.php', (int)$user['id']); jsonResponse(['success' => true, 'message' => messagePayload([ 'id' => $db->lastInsertId(), 'group_id' => $groupId, '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); } $groupId = requestedGroup($user); $active = filter_var($_POST['active'] ?? false, FILTER_VALIDATE_BOOLEAN); $db = getDB(); if ($active) { $db->prepare("INSERT INTO recording_status (group_key,user_id,expires_at) VALUES (?,?,?) ON CONFLICT(group_key,user_id) DO UPDATE SET expires_at=excluded.expires_at") ->execute([groupKey($groupId), $user['id'], time() + 10]); } else { $db->prepare("DELETE FROM recording_status WHERE group_key=? AND user_id=?") ->execute([groupKey($groupId), $user['id']]); } jsonResponse(['success' => true]); } function pollMessages(): never { $user = authRequired(); $groupId = requestedGroup($user); $since = max(0, (int)($_GET['since'] ?? 0)); $limit = max(1, min(250, (int)getConfigVal('chat.messages_per_page', 100))); $db = getDB(); $whereGroup = $groupId === null ? 'group_id IS NULL' : 'group_id = ?'; $params = $groupId === null ? [dayKey()] : [dayKey(), $groupId]; if ($since === 0) { $stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? AND $whereGroup ORDER BY id DESC LIMIT ?"); $params[] = $limit; $stmt->execute($params); $rows = array_reverse($stmt->fetchAll()); } else { $stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? AND $whereGroup AND id>? ORDER BY id LIMIT ?"); $params[] = $since; $params[] = $limit; $stmt->execute($params); $rows = $stmt->fetchAll(); } $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.group_key=? AND r.expires_at>? AND r.user_id!=?"); $recordStmt->execute([groupKey($groupId), time(), $user['id']]); $recording = $recordStmt->fetchAll(); } jsonResponse([ 'messages' => array_map('messagePayload', $rows), 'online' => (int)$onlineStmt->fetchColumn(), 'recording' => $recording, 'groups' => groupsAvailableForUser($user) ? userGroups((int)$user['id']) : [], 'groups_enabled' => groupsAvailableForUser($user), 'groups_platform_enabled' => groupsEnabled(), 'group_id' => $groupId, '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]); }