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'], 'user_id' => isset($row['user_id']) ? (int)$row['user_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, 'voice_bitrate_kbps' => isset($row['voice_bitrate_kbps']) ? (int)$row['voice_bitrate_kbps'] : null, 'reply_to_id' => !empty($row['reply_to_id']) ? (int)$row['reply_to_id'] : null, 'reply_depth' => isset($row['reply_depth']) ? (int)$row['reply_depth'] : 0, 'reply_username' => $row['reply_username'] ?? null, 'reply_message' => $row['reply_message'] ?? null, 'reply_message_type' => $row['reply_message_type'] ?? null, 'created_at' => (int)$row['created_at'], 'day_key' => $row['day_key'] ?? dayKey(), ]; } function messageSelectSql(string $where): string { return "SELECT m.*,p.username AS reply_username,p.message AS reply_message, p.message_type AS reply_message_type FROM messages m LEFT JOIN messages p ON p.id=m.reply_to_id WHERE $where"; } function messageById(PDO $db, int $id): array { $stmt = $db->prepare(messageSelectSql('m.id=?')); $stmt->execute([$id]); $row = $stmt->fetch(); if (!$row) throw new RuntimeException('Stored message could not be loaded.'); return $row; } function resolveReply(PDO $db): array { $replyId = max(0, (int)($_POST['reply_to_id'] ?? 0)); if ($replyId === 0) return [null, 0]; if (!getConfigVal('chat.replies_enabled', true)) { jsonResponse(['error' => 'Replies are disabled'], 403); } $maxDepth = max(1, min(20, (int)getConfigVal('chat.reply_max_depth', 4))); $stmt = $db->prepare("SELECT id,reply_depth FROM messages WHERE id=? AND day_key=?"); $stmt->execute([$replyId, dayKey()]); $parent = $stmt->fetch(); if (!$parent) jsonResponse(['error' => 'The message being replied to is unavailable'], 404); $depth = (int)$parent['reply_depth'] + 1; if ($depth > $maxDepth) { jsonResponse(['error' => "Reply depth limit reached (max $maxDepth)"], 400); } return [$replyId, $depth]; } 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); $db = getDB(); [$replyId, $replyDepth] = resolveReply($db); $db->prepare("INSERT INTO messages (user_id,username,color,message,reply_to_id,reply_depth,created_at,day_key) VALUES (?,?,?,?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([ $user['id'], $user['username'], $user['color'], $message, $replyId, $replyDepth, dayKey(), ]); $messageId = (int)$db->lastInsertId(); trackEvent('message_text', '/api/messages.php', (int)$user['id']); jsonResponse(['success' => true, 'message' => messagePayload(messageById($db, $messageId))]); } 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, 16); 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'; elseif (strlen($header) >= 8 && substr($header, 4, 4) === 'ftyp') $mime = 'audio/mp4'; elseif (strlen($header) >= 2 && ord($header[0]) === 0xFF && (ord($header[1]) & 0xF0) === 0xF0) $mime = 'audio/aac'; $allowed = [ 'audio/webm' => 'webm', 'video/webm' => 'webm', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/wave' => 'wav', 'audio/mp4' => 'm4a', 'video/mp4' => 'm4a', 'audio/aac' => 'aac', 'audio/x-aac' => 'aac', ]; if (!isset($allowed[$mime])) jsonResponse(['error' => 'Voice clips must be WebM, MP4/M4A, AAC, or WAV'], 400); $db = getDB(); [$replyId, $replyDepth] = resolveReply($db); $dir = voiceUploadDir(); ensureWritableDirectory($dir, 'Voice storage'); $token = bin2hex(random_bytes(20)); $sourceFilename = $token . '.' . $allowed[$mime]; $sourcePath = $dir . '/' . $sourceFilename; if (!move_uploaded_file($file['tmp_name'], $sourcePath)) { jsonResponse(['error' => 'Could not store voice clip'], 500); } $sourceMime = match ($mime) { 'video/webm' => 'audio/webm', 'video/mp4' => 'audio/mp4', 'audio/x-wav', 'audio/wave' => 'audio/wav', 'audio/x-aac' => 'audio/aac', default => $mime, }; $bitrate = max(48, min(320, (int)featureValue($user, 'voice_bitrate_kbps', 64))); try { $stored = transcodeVoiceRecording($sourcePath, $sourceFilename, $sourceMime, $bitrate); } catch (Throwable $e) { if (is_file($sourcePath)) @unlink($sourcePath); throw $e; } $filename = $stored['filename']; $storedMime = $stored['mime']; try { $db->prepare("INSERT INTO messages (user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration, voice_bitrate_kbps,reply_to_id,reply_depth,created_at,day_key) VALUES (?,?,?,?,'voice',?,?,?,?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([ $user['id'], $user['username'], $user['color'], '[Voice clip]', $filename, $storedMime, $duration, $bitrate, $replyId, $replyDepth, dayKey(), ]); } catch (Throwable $e) { deleteVoiceFile($filename); throw $e; } $messageId = (int)$db->lastInsertId(); $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(messageById($db, $messageId))]); } 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(messageSelectSql('m.day_key=?') . " ORDER BY m.id DESC LIMIT ?"); $stmt->execute([dayKey(), $limit]); $rows = $stmt->fetchAll(); usort($rows, 'compareMessagesChronologically'); $hasMore = false; } else { $stmt = $db->prepare(messageSelectSql('m.day_key=? AND m.id>?') . " ORDER BY m.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 compareMessagesChronologically(array $a, array $b): int { return ((int)$a['created_at'] <=> (int)$b['created_at']) ?: ((int)$a['id'] <=> (int)$b['id']); } function archiveDays(): never { $user = authRequired(); $rows = personalArchiveRows($user, '', 1000); $days = array_values(array_unique(array_column($rows, 'day_key'))); rsort($days); jsonResponse(['days' => $days]); }