PHP Classes

File: public_html/webdoors/mrc/api.php

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   public_html/webdoors/mrc/api.php   Download  
File: public_html/webdoors/mrc/api.php
Role: Example script
Content type: text/plain
Description: Example script
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 42,072 bytes
 

Contents

Class file image Download
<?php /** * MRC Chat WebDoor - API Endpoint * * Self-contained API for the MRC Chat WebDoor. * Routed via ?action=<action>. */ require_once __DIR__ . '/../_doorsdk/php/helpers.php'; use BinktermPHP\Database; use BinktermPHP\Mrc\MrcClient; use BinktermPHP\Mrc\MrcConfig; use BinktermPHP\Realtime\BinkStream; header('Content-Type: application/json'); $user = \WebDoorSDK\requireAuth(); $db = \WebDoorSDK\getDatabase(); $action = $_GET['action'] ?? ''; try { switch ($action) { case 'status': handleStatus($db); break; case 'rooms': handleRooms($db); break; case 'messages': handleMessages($db, $user); break; case 'private': handlePrivateMessages($db, $user); break; case 'private_unread': handlePrivateUnread($db, $user); break; case 'heartbeat': handleHeartbeat($db, $user); break; case 'poll': handlePoll($db, $user); break; case 'longpoll': handleLongPoll($db, $user); break; case 'command': handleCommand($db, $user); break; case 'users': handleUsers($db); break; case 'send': handleSend($db, $user); break; case 'join': handleJoin($db, $user); break; case 'room_cursor': handleRoomCursor($db); break; case 'connect': handleConnect($db, $user); break; case 'disconnect': handleDisconnect($db, $user); break; default: \WebDoorSDK\jsonError('Unknown action', 400); } } catch (Exception $e) { \WebDoorSDK\jsonError($e->getMessage(), 500); } // ============================================================ function handleStatus(PDO $db): void { $config = MrcConfig::getInstance(); $stmt = $db->prepare("SELECT key, value, updated_at FROM mrc_state WHERE key IN ('connected', 'daemon_heartbeat')"); $stmt->execute(); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $stateMap = []; foreach ($rows as $row) { $stateMap[$row['key']] = $row; } $connected = ($stateMap['connected']['value'] ?? 'false') === 'true'; // Consider the daemon running only if it has written a heartbeat within // the last 90 seconds (daemon writes every 30 s; allow 3 missed beats). $daemonRunning = false; if (!empty($stateMap['daemon_heartbeat']['updated_at'])) { $lastBeat = strtotime($stateMap['daemon_heartbeat']['updated_at']); $daemonRunning = $lastBeat !== false && (time() - $lastBeat) < 90; } \WebDoorSDK\jsonResponse([ 'success' => true, 'enabled' => $config->isEnabled(), 'connected' => $connected, 'daemon_running' => $daemonRunning, 'server' => $config->getServerHost() . ':' . $config->getServerPort(), 'bbs_name' => $config->getBbsName() ]); } /** * Query the room list, falling back to the configured default room if the * server returned no rooms (e.g. fresh connection before LIST has arrived). * * @return array<int, array<string, mixed>> */ function fetchRoomList(PDO $db): array { // Count distinct users from both server-reported presence (mrc_users) and // local webdoor sessions (mrc_local_presence) to avoid undercounting. $stmt = $db->query(" SELECT r.room_name, r.topic, r.topic_set_by, r.topic_set_at, r.last_activity, ( SELECT COUNT(DISTINCT username) FROM ( SELECT username FROM mrc_users WHERE room_name = r.room_name UNION SELECT username FROM mrc_local_presence WHERE room_name = r.room_name ) all_users ) AS user_count FROM mrc_rooms r ORDER BY r.room_name "); $rooms = $stmt->fetchAll(PDO::FETCH_ASSOC); if (empty($rooms)) { $defaultRoom = MrcConfig::getInstance()->getDefaultRoom(); $rooms = [[ 'room_name' => $defaultRoom, 'topic' => null, 'topic_set_by' => null, 'topic_set_at' => null, 'user_count' => 0, 'last_activity' => null, ]]; } return $rooms; } function handleRooms(PDO $db): void { \WebDoorSDK\jsonResponse([ 'success' => true, 'rooms' => fetchRoomList($db) ]); } function normalizeMrcHandle(string $handle, array $user): string { $handle = trim($handle); if ($handle === '') { $handle = (string)($user['username'] ?? ''); } $handle = substr(MrcClient::sanitizeName($handle), 0, 30); if ($handle === '') { \WebDoorSDK\jsonError('Username is required'); } if (in_array(strtoupper($handle), ['SERVER', 'CLIENT', 'NOTME'], true)) { \WebDoorSDK\jsonError('Invalid username'); } return $handle; } function resolveMrcUsername(array $user): string { $sessionHandle = isset($_SESSION['mrc_username']) ? (string)$_SESSION['mrc_username'] : ''; return normalizeMrcHandle($sessionHandle, $user); } function handleMessages(PDO $db, array $user): void { $room = $_GET['room'] ?? ''; $limit = isset($_GET['limit']) ? min(1000, (int)$_GET['limit']) : 100; $after = isset($_GET['after']) ? (int)$_GET['after'] : 0; if (empty($room)) { \WebDoorSDK\jsonError('Room is required'); } if (strpos($room, '~') !== false) { \WebDoorSDK\jsonError('Invalid character in room'); } $stmt = $db->prepare(" SELECT id, from_user, from_site, from_room, to_user, to_room, message_body, msg_ext, is_private, received_at FROM mrc_messages WHERE (to_room = :room OR from_room = :room) AND id > :after AND is_private = false ORDER BY received_at ASC, id ASC LIMIT :limit "); $stmt->bindValue(':room', $room, PDO::PARAM_STR); $stmt->bindValue(':after', $after, PDO::PARAM_INT); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); $messages = $stmt->fetchAll(PDO::FETCH_ASSOC); \WebDoorSDK\jsonResponse(['success' => true, 'messages' => $messages]); } function handlePrivateMessages(PDO $db, array $user): void { $with = $_GET['with'] ?? ''; $limit = isset($_GET['limit']) ? min(1000, (int)$_GET['limit']) : 100; $after = isset($_GET['after']) ? (int)$_GET['after'] : 0; if (empty($with)) { \WebDoorSDK\jsonError('Private chat user is required'); } if (strpos($with, '~') !== false) { \WebDoorSDK\jsonError('Invalid character in user'); } $config = MrcConfig::getInstance(); $username = resolveMrcUsername($user); $withUser = MrcClient::sanitizeName($with); $stmt = $db->prepare(" SELECT id, from_user, from_site, from_room, to_user, to_room, message_body, msg_ext, is_private, received_at FROM mrc_messages WHERE is_private = true AND id > :after AND ( (from_user = :me AND to_user = :with_user) OR (from_user = :with_user AND to_user = :me) ) ORDER BY received_at ASC, id ASC LIMIT :limit "); $stmt->bindValue(':me', $username, PDO::PARAM_STR); $stmt->bindValue(':with_user', $withUser, PDO::PARAM_STR); $stmt->bindValue(':after', $after, PDO::PARAM_INT); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); $messages = $stmt->fetchAll(PDO::FETCH_ASSOC); \WebDoorSDK\jsonResponse(['success' => true, 'messages' => $messages]); } function handlePrivateUnread(PDO $db, array $user): void { $after = isset($_GET['after']) ? (int)$_GET['after'] : 0; $username = resolveMrcUsername($user); $stmt = $db->prepare(" SELECT id, from_user FROM mrc_messages WHERE is_private = true AND to_user = :me AND id > :after ORDER BY id ASC "); $stmt->bindValue(':me', $username, PDO::PARAM_STR); $stmt->bindValue(':after', $after, PDO::PARAM_INT); $stmt->execute(); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $counts = []; $latestId = $after; foreach ($rows as $row) { $from = $row['from_user'] ?? ''; if ($from !== '') { $counts[$from] = ($counts[$from] ?? 0) + 1; } $latestId = max($latestId, (int)$row['id']); } \WebDoorSDK\jsonResponse([ 'success' => true, 'latest_id' => $latestId, 'counts' => $counts ]); } function handleHeartbeat(PDO $db, array $user): void { $room = $_GET['room'] ?? ''; if (empty($room)) { \WebDoorSDK\jsonError('Room is required'); } if (strpos($room, '~') !== false) { \WebDoorSDK\jsonError('Invalid character in room'); } upsertLocalHandle($db, $user); upsertLocalPresence($db, $user, $room); \WebDoorSDK\jsonResponse(['success' => true]); } /** * Normalize a user-entered MRC room name. * Accepts an optional leading '#' from the UI, then enforces the protocol's * room-name rules before anything is queued to the daemon. */ function normalizeMrcRoomName(string $room): string { $room = trim($room); if (strncmp($room, '#', 1) === 0) { $room = substr($room, 1); } $room = MrcClient::sanitizeName($room); if ($room === '' || !preg_match('/^[A-Za-z0-9]{1,20}$/', $room)) { \WebDoorSDK\jsonError('Invalid room name'); } return $room; } /** * Emit a fresh room presence payload to all remaining local users in a room. */ function emitPresenceForRoom(PDO $db, string $room): void { $room = MrcClient::sanitizeName($room); if ($room === '') { return; } $userIdStmt = $db->prepare(" SELECT DISTINCT user_id AS id FROM mrc_local_presence WHERE room_name = :room AND user_id IS NOT NULL AND last_seen > CURRENT_TIMESTAMP - INTERVAL '10 minutes' "); $userIdStmt->execute(['room' => $room]); $targetUserIds = array_map('intval', array_column($userIdStmt->fetchAll(PDO::FETCH_ASSOC), 'id')); if (empty($targetUserIds)) { return; } $localBbs = MrcClient::sanitizeName(MrcConfig::getInstance()->getBbsName()); $usersStmt = $db->prepare(" SELECT username, COALESCE(bbs_name, 'unknown') AS bbs_name, false AS is_afk FROM mrc_users WHERE room_name = :room UNION SELECT username, :local_bbs AS bbs_name, false AS is_afk FROM mrc_local_presence WHERE room_name = :room2 AND last_seen > CURRENT_TIMESTAMP - INTERVAL '10 minutes' "); $usersStmt->execute([ 'room' => $room, 'local_bbs' => $localBbs, 'room2' => $room, ]); $payload = [ 'room' => $room, 'users' => $usersStmt->fetchAll(PDO::FETCH_ASSOC), ]; foreach ($targetUserIds as $targetUserId) { BinkStream::emit($db, 'mrc_presence', $payload, $targetUserId); } } function upsertLocalHandle(PDO $db, array $user): void { $config = MrcConfig::getInstance(); $localUsername = resolveMrcUsername($user); $localUserId = (int)($user['user_id'] ?? $user['id'] ?? 0); $localBbsName = MrcClient::sanitizeName($config->getBbsName()); if ($localUserId <= 0 || $localUsername === '') { return; } $db->prepare(" INSERT INTO mrc_local_handles (user_id, username, bbs_name, connected_at, last_seen) VALUES (:user_id, :username, :bbs_name, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT (user_id) DO UPDATE SET username = EXCLUDED.username, bbs_name = EXCLUDED.bbs_name, last_seen = CURRENT_TIMESTAMP ")->execute([ 'user_id' => $localUserId, 'username' => $localUsername, 'bbs_name' => $localBbsName, ]); } function upsertLocalPresence(PDO $db, array $user, string $room): void { $config = MrcConfig::getInstance(); $localUsername = resolveMrcUsername($user); $localUserId = (int)($user['user_id'] ?? $user['id'] ?? 0); $localBbsName = MrcClient::sanitizeName($config->getBbsName()); $room = MrcClient::sanitizeName($room); $db->prepare(" INSERT INTO mrc_rooms (room_name, last_activity) VALUES (:room, CURRENT_TIMESTAMP) ON CONFLICT (room_name) DO UPDATE SET last_activity = CURRENT_TIMESTAMP ")->execute(['room' => $room]); $db->prepare(" INSERT INTO mrc_local_presence (user_id, username, bbs_name, room_name, last_seen) VALUES (:user_id, :username, :bbs_name, :room, CURRENT_TIMESTAMP) ON CONFLICT (user_id, room_name) DO UPDATE SET username = EXCLUDED.username, bbs_name = EXCLUDED.bbs_name, last_seen = CURRENT_TIMESTAMP ")->execute([ 'user_id' => $localUserId > 0 ? $localUserId : null, 'username' => $localUsername, 'bbs_name' => $localBbsName, 'room' => $room ]); } function handlePoll(PDO $db, array $user): void { $viewMode = $_GET['view_mode'] ?? 'room'; $viewRoom = $_GET['view_room'] ?? ''; $joinRoom = $_GET['join_room'] ?? ''; $withUser = $_GET['with_user'] ?? ''; $after = isset($_GET['after']) ? (int)$_GET['after'] : 0; $afterPrivate = isset($_GET['after_private']) ? (int)$_GET['after_private'] : 0; $afterUnread = isset($_GET['after_unread']) ? (int)$_GET['after_unread'] : 0; $unreadInit = isset($_GET['unread_init']) && $_GET['unread_init'] === '1'; $includeRooms = isset($_GET['include_rooms']) && $_GET['include_rooms'] === '1'; if (!empty($viewRoom) && strpos($viewRoom, '~') !== false) { \WebDoorSDK\jsonError('Invalid character in room'); } if (!empty($joinRoom) && strpos($joinRoom, '~') !== false) { \WebDoorSDK\jsonError('Invalid character in room'); } if (!empty($withUser) && strpos($withUser, '~') !== false) { \WebDoorSDK\jsonError('Invalid character in user'); } $response = ['success' => true]; upsertLocalHandle($db, $user); // Messages for current view if ($viewMode === 'private' && $withUser !== '') { $username = resolveMrcUsername($user); $withUser = MrcClient::sanitizeName($withUser); $stmt = $db->prepare(" SELECT id, from_user, from_site, from_room, to_user, to_room, message_body, msg_ext, is_private, received_at FROM mrc_messages WHERE is_private = true AND id > :after AND ( (from_user = :me AND to_user = :with_user) OR (from_user = :with_user AND to_user = :me) ) ORDER BY received_at ASC, id ASC LIMIT 200 "); $stmt->bindValue(':me', $username, PDO::PARAM_STR); $stmt->bindValue(':with_user', $withUser, PDO::PARAM_STR); $stmt->bindValue(':after', $afterPrivate, PDO::PARAM_INT); $stmt->execute(); $response['messages'] = $stmt->fetchAll(PDO::FETCH_ASSOC); $response['message_mode'] = 'private'; } elseif ($viewRoom !== '') { $viewRoom = MrcClient::sanitizeName($viewRoom); $stmt = $db->prepare(" SELECT id, from_user, from_site, from_room, to_user, to_room, message_body, msg_ext, is_private, received_at FROM mrc_messages WHERE (to_room = :room OR from_room = :room) AND id > :after AND is_private = false ORDER BY received_at ASC, id ASC LIMIT 200 "); $stmt->bindValue(':room', $viewRoom, PDO::PARAM_STR); $stmt->bindValue(':after', $after, PDO::PARAM_INT); $stmt->execute(); $response['messages'] = $stmt->fetchAll(PDO::FETCH_ASSOC); $response['message_mode'] = 'room'; } else { $response['messages'] = []; $response['message_mode'] = $viewMode === 'private' ? 'private' : 'room'; } // Private unread counts $username = resolveMrcUsername($user); if ($username !== '') { if ($unreadInit && $afterUnread === 0) { $stmt = $db->prepare(" SELECT COALESCE(MAX(id), 0) AS max_id FROM mrc_messages WHERE is_private = true AND to_user = :me "); $stmt->bindValue(':me', $username, PDO::PARAM_STR); $stmt->execute(); $latestId = (int)$stmt->fetchColumn(); $response['private_unread'] = [ 'latest_id' => $latestId, 'counts' => [] ]; } else { $stmt = $db->prepare(" SELECT id, from_user FROM mrc_messages WHERE is_private = true AND to_user = :me AND id > :after ORDER BY id ASC "); $stmt->bindValue(':me', $username, PDO::PARAM_STR); $stmt->bindValue(':after', $afterUnread, PDO::PARAM_INT); $stmt->execute(); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $counts = []; $latestId = $afterUnread; foreach ($rows as $row) { $from = $row['from_user'] ?? ''; if ($from !== '') { $counts[$from] = ($counts[$from] ?? 0) + 1; } $latestId = max($latestId, (int)$row['id']); } $response['private_unread'] = [ 'latest_id' => $latestId, 'counts' => $counts ]; } } // Users list for joined room ? include both server-reported (mrc_users) and // locally-connected (mrc_local_presence) users so that the local user // appears immediately after joining, before the server's USERLIST arrives. if ($joinRoom !== '') { $joinRoom = MrcClient::sanitizeName($joinRoom); $stmt = $db->prepare(" SELECT username, bbs_name, room_name, ip_address, connected_at, last_seen, COALESCE(is_afk, false) AS is_afk, afk_message FROM mrc_users WHERE room_name = :room UNION ALL SELECT username, bbs_name, room_name, ip_address, connected_at, last_seen, false AS is_afk, NULL AS afk_message FROM mrc_local_presence WHERE room_name = :room ORDER BY username "); $stmt->execute(['room' => $joinRoom]); $response['users'] = $stmt->fetchAll(PDO::FETCH_ASSOC); } else { $response['users'] = []; } // Rooms list (optional) if ($includeRooms) { $response['rooms'] = fetchRoomList($db); } // Heartbeat for joined room if ($joinRoom !== '') { upsertLocalPresence($db, $user, $joinRoom); } \WebDoorSDK\jsonResponse($response); } /** * Long-poll endpoint: holds the connection for up to 20 seconds and returns * as soon as new messages or unread DMs arrive, or when the timeout expires. * * The session lock is released immediately so concurrent requests from the * same user (e.g. sending a message) are never blocked by this handler. * Users and rooms are fetched once up-front and included in every response * so the client does not need a separate slow-poll interval. */ function handleLongPoll(PDO $db, array $user): void { // Release PHP session lock so send/join requests are not blocked. session_write_close(); // Allow this script to run longer than the default PHP max_execution_time. set_time_limit(35); $viewMode = $_GET['view_mode'] ?? 'room'; $viewRoom = $_GET['view_room'] ?? ''; $joinRoom = $_GET['join_room'] ?? ''; $withUser = $_GET['with_user'] ?? ''; $after = isset($_GET['after']) ? (int)$_GET['after'] : 0; $afterPrivate = isset($_GET['after_private']) ? (int)$_GET['after_private'] : 0; $afterUnread = isset($_GET['after_unread']) ? (int)$_GET['after_unread'] : 0; if (!empty($viewRoom) && strpos($viewRoom, '~') !== false) { \WebDoorSDK\jsonError('Invalid character in room'); } if (!empty($joinRoom) && strpos($joinRoom, '~') !== false) { \WebDoorSDK\jsonError('Invalid character in room'); } if (!empty($withUser) && strpos($withUser, '~') !== false) { \WebDoorSDK\jsonError('Invalid character in user'); } $username = resolveMrcUsername($user); upsertLocalHandle($db, $user); $viewRoom = $viewRoom !== '' ? MrcClient::sanitizeName($viewRoom) : ''; $joinRoom = $joinRoom !== '' ? MrcClient::sanitizeName($joinRoom) : ''; $withUser = $withUser !== '' ? MrcClient::sanitizeName($withUser) : ''; // Update presence heartbeat at the start of each long-poll cycle. if ($joinRoom !== '' && $username !== '') { upsertLocalPresence($db, $user, $joinRoom); } // Fetch slow-changing data once up-front; included in every response. // Include both server-reported (mrc_users) and locally-connected // (mrc_local_presence) users so the local user is visible immediately. $users = []; if ($joinRoom !== '') { $stmt = $db->prepare(" SELECT username, bbs_name, room_name, ip_address, connected_at, last_seen, COALESCE(is_afk, false) AS is_afk, afk_message FROM mrc_users WHERE room_name = :room UNION ALL SELECT username, bbs_name, room_name, ip_address, connected_at, last_seen, false AS is_afk, NULL AS afk_message FROM mrc_local_presence WHERE room_name = :room ORDER BY username "); $stmt->execute(['room' => $joinRoom]); $users = $stmt->fetchAll(PDO::FETCH_ASSOC); } $rooms = fetchRoomList($db); // Prepare message query parameters based on view mode. $messageMode = 'room'; $msgStmt = null; if ($viewMode === 'private' && $withUser !== '' && $username !== '') { $messageMode = 'private'; $msgStmt = $db->prepare(" SELECT id, from_user, from_site, from_room, to_user, to_room, message_body, msg_ext, is_private, received_at FROM mrc_messages WHERE is_private = true AND id > :after AND ( (from_user = :me AND to_user = :with_user) OR (from_user = :with_user AND to_user = :me) ) ORDER BY received_at ASC, id ASC LIMIT 200 "); $msgStmt->bindValue(':me', $username, PDO::PARAM_STR); $msgStmt->bindValue(':with_user', $withUser, PDO::PARAM_STR); } elseif ($viewRoom !== '') { $msgStmt = $db->prepare(" SELECT id, from_user, from_site, from_room, to_user, to_room, message_body, msg_ext, is_private, received_at FROM mrc_messages WHERE (to_room = :room OR from_room = :room) AND id > :after AND is_private = false ORDER BY received_at ASC, id ASC LIMIT 200 "); $msgStmt->bindValue(':room', $viewRoom, PDO::PARAM_STR); } // Prepare unread query. $unreadStmt = null; if ($username !== '') { $unreadStmt = $db->prepare(" SELECT id, from_user FROM mrc_messages WHERE is_private = true AND to_user = :me AND id > :after ORDER BY id ASC "); $unreadStmt->bindValue(':me', $username, PDO::PARAM_STR); } $timeout = 20.0; // seconds $sleepUs = 500000; // 500 ms $deadline = microtime(true) + $timeout; while (microtime(true) < $deadline) { $messages = []; if ($msgStmt !== null) { $afterVal = ($messageMode === 'private') ? $afterPrivate : $after; $msgStmt->bindValue(':after', $afterVal, PDO::PARAM_INT); $msgStmt->execute(); $messages = $msgStmt->fetchAll(PDO::FETCH_ASSOC); } $unreadCounts = []; $latestUnreadId = $afterUnread; if ($unreadStmt !== null) { $unreadStmt->bindValue(':after', $afterUnread, PDO::PARAM_INT); $unreadStmt->execute(); foreach ($unreadStmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $from = $row['from_user'] ?? ''; if ($from !== '') { $unreadCounts[$from] = ($unreadCounts[$from] ?? 0) + 1; } $latestUnreadId = max($latestUnreadId, (int)$row['id']); } } if (!empty($messages) || !empty($unreadCounts)) { \WebDoorSDK\jsonResponse([ 'success' => true, 'messages' => $messages, 'message_mode' => $messageMode, 'private_unread' => ['latest_id' => $latestUnreadId, 'counts' => $unreadCounts], 'users' => $users, 'rooms' => $rooms, ]); return; } usleep($sleepUs); } // Timeout ? return empty so the client reconnects immediately. \WebDoorSDK\jsonResponse([ 'success' => true, 'messages' => [], 'message_mode' => $messageMode, 'private_unread' => ['latest_id' => $afterUnread, 'counts' => []], 'users' => $users, 'rooms' => $rooms, 'timed_out' => true, ]); } function handleUsers(PDO $db): void { $room = $_GET['room'] ?? ''; if (empty($room)) { \WebDoorSDK\jsonError('Room is required'); } if (strpos($room, '~') !== false) { \WebDoorSDK\jsonError('Invalid character in room'); } $stmt = $db->prepare(" SELECT username, bbs_name, room_name, ip_address, connected_at, last_seen, COALESCE(is_afk, false) AS is_afk, afk_message FROM mrc_users WHERE room_name = :room UNION ALL SELECT username, bbs_name, room_name, ip_address, connected_at, last_seen, false AS is_afk, NULL AS afk_message FROM mrc_local_presence WHERE room_name = :room ORDER BY username "); $stmt->execute(['room' => $room]); \WebDoorSDK\jsonResponse(['success' => true, 'users' => $stmt->fetchAll(PDO::FETCH_ASSOC)]); } function handleCommand(PDO $db, array $user): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $command = strtolower(trim($input['command'] ?? '')); $room = $input['room'] ?? ''; if ($command === '') { \WebDoorSDK\jsonError('Command is required'); } if (!preg_match('/^[a-z]{1,20}$/', $command)) { \WebDoorSDK\jsonError('Invalid command'); } // these commands can be sent without a room $roomOptional = in_array($command, ['rooms', 'motd', 'register', 'identify', 'update', 'help'], true); if (!$roomOptional) { if (empty($room)) { \WebDoorSDK\jsonError('Room is required'); } } if (!empty($room) && strpos($room, '~') !== false) { \WebDoorSDK\jsonError('Invalid character in room'); } $config = MrcConfig::getInstance(); $username = resolveMrcUsername($user); upsertLocalHandle($db, $user); $room = $command === 'rooms' ? '' : MrcClient::sanitizeName($room); $bbsName = MrcClient::sanitizeName($config->getBbsName()); $commandArgs = $input['args'] ?? []; $commandArgs = is_array($commandArgs) ? $commandArgs : []; $commandArgs = array_map('trim', $commandArgs); $commandArgs = array_values(array_filter($commandArgs, 'strlen')); // Build f7 and f6 per command. // REGISTER/IDENTIFY/UPDATE use f6='' (personal commands, not room-targeted). $f6 = $room; $f7 = ''; switch ($command) { case 'help': $topic = !empty($commandArgs[0]) ? ' ' . substr(str_replace(['~', ' '], '', $commandArgs[0]), 0, 20) : ''; $f7 = 'HELP' . $topic; break; case 'motd': $f7 = 'MOTD'; break; case 'rooms': $f7 = 'LIST'; $f6 = ''; break; case 'topic': if (empty($commandArgs)) { \WebDoorSDK\jsonError('Topic text is required'); } $topicText = implode(' ', $commandArgs); $topicText = str_replace('~', '', $topicText); $topicText = preg_replace('/\\|[0-9A-Fa-f]{2}/', '', $topicText); $topicText = preg_replace('/\\|[A-Za-z]{2}/', '', $topicText); $topicText = trim(substr($topicText, 0, 55)); if ($topicText === '') { \WebDoorSDK\jsonError('Topic text is required'); } $f7 = "NEWTOPIC:{$room}:{$topicText}"; break; case 'register': if (empty($commandArgs)) { $f7 = 'REGISTER'; } else { $password = substr(str_replace(['~', ' '], '', $commandArgs[0]), 0, 20); $email = ''; if (!empty($commandArgs[1])) { $email = substr(str_replace(['~', ' '], '', $commandArgs[1]), 0, 128); } $f7 = 'REGISTER' . ($password !== '' ? ' ' . $password : '') . ($email !== '' ? ' ' . $email : ''); } $f6 = ''; break; case 'identify': if (empty($commandArgs)) { $f7 = 'IDENTIFY'; } else { $password = substr(str_replace(['~', ' '], '', $commandArgs[0]), 0, 20); $f7 = 'IDENTIFY' . ($password !== '' ? ' ' . $password : ''); } $f6 = ''; break; case 'update': if (empty($commandArgs)) { $f7 = 'UPDATE'; } else { $param = strtoupper(str_replace(['~', ' '], '', $commandArgs[0])); $value = isset($commandArgs[1]) ? substr(str_replace('~', '', $commandArgs[1]), 0, 128) : ''; $f7 = 'UPDATE' . ($param !== '' ? ' ' . $param : '') . ($value !== '' ? ' ' . $value : ''); } $f6 = ''; break; default: // Generic passthrough: uppercase the command word and append any args $safeArgs = array_map(function($a) { return substr(str_replace('~', '', $a), 0, 140); }, $commandArgs); $f7 = strtoupper($command) . (!empty($safeArgs) ? ' ' . implode(' ', $safeArgs) : ''); break; } $db->prepare(" INSERT INTO mrc_outbound (field1, field2, field3, field4, field5, field6, field7, priority) VALUES (:f1, :f2, :f3, :f4, :f5, :f6, :f7, :priority) ")->execute([ 'f1' => $command === 'rooms' ? $bbsName : $username, 'f2' => $bbsName, 'f3' => $room, 'f4' => 'SERVER', 'f5' => '', 'f6' => $f6, 'f7' => $f7, 'priority' => 5 ]); if ($command === 'rooms') { $db->prepare(" INSERT INTO mrc_state (key, value, updated_at) VALUES ('list_refresh_started', :value, CURRENT_TIMESTAMP) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at ")->execute(['value' => (string)time()]); $db->prepare(" INSERT INTO mrc_state (key, value, updated_at) VALUES ('list_refresh_pending', 'true', CURRENT_TIMESTAMP) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at ")->execute(); } \WebDoorSDK\jsonResponse(['success' => true]); } function handleSend(PDO $db, array $user): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $room = $input['room'] ?? ''; $message = $input['message'] ?? ''; $toUser = $input['to_user'] ?? ''; if (empty($message) || (empty($room) && empty($toUser))) { \WebDoorSDK\jsonError('Message and room or user are required'); } if (strpos($message, '~') !== false) { \WebDoorSDK\jsonError('Invalid character in message'); } if (!empty($room) && strpos($room, '~') !== false) { \WebDoorSDK\jsonError('Invalid character in room'); } if (!empty($toUser) && strpos($toUser, '~') !== false) { \WebDoorSDK\jsonError('Invalid character in user'); } $config = MrcConfig::getInstance(); $username = resolveMrcUsername($user); upsertLocalHandle($db, $user); $message = str_replace('~', '', $message); $message = substr($message, 0, $config->getMaxMessageLength()); $bbsName = MrcClient::sanitizeName($config->getBbsName()); $toUser = $toUser !== '' ? MrcClient::sanitizeName($toUser) : ''; $stmt = $db->prepare(" INSERT INTO mrc_outbound (field1, field2, field3, field4, field5, field6, field7, priority) VALUES (:f1, :f2, :f3, :f4, :f5, :f6, :f7, :priority) "); // MRC spec (Field 7): Word 1 = handle of the user, Word 2+ = chat message. // Other clients (e.g. Mystic, ZOC) read W1 as the sender's name and // display it as "W1: rest". Without this prefix, the first word of the // message text is treated as the sender name. $formattedMessage = '|03<|02' . $username . '|03> ' . $message; if (!empty($toUser)) { $stmt->execute([ 'f1' => $username, 'f2' => $bbsName, 'f3' => '', 'f4' => $toUser, 'f5' => '', 'f6' => '', 'f7' => '|03<|02' . $username . '|03> (Private) ' . $message, 'priority' => 0 ]); } else { $stmt->execute([ 'f1' => $username, 'f2' => $bbsName, 'f3' => $room, 'f4' => '', 'f5' => '', 'f6' => $room, 'f7' => $formattedMessage, 'priority' => 0 ]); } \WebDoorSDK\jsonResponse(['success' => true]); } function handleJoin(PDO $db, array $user): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $room = $input['room'] ?? ''; $fromRoom = $input['from_room'] ?? ''; if (empty($room)) { \WebDoorSDK\jsonError('Room is required'); } if (strpos($room, '~') !== false || strpos($fromRoom, '~') !== false) { \WebDoorSDK\jsonError('Invalid character in room'); } $config = MrcConfig::getInstance(); $username = resolveMrcUsername($user); upsertLocalHandle($db, $user); $room = normalizeMrcRoomName((string)$room); $fromRoom = trim((string)$fromRoom); if ($fromRoom !== '') { $fromRoom = normalizeMrcRoomName($fromRoom); } $bbsName = MrcClient::sanitizeName($config->getBbsName()); $clientIp = $_SERVER['REMOTE_ADDR'] ?? ''; if ($clientIp !== '') { $clientIp = preg_replace('/[^0-9a-fA-F:\\.]/', '', $clientIp); } // Queue NEWROOM command for the daemon to send $db->prepare(" INSERT INTO mrc_outbound (field1, field2, field3, field4, field5, field6, field7, priority) VALUES (:f1, :f2, :f3, :f4, :f5, :f6, :f7, :priority) ")->execute([ 'f1' => $username, 'f2' => $bbsName, 'f3' => $fromRoom, 'f4' => 'SERVER', 'f5' => '', 'f6' => $room, 'f7' => "NEWROOM:{$fromRoom}:{$room}", 'priority' => 10 ]); if ($clientIp !== '') { $db->prepare(" INSERT INTO mrc_outbound (field1, field2, field3, field4, field5, field6, field7, priority) VALUES (:f1, :f2, :f3, :f4, :f5, :f6, :f7, :priority) ")->execute([ 'f1' => $username, 'f2' => $bbsName, 'f3' => '', 'f4' => 'SERVER', 'f5' => '', 'f6' => '', 'f7' => "USERIP:{$clientIp}", 'priority' => 9 ]); } // Track user as local presence so IAMHERE keepalives are sent for them $db->prepare(" INSERT INTO mrc_rooms (room_name, last_activity) VALUES (:room, CURRENT_TIMESTAMP) ON CONFLICT (room_name) DO UPDATE SET last_activity = CURRENT_TIMESTAMP ")->execute(['room' => $room]); $localUserId = (int)($user['user_id'] ?? $user['id'] ?? 0); $db->prepare(" INSERT INTO mrc_local_presence (user_id, username, bbs_name, room_name, ip_address, last_seen) VALUES (:user_id, :username, :bbs_name, :room, :ip_address, CURRENT_TIMESTAMP) ON CONFLICT (user_id, room_name) DO UPDATE SET username = EXCLUDED.username, bbs_name = EXCLUDED.bbs_name, last_seen = CURRENT_TIMESTAMP, ip_address = COALESCE(EXCLUDED.ip_address, mrc_local_presence.ip_address) ")->execute([ 'user_id' => $localUserId > 0 ? $localUserId : null, 'username' => $username, 'bbs_name' => $bbsName, 'room' => $room, 'ip_address' => $clientIp ]); $stmt = $db->prepare(" SELECT COALESCE(MAX(id), 0) AS max_id FROM mrc_messages WHERE is_private = false AND (to_room = :room OR from_room = :room) "); $stmt->execute(['room' => $room]); $maxId = (int)$stmt->fetchColumn(); \WebDoorSDK\jsonResponse([ 'success' => true, 'last_message_id' => $maxId ]); } /** * Get the latest non-private message id for a room. */ function handleRoomCursor(PDO $db): void { $room = $_GET['room'] ?? ''; if (empty($room)) { \WebDoorSDK\jsonError('Room is required'); } if (strpos($room, '~') !== false) { \WebDoorSDK\jsonError('Invalid character in room'); } $room = MrcClient::sanitizeName($room); $stmt = $db->prepare(" SELECT COALESCE(MAX(id), 0) AS max_id FROM mrc_messages WHERE is_private = false AND (to_room = :room OR from_room = :room) "); $stmt->execute(['room' => $room]); $maxId = (int)$stmt->fetchColumn(); \WebDoorSDK\jsonResponse([ 'success' => true, 'last_message_id' => $maxId ]); } /** * Establish an MRC session for the current user. * Queues USERIP to register presence with the server, and optionally * queues IDENTIFY if a password is provided. */ function handleConnect(PDO $db, array $user): void { $input = json_decode(file_get_contents('php://input'), true) ?? []; $password = isset($input['password']) ? trim((string)$input['password']) : ''; $username = normalizeMrcHandle((string)($input['username'] ?? ''), $user); $_SESSION['mrc_username'] = $username; upsertLocalHandle($db, $user); $config = MrcConfig::getInstance(); $bbsName = MrcClient::sanitizeName($config->getBbsName()); $clientIp = $_SERVER['REMOTE_ADDR'] ?? ''; if ($clientIp !== '') { $clientIp = preg_replace('/[^0-9a-fA-F:\.]/', '', $clientIp); } $outStmt = $db->prepare(" INSERT INTO mrc_outbound (field1, field2, field3, field4, field5, field6, field7, priority) VALUES (:f1, :f2, :f3, :f4, :f5, :f6, :f7, :priority) "); if ($clientIp !== '') { $outStmt->execute([ 'f1' => $username, 'f2' => $bbsName, 'f3' => '', 'f4' => 'SERVER', 'f5' => '', 'f6' => '', 'f7' => "USERIP:{$clientIp}", 'priority' => 9 ]); } if ($password !== '') { $password = substr(str_replace(['~', ' '], '', $password), 0, 20); if ($password !== '') { $outStmt->execute([ 'f1' => $username, 'f2' => $bbsName, 'f3' => '', 'f4' => 'SERVER', 'f5' => '', 'f6' => '', 'f7' => "IDENTIFY {$password}", 'priority' => 8 ]); } } \WebDoorSDK\jsonResponse([ 'success' => true, 'username' => $username, ]); } /** * Disconnect the current user from MRC. * Queues LOGOFF for every room the user is currently in and removes * their local presence so IAMHERE keepalives stop. */ function handleDisconnect(PDO $db, array $user): void { $config = MrcConfig::getInstance(); $username = resolveMrcUsername($user); $bbsName = MrcClient::sanitizeName($config->getBbsName()); $localUserId = (int)($user['user_id'] ?? $user['id'] ?? 0); $stmt = $db->prepare(" SELECT DISTINCT room_name FROM mrc_local_presence WHERE user_id = :user_id AND last_seen > CURRENT_TIMESTAMP - INTERVAL '10 minutes' "); $stmt->execute(['user_id' => $localUserId]); $rooms = array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'room_name'); $outStmt = $db->prepare(" INSERT INTO mrc_outbound (field1, field2, field3, field4, field5, field6, field7, priority) VALUES (:f1, :f2, :f3, :f4, :f5, :f6, :f7, :priority) "); foreach ($rooms as $room) { $room = MrcClient::sanitizeName($room); $outStmt->execute([ 'f1' => $username, 'f2' => $bbsName, 'f3' => $room, 'f4' => 'SERVER', 'f5' => '', 'f6' => $room, 'f7' => 'LOGOFF', 'priority' => 10 ]); } $db->prepare(" DELETE FROM mrc_local_presence WHERE user_id = :user_id ")->execute(['user_id' => $localUserId]); $db->prepare(" DELETE FROM mrc_local_handles WHERE user_id = :user_id ")->execute(['user_id' => $localUserId]); foreach ($rooms as $room) { emitPresenceForRoom($db, $room); } unset($_SESSION['mrc_username']); // Notify all other tabs/windows for this user so they return to the // connect screen instead of running against a terminated session. if ($localUserId > 0) { BinkStream::emit($db, 'mrc_session_ended', [], $localUserId); } \WebDoorSDK\jsonResponse(['success' => true]); }