diff --git a/app/active_calls/active_calls.php b/app/active_calls/active_calls.php new file mode 100644 index 0000000000..e5380e37c2 --- /dev/null +++ b/app/active_calls/active_calls.php @@ -0,0 +1,1027 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +//includes files +require_once dirname(__DIR__, 2) . "/resources/require.php"; +require_once "resources/check_auth.php"; + +//check permissions +if (permission_exists('call_active_view')) { + //access granted +} else { + echo "access denied"; + exit; +} + +//set a default value +$debug = false; + +global $domain_uuid, $user_uuid, $settings, $database, $config; + +if (empty($domain_uuid)) { + $domain_uuid = $_SESSION['domain_uuid'] ?? ''; +} + +if (empty($user_uuid)) { + $user_uuid = $_SESSION['user_uuid'] ?? ''; +} + +if (!($config instanceof config)) { + $config = config::load(); +} + +if (!($database instanceof database)) { + $database = database::new(); +} + +if (!($settings instanceof settings)) { + $settings = new settings(['database' => $database, 'domain_uuid' => $domain_uuid, 'user_uuid' => $user_uuid]); +} + +//ensure we have the proper icons to avoid PHP warnings +$theme_button_icon_all = $settings->get('theme', 'button_icon_all'); +$theme_button_icon_back = $settings->get('theme', 'button_icon_back'); +$theme_button_icon_broom = $settings->get('theme', 'button_icon_broom'); + +//add multi-lingual support +$language = new text; +$text = $language->get(); + +$token = (new token())->create($_SERVER['PHP_SELF']); + +//show the header +$document['title'] = $text['title']; +require_once dirname(__DIR__, 2) . "/resources/header.php"; + +//add the style +echo "\n"; + +// if (permission_exists('call_active_details')) { +if ($debug) { + echo "\n"; +} +echo "
\n"; +if (permission_exists('call_active_all')) { + echo "
" . $text['title'] . "
0
"; +} else { + echo "
" . $text['title'] . "
0
"; +} +echo "
\n"; +if (permission_exists('call_active_all')) { + // Show All button + echo button::create([ + 'id' => 'btn_show_all', + 'type' => 'button', + 'label' => $text['button-show_all'], + 'icon' => $theme_button_icon_all, + ]); + // Hide the back button initially + echo button::create([ + 'id' => 'btn_back', + 'label' => $text['button-back'], + 'icon' => $theme_button_icon_back, + 'style' => 'display: none;', + ]); +} + +if (!$settings->get('active_calls', 'remove_completed_calls', true)) { + // Clear rows (development) + echo button::create([ + 'id' => 'btn_clear', + 'label' => $text['button-clear'] ?? 'clear', + 'icon' => $theme_button_icon_broom, + 'style' => 'display: inline-block;', + 'onclick' => 'clear_rows()' + ]); +} + +if (permission_exists('call_active_hangup')) { + if (permission_exists('call_active_hangup')) { + // Hangup selected calls + echo button::create([ + 'id' => 'btn_hangup', + 'type' => 'button', + 'label' => $text['label-hangup'], + 'icon' => 'phone-slash', + 'onclick' => "if (confirm('" . $text['confirm-hangup'] . "')) { " + . "hangup_selected();" + . "} else { " + . "this.blur(); " + . "return false; " + . "}", + ]) . "\n"; + } +} +echo "
\n"; +echo "
\n"; +echo "
\n"; +echo $text['description'] . "\n"; +echo "

\n"; +echo "
\n"; +echo "
\n"; +echo " \n"; +echo " \n"; +echo " \n"; +if (permission_exists('call_active_hangup')) { + echo " \n"; +} +if (permission_exists('call_active_direction')) { + echo " \n"; +} +if (permission_exists('call_active_profile')) { + echo " \n"; +} +echo " \n"; +echo " \n"; +echo " \n"; +echo " \n"; +echo " \n"; +if (permission_exists('call_active_application')) { + echo " \n"; +} +if (permission_exists('call_active_codec')) { + echo " \n"; +} +if (permission_exists('call_active_secure')) { + echo " \n"; +} +if (permission_exists('call_active_eavesdrop') || permission_exists('call_active_hangup')) { + echo " \n"; +} +echo " \n"; +echo " \n"; +echo " \n"; +echo " \n"; +echo "
\n"; + echo " \n"; + echo " " . $text['label-direction'] . "" . $text['label-profile'] . "" . $text['label-duration'] . "" . $text['label-cid-name'] . "" . $text['label-cid-number'] . "" . $text['label-destination'] . "" . $text['label-app'] . "" . $text['label-codec'] . "" . $text['label-secure'] . " 
\n"; +// After the table, put a generic hangup and eavesdrop button that we can clone +if (permission_exists('call_active_hangup')) { + echo button::create([ + 'id' => 'btn_hangup', + 'type' => 'button', + 'style' => 'display: none;', + 'label' => $text['label-hangup'], + 'icon' => 'phone-slash', + ]) . "\n"; +} +echo "
\n"; +echo "
\n"; +if (permission_exists('call_active_eavesdrop')) { + echo button::create([ + 'id' => 'btn_eavesdrop' + , 'type' => 'button' + , 'label' => $text['label-eavesdrop'] + , 'icon' => 'headphones' + , 'collapse' => 'hide-lg-dn' + , 'style' => 'display: none;' + ]); +} + +echo " \n"; + +echo " \n"; + +// +// Pass the token array, websocket services to subscribe to, and time limit to +// the subscriber class so that when this subscriber makes a websocket +// connection, the subscriber object can validate the information. +// +subscriber::save_token($token, ['active.calls']); + +//break the caching +$version = md5(file_get_contents(__DIR__, '/resources/javascript/websocket_client.js')); +echo "\n"; +$version = md5(file_get_contents(__DIR__, '/resources/javascript/arrow.js')); +echo "\n"; +?> + + + diff --git a/app/active_calls/app_config.php b/app/active_calls/app_config.php new file mode 100644 index 0000000000..45838eff84 --- /dev/null +++ b/app/active_calls/app_config.php @@ -0,0 +1,494 @@ + +*/ diff --git a/app/active_calls/resources/classes/active_calls_service.php b/app/active_calls/resources/classes/active_calls_service.php new file mode 100644 index 0000000000..9446192568 --- /dev/null +++ b/app/active_calls/resources/classes/active_calls_service.php @@ -0,0 +1,853 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * Description of active_calls_service + * + * @author Tim Fry + */ +class active_calls_service extends service implements websocket_service_interface { + + const SWITCH_EVENTS = [ + ['Event-Name' => 'CHANNEL_CREATE'], + ['Event-Name' => 'CHANNEL_CALLSTATE'], + ['Event-Name' => 'CALL_UPDATE'], + ['Event-Name' => 'PLAYBACK_START'], + ['Event-Name' => 'PLAYBACK_STOP'], + ['Event-Name' => 'CHANNEL_DESTROY'], + ['Event-Name' => 'CHANNEL_PARK'], + ['Event-Name' => 'CHANNEL_UNPARK'], + ['Event-Name' => 'CHANNEL_EXECUTE'], + ['Event-Name' => 'HEARTBEAT'], // Ensures that the switch is still responding + ['Event-Subclass' => 'valet_parking::info'], + ]; + + const EVENT_KEYS = [ + // Event name: CHANNEL_EXECUTE, CHANNEL_DESTROY, NEW_CALL... + 'event_name', + // Unique Call Identifier to determine new/existing calls + 'unique_id', + // Domain + 'caller_context', + // Ringing, Hangup, Answered + 'answer_state', + 'channel_call_state', + // Time stamp + 'caller_channel_created_time', + // Codecs + 'channel_read_codec_name', + 'channel_write_codec_name', + 'channel_read_codec_rate', + 'channel_write_codec_rate', + 'caller_channel_name', + // Caller/Callee ID + 'caller_caller_id_name', + 'caller_caller_id_number', + 'caller_destination_number', + // Encrypted + 'secure', + // Application + 'application', + 'application_data', + 'variable_current_application', + 'playback_file_path', + // Valet parking info + 'valet_extension', + 'action', + 'variable_referred_by_user', + 'variable_pre_transfer_caller_id_name', + 'variable_valet_parking_timeout', + // Direction + 'call_direction', + 'variable_call_direction', + 'other_leg_rdnis', + 'other_leg_unique_id', + 'content_type', + ]; + + // + // Maps the event key to the permission name + // + const PERMISSION_MAP = [ + 'channel_read_codec_name' => 'call_active_codec', + 'channel_read_codec_rate' => 'call_active_codec', + 'channel_write_codec_name' => 'call_active_codec', + 'channel_write_codec_rate' => 'call_active_codec', + 'caller_channel_name' => 'call_active_profile', + 'secure' => 'call_active_secure', + 'application' => 'call_active_application', + 'playback_file_path' => 'call_active_application', + 'variable_current_application'=> 'call_active_application', + ]; + + /** + * Switch Event Socket + * @var event_socket + */ + private $event_socket; + + /** + * Web Socket Client + * @var websocket_client + */ + private $ws_client; + + /** + * Resource for the Switch Event Socket used to control blocking + * @var resource + */ + private $switch_socket; + private $topics; + + /** + * Event Filter + * @var filter + */ + private $event_filter; + + private static $switch_port = null; + private static $switch_host = null; + private static $switch_password = null; + + private static $websocket_port = null; + private static $websocket_host = null; + + /** + * Checks if an event exists in the SWITCH_EVENTS. + * @param string $event_name The value to search for. + * @return bool True if the value is found, false otherwise. + */ + public static function event_exists(string $event_name): bool { + if (!empty($event_name)) { + foreach (active_calls_service::SWITCH_EVENTS as $events) { + if (in_array($event_name, $events)) { + return true; + } + } + } + return false; + } + + /** + * Checks if an event exists in the SWITCH_EVENTS ignoring case. + * This check is slower then the event_exists function. Whenever possible, it is recommended to use that function instead. + * @param string $event_name + * @return bool + */ + public static function event_exists_ignore_case(string $event_name): bool { + foreach (self::SWITCH_EVENTS as $events) { + foreach ($events as $value) { + if (strtolower($value) === strtolower($event_name)) { + return true; + } + } + } + return false; + } + + /** + * Builds a filter for the subscriber + * @param subscriber $subscriber + * @return filter + */ + public static function create_filter_chain_for(subscriber $subscriber): filter { + return filter_chain::and_link([ + new event_filter(self::SWITCH_EVENTS), + new permission_filter(self::PERMISSION_MAP, $subscriber->get_permissions()), + new event_key_filter(self::EVENT_KEYS), + ]); + } + + /** + * Returns the service name for this service that is used when the web browser clients subscriber + * to this service for updates + * @return string + */ + public static function get_service_name(): string { + return "active.calls"; + } + + /** + * Returns a string used to execute a hangup command + * @param string $uuid + * @return string + */ + public static function get_hangup_command(string $uuid): string { + return "bgapi uuid_kill $uuid"; + } + + /** + * Reloads the settings for the service so the service does not have to be restarted + * @return void + */ + protected function reload_settings(): void { + // re-read the config file to get any possible changes + parent::$config->read(); + + // use the connection information in the config file + + // re-connect to the event socket + if ($this->connect_to_event_socket()) { + $this->register_event_socket_filters(); + } + // re-connect to the websocket server + $this->connect_to_ws_server(); + } + + /** + * Displays the version of the active calls service in the console + * @return void + */ + protected static function display_version(): void { + echo "Active Calls Service 1.0\n"; + } + + /** + * Sets the command line options + */ + protected static function set_command_options() { + parent::append_command_option( + command_option::new() + ->description('Set the Port to connect to the switch') + ->short_option('p') + ->short_description('-p ') + ->long_option('switch-port') + ->long_description('--switch-port ') + ->callback('set_switch_port') + ); + parent::append_command_option( + command_option::new() + ->description('Set the IP address for the switch') + ->short_option('i') + ->short_description('-i ') + ->long_option('switch-ip') + ->long_description('--switch-ip ') + ->callback('set_switch_host_address') + ); + parent::append_command_option( + command_option::new() + ->description('Set the password to be used with switch') + ->short_option('o') + ->short_description('-o ') + ->long_option('switch-password') + ->long_description('--switch-password ') + ->callback('set_switch_password') + ); + parent::append_command_option( + command_option::new() + ->description('Set the Port to connect to the websockets service') + ->short_option('w') + ->short_description('-w ') + ->long_option('websockets-port') + ->long_description('--websockets-port ') + ->callback('set_websockets_port') + ); + parent::append_command_option( + command_option::new() + ->description('Set the IP address for the websocket') + ->short_option('k') + ->short_description('-k ') + ->long_option('websockets-address') + ->long_description('--websockets-address ') + ->callback('set_websockets_host_address') + ); + + } + + protected static function set_websockets_port($port): void { + self::$websocket_port = $port; + } + + protected static function set_websockets_host_address($host): void { + self::$websocket_host = $host; + } + + protected static function set_switch_host_address($host): void { + self::$switch_host = $host; + } + + protected static function set_switch_port($port): void { + self::$switch_port = $port; + } + + protected static function set_switch_password($password): void { + self::$switch_password = $password; + } + + /** + * Main entry point + * @return int Non-zero exit indicates an error has occurred + */ + public function run(): int { + + // Notify connected web server socket when we close + register_shutdown_function(function ($ws_client) { + if ($ws_client !== null) + $ws_client->disconnect(); + }, $this->ws_client); + + // Create an active call filter using filter objects to create an 'OR' chain + $this->event_filter = filter_chain::or_link([new event_key_filter(active_calls_service::EVENT_KEYS)]); + + // Register callbacks for the topics + $this->on_topic('in.progress', [$this, 'on_in_progress'] ); + $this->on_topic('hangup', [$this, 'on_hangup'] ); + $this->on_topic('eavesdrop', [$this, 'on_eavesdrop'] ); + $this->on_topic('authenticate', [$this, 'on_authenticate']); + + $this->info("Starting " . self::class . " service"); + // Suppress the WebSocket Server Error Message so it doesn't flood the system logs + $suppress_ws_message = false; + // Suppress the Event Socket Error Message so it doesn't flood the system logs + $suppress_es_message = false; + while ($this->running) { + $read = []; + // reconnect to event_socket + if ($this->event_socket === null || !$this->event_socket->is_connected()) { + if (!$this->connect_to_event_socket()) { + if (!$suppress_es_message) $this->error("Unable to connect to switch event server"); + $suppress_es_message = true; + } else { + $this->register_event_socket_filters(); + } + } + + // reconnect to websocket server + if ($this->ws_client === null || !$this->ws_client->is_connected()) { + //$this->warn("Web socket disconnected"); + if (!$this->connect_to_ws_server()) { + if (!$suppress_ws_message) $this->error("Unable to connect to websocket server."); + $suppress_ws_message = true; + } + } + + // The switch _socket_ is used to read the 'data ready' on the stream + if ($this->event_socket !== null && $this->event_socket->is_connected()) { + $read[] = $this->switch_socket; + $suppress_es_message = false; + } + + if ($this->ws_client !== null && $this->ws_client->is_connected()) { + $read[] = $this->ws_client->socket(); + $suppress_ws_message = false; + } + + if (!empty($read)) { + $write = $except = []; + // Wait for an event and timeout at 1/3 of a second so we can re-check all connections + if (false === stream_select($read, $write, $except, 0, 333333)) { + // severe error encountered so exit + $this->running = false; + // Exit with non-zero exit code + return 1; + } + + if (!empty($read)) { + $this->debug("Received event"); + // Iterate over each socket event + foreach ($read as $resource) { + // Switch event + if ($resource === $this->switch_socket) { + $this->handle_switch_event(); + // No need to process more in the loop + continue; + } + + // Web socket event + if ($resource === $this->ws_client->socket()) { + $this->handle_websocket_event($this->ws_client); + continue; + } + } + } + } + } + + // Normal termination + return 0; + } + + private function debug(string $message) { + self::log($message, LOG_DEBUG); + } + + private function warn(string $message) { + self::log($message, LOG_WARNING); + } + + private function error(string $message) { + self::log($message, LOG_ERR); + } + + private function info(string $message) { + self::log($message, LOG_INFO); + } + + private function on_authenticate(websocket_message $websocket_message) { + $this->info("Authenticating with websocket server"); + // Create a service token + [$token_name, $token_hash] = websocket_client::create_service_token(active_calls_service::get_service_name(), static::class); + + // Request authentication as a service + $this->ws_client->authenticate($token_name, $token_hash); + } + + private function on_in_progress(websocket_message $websocket_message) { + // Check permission + if (!$websocket_message->has_permission('call_active_view')) { + $this->warn("Permission 'call_active_show' not found in subscriber request"); + websocket_client::send($this->ws_client->socket(), websocket_message::request_forbidden($websocket_message->request_id, SERVICE_NAME, $websocket_message->topic)); + } + + // Make sure we are connected + if (!$this->event_socket->is_connected()) { + return; + } + + // Set up the response array + $response = []; + $response['service_name'] = SERVICE_NAME; + // Attach the original request ID and subscriber ID given from websocket server so it can route it back + $response['request_id'] = $websocket_message->request_id; + $response['resource_id'] = $websocket_message->resource_id; + $response['status_string'] = 'ok'; + $response['status_code'] = 200; + + // Get the active calls from the helper function + $calls = $this->get_active_calls($this->event_socket, $this->ws_client); + $count = count($calls); + $this->debug("Sending calls in progress ($count)"); + + // Use the subscribers permissions to filter out the event keys not permitted + $filter = filter_chain::or_link([new permission_filter(self::PERMISSION_MAP, $websocket_message->permissions())]); + + /** @var event_message $event */ + foreach ($calls as $event) { + // Remove keys that are not permitted by filter + $event->apply_filter($filter); + $response['payload'] = $event; + $response['topic'] = $event->name; + $websocket_response = new websocket_message($response); + websocket_client::send($this->ws_client->socket(), $websocket_response); + } + } + + private function on_hangup(websocket_message $websocket_message) { + // Check permission + if (!$websocket_message->has_permission('call_active_hangup')) { + $this->warn("Permission 'call_active_hangup' not found in subscriber request"); + websocket_client::send($this->ws_client->socket(), websocket_message::request_forbidden($websocket_message->request_id, SERVICE_NAME, $websocket_message->topic)); + } + + // Make sure we are connected + if (!$this->event_socket->is_connected()) { + $this->warn("Failed to hangup call because event socket no longer connected"); + return; + } + + // Set up the response array + $response = []; + $response['service_name'] = SERVICE_NAME; + $response['topic'] = $websocket_message->topic; + $response['request_id'] = $websocket_message->request_id; + + // Get the payload + $payload = $websocket_message->payload; + + // Get the UUID from the payloadc + $uuid = $payload['unique_id'] ?? ''; + + $response['status_message'] = 'success'; + $response['status_code'] = 200; + + //Notify switch to hangup and ignore the response + $response = $this->event_socket->request("bgapi uuid_kill $uuid"); + + // Notify websocket server of the result + websocket_client::send($this->ws_client->socket(), new websocket_message($response)); + } + + private function on_eavesdrop(websocket_message $websocket_message) { + // Check permission + if (!$websocket_message->has_permission('call_active_eavesdrop')) { + $this->warn("Permission 'call_active_eavesdrop' not found in subscriber request"); + websocket_client::send($this->ws_client->socket(), websocket_message::request_forbidden($websocket_message->request_id, SERVICE_NAME, $websocket_message->topic)); + } + + // Make sure we are connected + if (!$this->event_socket->is_connected()) { + $this->warn("Failed to hangup call because event socket no longer connected"); + return; + } + + // Set up the response array + $response = []; + $response['service_name'] = SERVICE_NAME; + $response['topic'] = $websocket_message->topic; + $response['request_id'] = $websocket_message->request_id; + + // Get the payload + $payload = $websocket_message->payload(); + + // Get the eavesdrop information from the payload to send to the switch + $uuid = $payload['unique_id'] ?? ''; + $origination_caller_id_name = $payload['origination_caller_id_name'] ?? ''; + $caller_caller_id_number = $payload['caller_caller_id_number'] ?? ''; + $origination_caller_contact = $payload['origination_caller_contact'] ?? ''; + $domain_name = $payload['domain_name'] ?? ''; + + $response['status_message'] = 'success'; + $response['status_code'] = 200; + + $api_cmd = "bgapi originate {origination_caller_id_name=$origination_caller_id_name,origination_caller_id_number=$caller_caller_id_number}user/$origination_caller_contact@$domain_name &eavesdrop($uuid)"; + + // Log the eavesdrop + $this->info("Eavesdrop on $uuid by $origination_caller_contact@$domain_name"); + + // + // Send to the switch and ignore the result + // Ignoring the switch information is important because on a busy system there will be more + // events so the response is not necessarily correct + // + $this->event_socket->request($api_cmd); + + // Execute eavesdrop command + $response['status_message'] = 'success'; + $response['status_code'] = 200; + + // Notify websocket server of the result + websocket_client::send($this->ws_client->socket(), new websocket_message($response)); + } + + /** + * Connects to the web socket server using a websocket_client object + * @return bool + */ + private function connect_to_ws_server(): bool { + $host = self::$websocket_host ?? self::$config->get('websocket.host', '127.0.0.1'); + $port = self::$websocket_port ?? self::$config->get('websocket.port', 8080); + try { + // Create a websocket client + $this->ws_client = new websocket_client("ws://$host:$port"); + + // Block stream for handshake and authentication + $this->ws_client->set_blocking(true); + + // Connect to web socket server + $this->ws_client->connect(); + + // Disable the stream blocking + $this->ws_client->set_blocking(false); + + $this->debug(self::class . " RESOURCE ID: " . $this->ws_client->socket()); + } catch (\RuntimeException $re) { + //unable to connect + return false; + } + return true; + } + + /** + * Connects to the switch event socket + * @return bool + */ + private function connect_to_event_socket(): bool { + + // check if we have defined it already + if (!isset($this->switch_socket)) { + //default to false for the while loop below + $this->switch_socket = false; + } + + // When no command line option is used to set the switch host, port, or password, get it from + // the config file. If it is not in the config file, then set a default value + $host = self::$switch_host ?? parent::$config->get('switch.event_socket.host', '127.0.0.1'); + $port = self::$switch_port ?? parent::$config->get('switch.event_socket.port', 8021); + $password = self::$switch_password ?? parent::$config->get('switch.event_socket.password', 'ClueCon'); + + try { + //set up the socket away from the event_socket object so we have control over blocking + $this->switch_socket = stream_socket_client("tcp://$host:$port", $errno, $errstr, 5); + } catch (\RuntimeException $re) { + $this->warn('Unable to connect to event socket'); + } + + // If we didn't connect then return back false + if (!$this->switch_socket) { + return false; + } + + // Block (wait) for responses so we can authenticate + stream_set_blocking($this->switch_socket, true); + + // Create the event_socket object using the connected socket + $this->event_socket = new event_socket($this->switch_socket); + + // The host and port are already provided when we connect the socket so just provide password + $this->event_socket->connect(null, null, $password); + + // No longer need to wait for events + stream_set_blocking($this->switch_socket, false); + + return $this->event_socket->is_connected(); + } + + /** + * Registers the switch events needed for active calls + */ + private function register_event_socket_filters() { + $this->event_socket->request('event plain all'); + + // + // CUSTOM and API are required to handle events such as: + // - 'valet_parking::info' + // - 'SMS::SEND_MESSAGE' + // - 'cache::flush' + // - 'sofia::register' + // + // $event_filter = [ + // 'CUSTOM', // Event-Name is swapped with Event-Subclass + // 'API', // Event-Name is swapped with API-Command + // ]; + // Merge API and CUSTOM with the events listening + // $events = array_merge(ws_active_calls::SWITCH_EVENTS, $event_filter); + // Add filters for active calls only + foreach (active_calls_service::SWITCH_EVENTS as $events) { + foreach ($events as $event_key => $event_name) { + $this->debug("Requesting event filter for [$event_key]=[$event_name]"); + $response = $this->event_socket->request("filter $event_key $event_name"); + while (!is_array($response)) { + $response = $this->event_socket->read_event(); + } + if (is_array($response)) { + while (($response = array_pop($response)) !== "+OK filter added. [$event_key]=[$event_name]") { + $response = $this->event_socket->read_event(); + usleep(1000); + } + } + $this->debug("Response: " . $response); + } + } + } + + private function get_active_calls(): array { + $calls = []; + + // + // We use a new socket connection to get the response because the switch + // can be firing events while we are processing so we need only this + // request answered + // + $event_socket = new event_socket(); + $event_socket->connect(); + + // Make sure we are connected + if (!$event_socket->is_connected()) { + return $calls; + } + + // Send the command on a new channel + $json = trim($event_socket->request('api show channels as json')); + $event_socket->close(); + + $json_array = json_decode($json, true); + if (empty($json_array["rows"])) { + return $calls; + } + + // Map the rows returned to the active call format + foreach ($json_array["rows"] as $call) { + $message = new event_message($call, $this->event_filter); + $this->debug("MESSAGE: $message"); + // adjust basic info to match an event setting the callstate to ringing + // so that a row can be created for it + $message->event_name = 'CHANNEL_CALLSTATE'; + $message->answer_state = 'ringing'; + $message->channel_call_state = 'RINGING'; + $message->unique_id = $call['uuid']; + $message->call_direction = $call['direction']; + + //set the codecs + $message->caller_channel_created_time = intval($call['created_epoch']) * 1000000; + $message->channel_read_codec_name = $call['read_codec']; + $message->channel_read_codec_rate = $call['read_rate']; + $message->channel_write_codec_name = $call['write_codec']; + $message->channel_write_codec_rate = $call['write_rate']; + + //get the profile name + $message->caller_channel_name = $call['name']; + + //domain or context + $message->caller_context = $call['context']; + $message->caller_caller_id_name = $call['initial_cid_name']; + $message->caller_caller_id_number = $call['initial_cid_num']; + $message->caller_destination_number = $call['initial_dest']; + $message->application = $call['application'] ?? ''; + $message->secure = $call['secure'] ?? ''; + + if (true) { + $this->debug("-------- ACTIVE CALL ----------"); + $this->debug($message); + $this->debug("In Progress: '$message->name', $message->unique_id"); + $this->debug("-------------------------------"); + } + $calls[] = $message; + } + + return $calls; + } + + /** + * Call each of the registered events for the websocket topic that has arrived + * @param string $topic + * @param websocket_message $websocket_message + */ + private function trigger_topic(string $topic, websocket_message $websocket_message) { + if (empty($topic) || empty($websocket_message)) { + return; + } + + if (!empty($this->topics[$topic])) { + foreach ($this->topics[$topic] as $callback) { + call_user_func($callback, $websocket_message); + } + } + } + + /** + * Allows the service to register a callback so when the topic arrives the callable is called + * @param type $topic + * @param type $callable + */ + public function on_topic($topic, $callable) { + if (!isset($this->topics[$topic])) { + $this->topics[$topic] = []; + } + $this->topics[$topic][] = $callable; + } + + /** + * Handles the message from the web socket client and triggers the appropriate requested topic event + * @param resource $ws_client + * @return void + */ + private function handle_websocket_event() { + // Read the JSON string + $json_string = $this->ws_client->read(); + + // Nothing to do + if ($json_string === null) { + $this->warn('Message received from Websocket is empty'); + return; + } + + $this->debug("Received message on websocket: $json_string (" . strlen($json_string) . " bytes)"); + + // Get the web socket message as an object + $message = websocket_message::create_from_json_message($json_string); + + // Nothing to do + if (empty($message->topic())) { + $this->error("Message received does not have topic"); + return; + } + + // Call the registered topic event + $this->trigger_topic($message->topic, $message, $this->ws_client); + } + + /** + * Handles a switch event by reading the event and then dispatching to the web socket server + */ + private function handle_switch_event() { + + $raw_event = $this->event_socket->read_event(); + + //$this->debug("====================================="); + //$this->debug("RAW EVENT: " . ($raw_event['$'] ?? '')); + //$this->debug("====================================="); + + // get the switch message event object + $event = event_message::create_from_switch_event($raw_event, $this->event_filter); + + // Log the event + $this->debug("EVENT: '" . $event->name . "'"); + + if (!$this->ws_client->is_connected()) { + $this->debug('Not connected to websocket host. Dropping Event'); + return; + } + + // Ensure it is an event that we are looking for + if (active_calls_service::event_exists($event->name)) { + // Create a message to send on websocket + $message = new websocket_message(); + + // Set the service name so subscribers can filter + $message->service(SERVICE_NAME); + + // Set the topic to the event name + $message->topic = $event->name; + + // The event is the payload + $message->payload($event->to_array()); + + // Notify system log of the message and event name + $this->debug("Sending Event: '$event->event_name'"); + + //send event to the web socket routing service + websocket_client::send($this->ws_client->socket(), $message); + } + } + + /** + * Gets the array of enabled domains using the UUID as the array key and the domain name as the array value + * @return array + */ + private static function get_domain_names(database $database): array { + return array_column($database->execute("select domain_name, domain_uuid from v_domains where domain_enabled='true'") ?: [], 'domain_name', 'domain_uuid'); + } + + /** + * Queries the database to return the domain name for the given uuid + * @param string $domain_uuid + * @return string + */ + private static function get_domain_name_by_uuid(database $database, string $domain_uuid): string { + return $database->execute("select domain_name from v_domains where domain_enabled='true' and domain_uuid = :domain_uuid limit 1", ['domain_uuid' => $domain_uuid], 'column') ?: ''; + } + + /** + * Queries the database to return a single domain uuid for the given name. If more then one match is possible use the get_domain_names function. + * @param string $domain_name + * @return string + */ + private static function get_domain_uuid_by_name(database $database, string $domain_name): string { + return $database->execute("select domain_uuid from v_domains where domain_enabled='true' and domain_name = :domain_name limit 1", ['domain_name' => $domain_name], 'column') ?: ''; + } +} diff --git a/app/active_calls/resources/classes/event_filter.php b/app/active_calls/resources/classes/event_filter.php new file mode 100644 index 0000000000..6a30aa6dbb --- /dev/null +++ b/app/active_calls/resources/classes/event_filter.php @@ -0,0 +1,85 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * Filter an event based on event name or event subclass or event command + * + * @author Tim Fry + */ +class event_filter implements filter { + + private $event_names; + + public function __construct(array $event_names) { + $this->add_event_names($event_names); + } + + public function __invoke(string $key, $value): ?bool { + if ($key !== 'event_name') { + return true; + } + return $this->has_event_name($value); + } + + /** + * Adds a single event name filter + * @param string $name + */ + public function add_event_name(string $name) { + $this->event_names[$name] = $name; + } + + /** + * Adds the array list to the filters. + * @param array $event_names + */ + public function add_event_names(array $event_names) { + // Add all event key filters passed + foreach ($event_names as $event_name) { + if (is_array($event_name)) { + $this->add_event_names($event_name); + } else { + $this->add_event_name($event_name); + } + } + } + + public function has_event_name(string $name): ?bool { + if (isset($this->event_names[$name])) + return true; + // + // If the event name is not allowed by the permissions given in + // this object, then the entire event must be dropped. I could + // not figure out a better way to do this except to throw an + // exception so that the caller can drop the message. + // + // TODO: Find another way not so expensive to reject the payload + // + return null; + } +} diff --git a/app/active_calls/resources/classes/event_key_filter.php b/app/active_calls/resources/classes/event_key_filter.php new file mode 100644 index 0000000000..a4c6d4f1c9 --- /dev/null +++ b/app/active_calls/resources/classes/event_key_filter.php @@ -0,0 +1,90 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * Active call filter class definition + * @author Tim Fry + */ +class event_key_filter implements filter { + + private $filters; + + public function __construct(array $filters = []) { + $this->add_filters($filters); + } + + public function __invoke(string $key, $value): ?bool { + return $this->has_filter_key($key); + } + + /** + * Adds a single filter + * @param string $key + */ + public function add_filter(string $key) { + $this->filters[$key] = $key; + } + + /** + * Returns the current list of filters + * @return array + */ + public function get_filters(): array { + return array_values($this->filters); + } + + /** + * Removes a single list of filters + * @param string $key + */ + public function remove_filter(string $key) { + unset($this->filters[$key]); + } + + /** + * Clears all filters + */ + public function clear_filters() { + $this->filters = []; + } + + /** + * Adds the array list to the filters. + * @param array $list_of_keys + */ + public function add_filters(array $list_of_keys) { + // Add all event key filters passed + foreach ($list_of_keys as $key) { + $this->filters[$key] = $key; + } + } + + public function has_filter_key(string $key): bool { + return isset($this->filters[$key]); + } +} diff --git a/app/active_calls/resources/classes/event_message.php b/app/active_calls/resources/classes/event_message.php new file mode 100644 index 0000000000..e38261f805 --- /dev/null +++ b/app/active_calls/resources/classes/event_message.php @@ -0,0 +1,323 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * Tracks switch events in an object instead of array + * + * @author Tim Fry + */ +class event_message implements filterable_payload { + + const BODY_ARRAY_KEY = '_body'; + + const EVENT_SWAP_API = 0x01; + const EVENT_USE_SUBCLASS = 0x02; + + // Default keys in the event to capture + public static $keys = []; + + /** + * Associative array to store the event with the key name always lowercase and the hyphen replaced with an underscore + * @var array + */ + private $event; + private $body; + + /** + * Only permitted keys on this list are allowed to be inserted in to the event_message object + * @var filter + */ + private $event_filter; + + /** + * Creates an event message + * @param array $event_array + * @param filter $filter + */ + public function __construct(array $event_array, ?filter $filter = null) { + + // Set the event to an empty array + $this->event = []; + + // Clear the memory area for body and key_filter + $this->body = null; + + $this->event_filter = $filter; + + // Set the event array to match + foreach ($event_array as $name => $value) { + $this->__set($name, $value); + } + + } + + /** + * Sanitizes the key name and then stores the value in the event property as an associative array + * @param string $name + * @param string $value + * @return void + */ + public function __set(string $name, $value) { + self::sanitize_event_key($name); + + // Use the filter chain to ensure the key is allowed + if ($this->event_filter === null || ($this->event_filter)($name, $value)) { + $this->event[$name] = $value; + } + } + + /** + * Sanitizes the key name and then returns the value stored in the event property + * @param string $name Name of the event key + * @return string Returns the stored value or an empty string + */ + public function __get(string $name) { + self::sanitize_event_key($name); + if ($name === 'name') $name = 'event_name'; + return $this->event[$name] ?? ''; + } + + public function __toArray(): array { + $array = []; + foreach ($this->event as $key => $value) { + $array[$key] = $value; + } + return $array; + } + + public function to_array(): array { + return $this->__toArray(); + } + + public function apply_filter(filter $filter) { + foreach ($this->event as $key => $value) { + $result = ($filter)($key, $value); + if ($result === null) { + $this->event = []; + } elseif (!$result) { + unset($this->event[$key]); + } + } + return $this; + } + + public static function parse_active_calls($json_string): array { + $calls = []; + $json_array = json_decode($json_string, true); + if (empty($json_array["rows"])) { + return $calls; + } + foreach ($json_array["rows"] as $call) { + $message = new event_message($call); + // adjust basic info to match an event setting the callstate to ringing + // so that a row can be created for it + $message['event_name'] = 'CHANNEL_CALLSTATE'; + $message['answer_state'] = 'ringing'; + $message['channel_call_state'] = 'ACTIVE'; + $message['unique_id'] = $call['uuid']; + $message['call_direction'] = $call['direction']; + + //set the codecs + $message['caller_channel_created_time'] = intval($call['created_epoch']) * 1000000; + $message['channel_read_codec_name'] = $call['read_codec']; + $message['channel_read_codec_rate'] = $call['read_rate']; + $message['channel_write_codec_name'] = $call['write_codec']; + $message['channel_write_codec_rate'] = $call['write_rate']; + + //get the profile name + $message['caller_channel_name'] = $call['name']; + + //domain or context + $message['caller_context'] = $call['context']; + $message['caller_caller_id_name'] = $call['initial_cid_name']; + $message['caller_caller_id_number'] = $call['initial_cid_num']; + $message['caller_destination_number'] = $call['initial_dest']; + $message['application'] = $call['application'] ?? ''; + $message['secure'] = $call['secure'] ?? ''; + $calls[] = $message; + } + return $calls; + } + + /** + * Creates a websocket_message_event object from a json string + * @param type $json_string + * @return self|null + */ + public static function create_from_json($json_string) { + if (is_array($json_string)) { + print_r(debug_backtrace()); + die(); + } + $array = json_decode($json_string, true); + if ($array !== false) { + return new static($array); + } + return null; + } + + public static function create_from_switch_event($raw_event, filter $filter = null, $flags = 3): self { + + // Set the options from the flags passed + $swap_api_name_with_event_name = ($flags & self::EVENT_SWAP_API) !== 0; + $swap_subclass_event_name_with_event_name = ($flags & self::EVENT_USE_SUBCLASS) !== 0; + + // Get the payload and ignore the headers + if (is_array($raw_event) && isset($raw_event['$'])) { + $raw_event = $raw_event['$']; + } + + //check if it is still an array + if (is_array($raw_event)) { + $raw_event = array_pop($raw_event); + } + + $event_array = []; + foreach (explode("\n", $raw_event) as $line) { + $parts = explode(':', $line, 2); + $key = ''; + $value = ''; + if (count($parts) > 0) { + $key = $parts[0]; + } + if (count($parts) > 1) { + $value = urldecode(trim($parts[1])); + } + if (!empty($key)) { + $event_array[$key] = $value; + } + } + + //check for body + if (!empty($event_array['Content-Length'])) { + $event_array['_body'] = substr($raw_event, -1*$event_array['Content-Length']); + } + + // Instead of using 'CUSTOM' for the Event-Name we use the actual API-Command when it is available instead + if ($swap_api_name_with_event_name && !empty($event_array['API-Command'])) { + // swap the values + [$event_array['Event-Name'], $event_array['API-Command']] = [$event_array['API-Command'], $event_array['Event-Name']]; + } + + // Promote the Event-Subclass name to the Event-Name + if ($swap_subclass_event_name_with_event_name && !empty($event_array['Event-Subclass'])) { + // swap the values + [$event_array['Event-Name'], $event_array['Event-Subclass']] = [$event_array['Event-Subclass'], $event_array['Event-Name']]; + } + + // Return the new object + return new static($event_array, $filter); + } + + /** + * Return a Json representation for this object when the object is echoed or printed + * @return string + * @override websocket_message + */ + public function __toString(): string { + return json_encode($this->to_array()); + } + + /** + * Set or Get the body + * @param null|string $body + * @return self|string + */ + public function body(?string $body = null) { + + // Check if we are setting the value for body + if (func_num_args() > 0) { + + // Set the value + $this->body = $body; + + // Return the object for chaining + return $this; + } + + // A request was made to get the value from body + return $this->body; + } + + public function event_to_array(): array { + $array = []; + foreach ($this->event as $key => $value) { + $array[$key] = $value; + } + if ($this->body !== null) { + $array[self::BODY_ARRAY_KEY] = $this->body; + } + return $array; + } + + public function getIterator(): \Traversable { + yield from $this->event_to_array(); + } + + public function offsetExists(mixed $offset): bool { + self::sanitize_event_key($offset); + return isset($this->event[$offset]); + } + + public function offsetGet(mixed $offset): mixed { + self::sanitize_event_key($offset); + if ($offset === self::BODY_ARRAY_KEY) { + return $this->body; + } + return $this->event[$offset]; + } + + public function offsetSet(mixed $offset, mixed $value): void { + self::sanitize_event_key($offset); + if ($offset === self::BODY_ARRAY_KEY) { + $this->body = $value; + } else { + $this->event[$offset] = $value; + } + } + + public function offsetUnset(mixed $offset): void { + self::sanitize_event_key($offset); + if ($offset === self::BODY_ARRAY_KEY) { + $this->body = null; + } else { + unset($this->event[$offset]); + } + } + + /** + * Sanitizes key by replacing '-' with '_', converts to lowercase, and only allows digits 0-9 and letters a-z + * @param string $key + * @return string + */ + public static function sanitize_event_key(string &$key) /* : never */ { + $key = preg_replace('/[^a-z0-9_]/', '', str_replace('-', '_', strtolower($key))); + //rewrite 'name' to 'event_name' + if ($key === 'name') $key = 'event_name'; + } +} diff --git a/app/active_calls/resources/javascript/arrows.js b/app/active_calls/resources/javascript/arrows.js new file mode 100644 index 0000000000..b9c4adfb7f --- /dev/null +++ b/app/active_calls/resources/javascript/arrows.js @@ -0,0 +1,297 @@ +/* + * FusionPBX + * Version: MPL 1.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is FusionPBX + * + * The Initial Developer of the Original Code is + * Mark J Crane + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +function create_arrow(direction, color) { + switch (direction) { + case 'inbound': + arrow = create_arrow_inbound(color); + break; + case 'outbound': + arrow = create_arrow_outbound(color); + break; + case 'local': + arrow = create_arrow_local(color); + break; + case 'voicemail': + arrow = create_voicemail_icon(color); + break; + case 'missed': + arrow = create_inbound_missed(color); + break; + } + return arrow; +} + +function create_arrow_outbound(color, gridSize = 25) { + // Create SVG from SVG Namespace + const SVG_NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(SVG_NS, "svg"); + + // compute how much to scale the original 24-unit grid + const scale = gridSize / 25; + + // Set color + svg.setAttribute("stroke", color); + // Set brush width + svg.setAttribute("width", gridSize); + svg.setAttribute("height", gridSize); + svg.setAttribute("viewBox", `0 0 ${gridSize} ${gridSize}`); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke-width", 2 * scale); + svg.setAttribute("stroke-linecap", "round"); + + // Create line + const line = document.createElementNS(SVG_NS, "line"); + line.setAttribute("x1", (4 * scale).toString()); + line.setAttribute("y1", (20 * scale).toString()); + line.setAttribute("x2", (20 * scale).toString()); + line.setAttribute("y2", (4 * scale).toString()); + svg.appendChild(line); + + // Create the arrow head (a right-angle triangle) + const head = document.createElementNS(SVG_NS, "polygon"); +// head.setAttribute("points", "20,4 10,9 15,14"); + head.setAttribute("points", [[20, 4], [10, 9], [15, 14]] + .map(([x, y]) => `${x * scale},${y * scale}`).join(" ") + ); + head.setAttribute("fill", color); + svg.appendChild(head); + return svg; +} + +function create_arrow_inbound(color, gridSize = 25) { + const SVG_NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(SVG_NS, "svg"); + + // compute how much to scale the original 24-unit grid + const scale = gridSize / 25; + + // size and viewport + svg.setAttribute("width", gridSize); + svg.setAttribute("height", gridSize); + svg.setAttribute("viewBox", `0 0 ${gridSize} ${gridSize}`); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", color); + svg.setAttribute("stroke-width", 2 * scale); + svg.setAttribute("stroke-linecap", "round"); + + // scaled line from (4,4) → (20,20) + const line = document.createElementNS(SVG_NS, "line"); + line.setAttribute("x1", (4 * scale).toString()); + line.setAttribute("y1", (4 * scale).toString()); + line.setAttribute("x2", (20 * scale).toString()); + line.setAttribute("y2", (20 * scale).toString()); + svg.appendChild(line); + + // scaled triangle head: (20,20), (10,15), (15,10) + const head = document.createElementNS(SVG_NS, "polygon"); + head.setAttribute("points", [[20, 20], [10, 15], [15, 10]] + .map(([x, y]) => `${x * scale},${y * scale}`).join(" ") + ); + head.setAttribute("fill", color); + svg.appendChild(head); + + return svg; +} + +function create_arrow_local(color, gridSize = 25) { + const SVG_NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(SVG_NS, "svg"); + + // compute how much to scale the original 25-unit grid + const scale = gridSize / 25; + + // sizing & styling + svg.setAttribute("width", gridSize); + svg.setAttribute("height", gridSize); + svg.setAttribute("viewBox", `0 0 ${gridSize} ${gridSize}`); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", color); + svg.setAttribute("stroke-width", 2 * scale); + svg.setAttribute("stroke-linecap", "round"); + + // shaft + const line = document.createElementNS(SVG_NS, "line"); + line.setAttribute("x1", 6 * scale); + line.setAttribute("y1", 12 * scale); + line.setAttribute("x2", 18 * scale); + line.setAttribute("y2", 12 * scale); + svg.appendChild(line); + + // left arrow head + const leftHead = document.createElementNS(SVG_NS, "polygon"); + leftHead.setAttribute("points", [[6,8], [2,12], [6,16]] + .map(([x, y]) => `${x * scale},${y * scale}`).join(" ") + ); + leftHead.setAttribute("fill", color); + svg.appendChild(leftHead); + + // right arrow head + const rightHead = document.createElementNS(SVG_NS, "polygon"); + rightHead.setAttribute("points", [[18,8], [22,12], [18,16]] + .map(([x, y]) => `${x * scale},${y * scale}`).join(" ") + ); + rightHead.setAttribute("fill", color); + svg.appendChild(rightHead); + + return svg; +} + +function create_inbound_missed(color, gridSize = 25) { + const SVG_NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(SVG_NS, "svg"); + + // compute how much to scale the original 25-unit grid + const scale = gridSize / 25; + + // size and viewport + svg.setAttribute("width", gridSize); + svg.setAttribute("height", gridSize); + svg.setAttribute("viewBox", `0 0 ${gridSize} ${gridSize}`); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", color); + svg.setAttribute("stroke-width", 2 * scale); + svg.setAttribute("stroke-linecap", "round"); + + // 5. Reflective bounce polyline + const bounce = document.createElementNS(SVG_NS, 'polyline'); + bounce.setAttribute('points', [[4, 4], [12, 12], [20, 4]] + .map(([x, y]) => `${x * scale},${y * scale}`).join(" ") + ); + bounce.setAttribute('stroke', color); + bounce.setAttribute('stroke-width', 2 * scale); + bounce.setAttribute('fill', 'none'); + bounce.setAttribute('stroke-linecap', 'round'); + bounce.setAttribute('marker-end', 'url(#arrowhead)'); + svg.appendChild(bounce); + + // scaled triangle head: tip[20,4], left wing[17,5], right wing[19, 7] + const head = document.createElementNS(SVG_NS, "polygon"); + head.setAttribute("points", [[20, 4], [17, 5], [19, 7]] + .map(([x, y]) => `${x * scale},${y * scale}`).join(" ") + ); + head.setAttribute("fill", color); + svg.appendChild(head); + + // Left earpiece + const left = document.createElementNS(SVG_NS, 'ellipse'); + left.setAttribute("cx", 4 * scale); + left.setAttribute("cy", 17 * scale); + left.setAttribute("rx", 2 * scale); + left.setAttribute("ry", 1 * scale); + left.setAttribute("fill", color); + svg.appendChild(left); + + // Right earpiece + const right = document.createElementNS(SVG_NS, 'ellipse'); + right.setAttribute("cx", 18 * scale); + right.setAttribute("cy", 17 * scale); + right.setAttribute("rx", 2 * scale); + right.setAttribute("ry", 1 * scale); + right.setAttribute("fill", color); + svg.appendChild(right); + + // Arc to join left and right + const startX = 3 * scale; // left cx + rx + const startY = 17 * scale; // cy - ry + const endX = 19 * scale; // right cx - rx + const endY = startY; + + // choose radii so the handle bows upwards + const rx = (endX - startX) / 2; // half the distance + const ry = 2.2 * scale; // controls how tall the arc is + + const arc = document.createElementNS(SVG_NS, 'path'); + // Move to the left‐earpiece top, then arc to the right‐earpiece top + const d = `M${startX},${startY} A${rx},${ry} 0 0,1 ${endX},${endY}`; + arc.setAttribute('d', d); + arc.setAttribute('stroke', color); + arc.setAttribute('stroke-width', 2 * scale); + arc.setAttribute('stroke-linecap', 'round'); + + svg.appendChild(arc); + return svg; +} + +function create_voicemail_icon(fillColor, gridSize = 25) { + // SVG namespace + const SVG_NS = 'http://www.w3.org/2000/svg'; + + const scale = gridSize / 25; + const width = scale * 25; + const height = scale * 25; + + // Create the root SVG element + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('width', width); + svg.setAttribute('height', height); + svg.setAttribute('viewBox', '0 0 25 25'); + svg.setAttribute('aria-hidden', 'true'); + + // Border rectangle (inserted first so it's underneath) + const border = document.createElementNS(SVG_NS, 'rect'); + y = 7; + border.setAttribute('x', 1); + border.setAttribute('y', y); + border.setAttribute('width', 23); + border.setAttribute('height', 21 - y); + border.setAttribute('fill', 'none'); + border.setAttribute('stroke', fillColor); + border.setAttribute('stroke-width', '2'); + svg.appendChild(border); + + // Left circle + const left_circle = document.createElementNS(SVG_NS, 'circle'); + left_circle.setAttribute('cx', 7); + left_circle.setAttribute('cy', 14); + left_circle.setAttribute('r', 3); + left_circle.setAttribute('fill', 'none'); + left_circle.setAttribute('stroke', fillColor); + left_circle.setAttribute('stroke-width', '2'); + svg.appendChild(left_circle); + + // Right circle + const right_circle = document.createElementNS(SVG_NS, 'circle'); + right_circle.setAttribute('cx', 18); + right_circle.setAttribute('cy', 14); + right_circle.setAttribute('r', 3); + right_circle.setAttribute('fill', 'none'); + right_circle.setAttribute('stroke', fillColor); + right_circle.setAttribute('stroke-width', '2'); + svg.appendChild(right_circle); + + // Connecting line + const bar = document.createElementNS(SVG_NS, 'line'); + bar.setAttribute('x1', 6); + bar.setAttribute('y1', 11); + bar.setAttribute('x2', 19); + bar.setAttribute('y2', 11); + bar.setAttribute('stroke', fillColor); + bar.setAttribute('stroke-width', '2'); + svg.appendChild(bar); + + return svg; +} diff --git a/app/active_calls/resources/javascript/websocket_client.js b/app/active_calls/resources/javascript/websocket_client.js new file mode 100644 index 0000000000..3b5254a213 --- /dev/null +++ b/app/active_calls/resources/javascript/websocket_client.js @@ -0,0 +1,136 @@ +class ws_client { + constructor(url, token) { + this.ws = new WebSocket(url); + this.ws.addEventListener('message', this._onMessage.bind(this)); + this._nextId = 1; + this._pending = new Map(); + this._eventHandlers = new Map(); + // The token is submitted on every request + this.token = token; + } + + // internal message handler called when event occurs on the socket + _onMessage(ev) { + let message; + let switch_event; + try { + //console.log(ev.data); + message = JSON.parse(ev.data); + // check for authentication request + if (message.status_code === 407) { + console.log('Authentication Required'); + return; + } + switch_event = message.payload; + //console.log('envelope received: ',env); + } catch (err) { + console.error('Error parsing JSON data:', err); + //console.error('Invalid JSON:', ev.data); + return; + } + + // Pull out the request_id first + const rid = message.request_id ?? null; + + // If this is the response to a pending request + if (rid && this._pending.has(rid)) { + // Destructure with defaults in case they're missing + const { + service, + topic = '', + status = 'ok', + code = 200, + payload = {} + } = message; + + const {resolve, reject} = this._pending.get(rid); + this._pending.delete(rid); + + if (status === 'ok' && code >= 200 && code < 300) { + resolve({service, topic, payload, code, message}); + } else { + const err = new Error(message || `Error ${code}`); + err.code = code; + reject(err); + } + + return; + } + + // Otherwise it's a server‑pushed event… + // e.g. env.service === 'event' or env.topic is your event name + this._dispatchEvent(message.service_name, switch_event); + } + + // Send a request to the websocket server using JSON string + request(service, topic = null, payload = {}) { + const request_id = String(this._nextId++); + const env = { + request_id: request_id, + service, + ...(topic !== null ? {topic} : {}), + token: this.token, + payload: payload + }; + const raw = JSON.stringify(env); + this.ws.send(raw); + return new Promise((resolve, reject) => { + this._pending.set(request_id, {resolve, reject}); + // TODO: get timeout working to reject if no response in X ms + }); + } + + subscribe(topic) { + return this.request('active.calls', topic); + } + + unsubscribe(topic) { + return this.request('active.calls', topic); + } + + // register a callback for server-pushes + onEvent(topic, handler) { + console.log('registering event listener for ' + topic); + if (!this._eventHandlers.has(topic)) { + this._eventHandlers.set(topic, []); + } + this._eventHandlers.get(topic).push(handler); + } + /** + * Dispatch a server‑push event envelope to all registered handlers. + * @param {object} env + */ + _dispatchEvent(service, env) { + // if service==='event', topic carries the real event name: + let event = (typeof env === 'string') + ? JSON.parse(env) + : env; + + // dispatch event handlers + if (service === 'active.calls') { + const topic = event.event_name; + + let handlers = this._eventHandlers.get(topic) || []; + if (handlers.length === 0) { + handlers = this._eventHandlers.get('*') || []; + } + for (const fn of handlers) { + try { + fn(event); + } catch (err) { + console.error(`Error in handler for "${topic}":`, err); + } + } + } else { + const handlers = this._eventHandlers.get(service) || []; + for (const fn of handlers) { + try { + fn(event.data, event); + } catch (err) { + console.error(`Error in handler for "${service}":`, err); + } + } + } + } + +} diff --git a/app/active_calls/resources/service/active_calls.php b/app/active_calls/resources/service/active_calls.php new file mode 100755 index 0000000000..86da044ab2 --- /dev/null +++ b/app/active_calls/resources/service/active_calls.php @@ -0,0 +1,64 @@ +#!/usr/bin/env php + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * The purpose of this file is to subscribe to the switch events and then notify the web socket service of the event. + * + * Requirements: + * - PHP 7.1 or higher + * + * When an event is received from the switch, it is sent to the web socket service. The web socket service then + * broadcasts the event to all subscribers that have subscribed to the service that broadcasted the event. The + * web socket service only has information about who is connected. Each connection to the web socket service + * is called a subscriber. Subscribers can be either a service or a web client. When a token is created and + * given to the subscriber class using the "save_token" method, the subscriber is a web client. When the + * method used is "save_service_token", the subscriber is still a subscriber but now has elevated privileges. + * Each service can still subscribe to other events from other services just like regular subscribers. But, + * services have the added ability to broadcast events to other subscribers. + * + * Line 1 of this file allows the script to be executable + */ + +if (version_compare(PHP_VERSION, '7.1.0', '<')) { + die("This script requires PHP 7.1.0 or higher. You are running " . PHP_VERSION . "\n"); +} + +require_once dirname(__DIR__, 4) . '/resources/require.php'; + +define('SERVICE_NAME', active_calls_service::get_service_name()); + +try { + $active_calls_service = active_calls_service::create(); + // Exit using whatever status run returns + exit($active_calls_service->run()); +} catch (Exception $ex) { + echo "Error occurred in " . $ex->getFile() . ' (' . $ex->getLine() . '):' . $ex->getMessage(); + // Exit with error code + exit($ex->getCode()); +} diff --git a/app/active_calls/resources/service/debian-active_calls.service b/app/active_calls/resources/service/debian-active_calls.service new file mode 100644 index 0000000000..414daf3913 --- /dev/null +++ b/app/active_calls/resources/service/debian-active_calls.service @@ -0,0 +1,11 @@ +[Unit] +Description=Active Calls Websocket Service + +[Service] +ExecStart=/usr/bin/php /var/www/fusionpbx/app/active_calls/resources/service/active_calls.php --no-fork +Restart=on-failure +User=www-data +Group=www-data + +[Install] +WantedBy=multi-user.target diff --git a/app/calls_active/app_config.php b/app/calls_active/app_config.php index caafc5a997..ea299a137e 100644 --- a/app/calls_active/app_config.php +++ b/app/calls_active/app_config.php @@ -29,7 +29,11 @@ $apps[$x]['description']['ru-ru'] = "Активные каналы в системе"; $apps[$x]['description']['sv-se'] = ""; $apps[$x]['description']['uk-ua'] = ""; - +/* + * + * Permissions have been migrated to the active_calls app + * + * //permission details $y=0; $apps[$x]['permissions'][$y]['name'] = "call_active_view"; @@ -68,4 +72,4 @@ $y++; $apps[$x]['permissions'][$y]['name'] = "call_active_secure"; $apps[$x]['permissions'][$y]['groups'][] = "superadmin"; -?> +*/ diff --git a/app/system/resources/classes/bsd_system_information.php b/app/system/resources/classes/bsd_system_information.php new file mode 100644 index 0000000000..21e8077a83 --- /dev/null +++ b/app/system/resources/classes/bsd_system_information.php @@ -0,0 +1,55 @@ +. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * Description of bsd_system_information + * + * @author Tim Fry + */ +class bsd_system_information extends system_information { + + public function get_cpu_cores(): int { + $result = shell_exec("dmesg | grep -i --max-count 1 CPUs | sed 's/[^0-9]*//g'"); + $cpu_cores = trim($result); + return $cpu_cores; + } + + //get the CPU details + public function get_cpu_percent(): float { + $result = shell_exec('ps -A -o pcpu'); + $percent_cpu = 0; + foreach (explode("\n", $result) as $value) { + if (is_numeric($value)) { + $percent_cpu = $percent_cpu + $value; + } + } + return $percent_cpu; + } + + public function get_uptime() { + return shell_exec('uptime'); + } +} diff --git a/app/system/resources/classes/linux_system_information.php b/app/system/resources/classes/linux_system_information.php new file mode 100644 index 0000000000..e0a75b803c --- /dev/null +++ b/app/system/resources/classes/linux_system_information.php @@ -0,0 +1,78 @@ +. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * Description of linux_system_information + * + * @author Tim Fry + */ +class linux_system_information extends system_information { + + public function get_cpu_cores(): int { + $result = @trim(shell_exec("grep -P '^processor' /proc/cpuinfo")); + $cpu_cores = count(explode("\n", $result)); + return $cpu_cores; + } + + //get the CPU details + public function get_cpu_percent(): float { + $stat1 = file_get_contents('/proc/stat'); + usleep(500000); + $stat2 = file_get_contents('/proc/stat'); + + $lines1 = explode("\n", trim($stat1)); + $lines2 = explode("\n", trim($stat2)); + + $percent_cpu = 0; + $core_count = 0; + + foreach ($lines1 as $i => $line1) { + if (strpos($line1, 'cpu') !== 0 || $line1 === 'cpu') continue; + + $parts1 = preg_split('/\s+/', $line1); + $parts2 = preg_split('/\s+/', $lines2[$i]); + + $total1 = array_sum(array_slice($parts1, 1)); + $total2 = array_sum(array_slice($parts2, 1)); + + $idle1 = $parts1[4]; + $idle2 = $parts2[4]; + + $total_delta = $total2 - $total1; + $idle_delta = $idle2 - $idle1; + + $cpu_usage = ($total_delta - $idle_delta) / $total_delta * 100; + $percent_cpu += $cpu_usage; + $core_count++; + } + + return round($percent_cpu / $core_count, 2); + } + + public function get_uptime() { + return shell_exec('uptime'); + } +} diff --git a/app/system/resources/classes/system_dashboard_service.php b/app/system/resources/classes/system_dashboard_service.php new file mode 100644 index 0000000000..d0bb94916c --- /dev/null +++ b/app/system/resources/classes/system_dashboard_service.php @@ -0,0 +1,134 @@ +read(); + + // re-connect to the websocket server if required + $this->connect_to_ws_server(); + + // Connect to the database + $database = new database(['config' => parent::$config]); + + // get the interval + $this->settings = new settings(['database' => $database]); + + // get the settings from the global defaults + $this->cpu_status_refresh_interval = $this->settings->get('dashboard', 'cpu_status_refresh_interval', 3); + } + + /** + * @override base_websocket_system_service + * @return void + */ + protected function on_timer(): void { + // Send the CPU status + $this->on_cpu_status(); + } + + /** + * Executes once + * @return void + */ + protected function register_topics(): void { + + // get the settings from the global defaults + $this->reload_settings(); + + // Create a system information object that can tell us the cpu regardless of OS + self::$system_information = system_information::new(); + + // Register the call back to respond to cpu_status requests + $this->on_topic('cpu_status', [$this, 'on_cpu_status']); + + // Set a timer + $this->set_timer($this->cpu_status_refresh_interval); + + // Notify the user of the interval + $this->info("Broadcasting CPU Status every {$this->cpu_status_refresh_interval}s"); + } + + public function on_cpu_status($message = null): void { + // Get the CPU status + $cpu_percent = self::$system_information->get_cpu_percent(); + + // Send the response + $response = new websocket_message(); + $response + ->payload([self::CPU_STATUS_TOPIC => $cpu_percent]) + ->service_name(self::get_service_name()) + ->topic(self::CPU_STATUS_TOPIC); + + // Check if we are responding + if ($message !== null && $message instanceof websocket_message) { + $response->id($message->id()); + } + + // Log the broadcast + $this->debug("Broadcasting CPU percent '$cpu_percent'"); + + // Send to subscribers + $this->respond($response); + } + + public static function get_service_name(): string { + return "dashboard.system.information"; + } + + public static function create_filter_chain_for(subscriber $subscriber): ?filter { + // Get the subscriber permissions + $permissions = $subscriber->get_permissions(); + + // Create a filter + $filter = filter_chain::and_link([new permission_filter()]); + + // Match them to create a filter + foreach (self::PERMISSIONS as $permission) { + if (in_array($permission, $permissions)) { + $filter->add_permission($permission); + } + } + + // Return the filter with user permissions to ensure they can't receive information they shouldn't + return $filter; + } + + public static function set_system_information(): void { + self::$system_information = system_information::new(); + } +} diff --git a/app/system/resources/classes/system_information.php b/app/system/resources/classes/system_information.php new file mode 100644 index 0000000000..5eb70a60a5 --- /dev/null +++ b/app/system/resources/classes/system_information.php @@ -0,0 +1,51 @@ +. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/** + * Description of system_information + * + * @author Tim Fry + */ +abstract class system_information { + + abstract public function get_cpu_cores(): int; + abstract public function get_uptime(); + abstract public function get_cpu_percent(): float; + + public function get_load_average() { + return sys_getloadavg(); + } + + public static function new(): ?system_information { + if (stristr(PHP_OS, 'BSD')) { + return new bsd_system_information(); + } + if (stristr(PHP_OS, 'Linux')) { + return new linux_system_information(); + } + return null; + } +} diff --git a/app/system/resources/dashboard/system_cpu_status.php b/app/system/resources/dashboard/system_cpu_status.php index b4fdb7eba4..34c5106aff 100644 --- a/app/system/resources/dashboard/system_cpu_status.php +++ b/app/system/resources/dashboard/system_cpu_status.php @@ -1,9 +1,9 @@ get($_SESSION['domain']['language']['code'], 'app/system'); -//system cpu status + //system cpu status echo "
\n"; -//set the row style class names + //set the row style class names $c = 0; $row_style["0"] = "row_style0"; $row_style["1"] = "row_style1"; -//get the CPU details + //get the CPU details if (stristr(PHP_OS, 'BSD') || stristr(PHP_OS, 'Linux')) { $result = shell_exec('ps -A -o pcpu'); @@ -50,17 +50,88 @@ } -//show the content + //show the content echo "
\n"; echo " ".$text['label-cpu_usage']."\n"; -//add half doughnut chart - if (!isset($dashboard_chart_type) || $dashboard_chart_type == "doughnut") { - ?> + $token = (new token())->create($_SERVER['PHP_SELF']); + + echo " \n"; + + subscriber::save_token($token, [system_dashboard_service::get_service_name()]); + + //break the caching with version + $version = md5(file_get_contents(__DIR__, '/resources/javascript/websocket_client.js')); + + //set script source + echo "\n"; + + //add half doughnut chart + if (!isset($dashboard_chart_type) || $dashboard_chart_type == "doughnut"): ?>
- + ".round($percent_cpu)."%"; } diff --git a/app/system/resources/dashboard/system_cpu_status_1.php b/app/system/resources/dashboard/system_cpu_status_1.php new file mode 100644 index 0000000000..b4fdb7eba4 --- /dev/null +++ b/app/system/resources/dashboard/system_cpu_status_1.php @@ -0,0 +1,173 @@ +get($_SESSION['domain']['language']['code'], 'app/system'); + +//system cpu status + echo "
\n"; + +//set the row style class names + $c = 0; + $row_style["0"] = "row_style0"; + $row_style["1"] = "row_style1"; + +//get the CPU details + if (stristr(PHP_OS, 'BSD') || stristr(PHP_OS, 'Linux')) { + + $result = shell_exec('ps -A -o pcpu'); + $percent_cpu = 0; + foreach (explode("\n", $result) as $value) { + if (is_numeric($value)) { $percent_cpu = $percent_cpu + $value; } + } + if (stristr(PHP_OS, 'BSD')) { + $result = shell_exec("dmesg | grep -i --max-count 1 CPUs | sed 's/[^0-9]*//g'"); + $cpu_cores = trim($result); + } + if (stristr(PHP_OS, 'Linux')) { + $result = @trim(shell_exec("grep -P '^processor' /proc/cpuinfo")); + $cpu_cores = count(explode("\n", $result)); + } + if ($cpu_cores > 1) { $percent_cpu = $percent_cpu / $cpu_cores; } + $percent_cpu = round($percent_cpu, 2); + + //uptime + $result = shell_exec('uptime'); + $load_average = sys_getloadavg(); + + } + +//show the content + echo "
\n"; + echo " ".$text['label-cpu_usage']."\n"; + +//add half doughnut chart + if (!isset($dashboard_chart_type) || $dashboard_chart_type == "doughnut") { + ?> +
+ + + ".round($percent_cpu)."%"; + } + echo "
\n"; + + if ($dashboard_details_state != 'disabled') { + echo "
"; + echo "\n"; + echo "\n"; + echo "\n"; + echo "\n"; + echo "\n"; + + if (PHP_OS == 'FreeBSD' || PHP_OS == 'Linux') { + if (!empty($percent_cpu)) { + echo "\n"; + echo "\n"; + echo "\n"; + echo "\n"; + $c = ($c) ? 0 : 1; + } + + if (!empty($cpu_cores)) { + echo "\n"; + echo "\n"; + echo "\n"; + echo "\n"; + $c = ($c) ? 0 : 1; + } + + echo "\n"; + echo "\n"; + echo "\n"; + echo "\n"; + $c = ($c) ? 0 : 1; + + echo "\n"; + echo "\n"; + echo "\n"; + echo "\n"; + $c = ($c) ? 0 : 1; + + echo "\n"; + echo "\n"; + echo "\n"; + echo "\n"; + $c = ($c) ? 0 : 1; + } + + echo "
".$text['label-name']."".$text['label-value']."
\n"; + echo "
"; + //$n++; + + echo ""; + } + echo "
\n"; + +?> diff --git a/app/system/resources/javascript/websocket_client.js b/app/system/resources/javascript/websocket_client.js new file mode 100644 index 0000000000..16a5e4c74c --- /dev/null +++ b/app/system/resources/javascript/websocket_client.js @@ -0,0 +1,116 @@ +class ws_client { + constructor(url, token) { + this.ws = new WebSocket(url); + this.ws.addEventListener('message', this._onMessage.bind(this)); + this._nextId = 1; + this._pending = new Map(); + this._eventHandlers = new Map(); + // The token is submitted on every request + this.token = token; + } + + // internal message handler called when event occurs on the socket + _onMessage(ev) { + let message; + let switch_event; + try { + console.log(ev.data); + message = JSON.parse(ev.data); + // check for authentication request + if (message.status_code === 407) { + console.log('Authentication Required'); + return; + } + switch_event = message.payload; + //console.log('envelope received: ',env); + } catch (err) { + console.error('Error parsing JSON data:', err); + //console.error('Invalid JSON:', ev.data); + return; + } + + // Pull out the request_id first + const rid = message.request_id ?? null; + + // If this is the response to a pending request + if (rid && this._pending.has(rid)) { + // Destructure with defaults in case they're missing + const { + service, + topic = '', + status = 'ok', + code = 200, + payload = {} + } = message; + + const {resolve, reject} = this._pending.get(rid); + this._pending.delete(rid); + + if (status === 'ok' && code >= 200 && code < 300) { + resolve({service, topic, payload, code, message}); + } else { + const err = new Error(message || `Error ${code}`); + err.code = code; + reject(err); + } + + return; + } + + // Otherwise it's a server‑pushed event… + // e.g. env.service === 'event' or env.topic is your event name + this._dispatchEvent(message); + } + + // Send a request to the websocket server using JSON string + request(service, topic = null, payload = {}) { + const request_id = String(this._nextId++); + const env = { + request_id: request_id, + service, + ...(topic !== null ? {topic} : {}), + token: this.token, + payload: payload + }; + const raw = JSON.stringify(env); + this.ws.send(raw); + return new Promise((resolve, reject) => { + this._pending.set(request_id, {resolve, reject}); + // TODO: get timeout working to reject if no response in X ms + }); + } + + subscribe(topic) { + return this.request('active.calls', topic); + } + + unsubscribe(topic) { + return this.request('active.calls', topic); + } + + // register a callback for server-pushes + onEvent(topic, handler) { + console.log('registering event listener for ' + topic); + if (!this._eventHandlers.has(topic)) { + this._eventHandlers.set(topic, []); + } + this._eventHandlers.get(topic).push(handler); + } + /** + * Dispatch a server‑push event envelope to all registered handlers. + * @param {object} env + */ + _dispatchEvent(message) { + const service = message.service_name; + const topic = message.topic; + const handlers = this._eventHandlers.get(topic) || []; + for (const fn of handlers) { + try { + fn(message.payload); + } catch (err) { + console.error(`Error in handler for "${topic}":`, err); + } + } + } + +} \ No newline at end of file diff --git a/app/system/resources/services/system.php b/app/system/resources/services/system.php new file mode 100644 index 0000000000..7036b6b899 --- /dev/null +++ b/app/system/resources/services/system.php @@ -0,0 +1,47 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +if (version_compare(PHP_VERSION, '7.1.0', '<')) { + die("This script requires PHP 7.1.0 or higher. You are running " . PHP_VERSION . "\n"); +} + +require_once dirname(__DIR__, 4) . '/resources/require.php'; + +try { + + // Create the service + $system_dashboard_service = system_dashboard_service::create(); + + // Exit using whatever status run returns + exit($system_dashboard_service->run()); + +} catch (Throwable $ex) { + echo "Error occurred in " . $ex->getFile() . ' (' . $ex->getLine() . '):' . $ex->getMessage(); + // Exit with error code + exit($ex->getCode()); +} diff --git a/core/websockets/resources/classes/base_message.php b/core/websockets/resources/classes/base_message.php new file mode 100644 index 0000000000..25a9a36e13 --- /dev/null +++ b/core/websockets/resources/classes/base_message.php @@ -0,0 +1,218 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * A base message for communication + * + * @author Tim Fry + * @param string $payload; + */ +class base_message { + + /** + * The id is contained to the base_message class. Subclasses or child classes should not adjust this value + * @var int + */ + private $id; + + /** + * Payload can be any value + * @var mixed + */ + protected $payload; + + /** + * Constructs a base_message object. + * When the array is provided as an associative array, the object properties + * are filled using the array key as the property name and the value of the array + * for the value of the property in the object. + * @param array $associative_properties_array + */ + public function __construct($associative_properties_array = []) { + + // Assign the unique object id given by PHP to identify the object + $this->id = spl_object_id($this); + + // Assign the object properties using the associative array provided in constructor + foreach ($associative_properties_array as $property_or_method => $value) { + $this->__set($property_or_method, $value); + } + } + + /** + * Returns the property from the object. + * If the method exists then the method will be called to get the value in the object property. + * If the method is not in the object then the property name is checked to see if it is valid. If the + * name is not available then an exception is thrown. + * @param string $name Name of the property + * @return mixed + * @throws InvalidProperty + */ + public function __get(string $name) { + if ($name === 'class') { + return static::class; + } elseif (method_exists($this, "get_$name")) { + // call function with 'get_' prefixed + return $this->{"get_$name"}(); + } elseif (method_exists($this, $name)) { + // call function with name only + return $this->{$name}(); + } elseif (property_exists($this, $name)) { + // return the property from the object + return $this->{$name}; + } + } + + /** + * Sets the object property in the given name to be the given value + * @param string $name Name of the object property + * @param mixed $value Value of the object property + * @return void + * @throws \InvalidArgumentException + */ + public function __set(string $name, $value): void { + if (method_exists($this, "set_$name")){ + // + // By calling the method with the setter name of the property first, we give + // the child object the opportunity to modify the value before it is + // stored in the object. In the case of the key names for an event this + // allows that child class to adjust the event name from a key value of + // 'Unique-Id' to be standardized to 'unique_id'. + // + $this->{"set_$name"}($value); + } elseif (method_exists($this, $name)) { + // + // We next check for a function with the same name as the property. If the + // method exists then we call the method with the same name instead of + // setting the property directly. This allows the value to be adjusted + // before it is set in the object. Similar to the previous check. + // + $this->{$name}($value); + } elseif (property_exists($this, $name)) { + // + // Lastly, we check for the property to exist and set it directly. This + // is so the property of the child message or base message can be set. + // + $this->{$name} = $value; + } + } + + /** + * Provides a method that PHP will call if the object is echoed or printed. + * @return string JSON string representing the object + * @depends to_json + */ + public function __toString(): string { + return $this->to_json(); + } + + /** + * Returns this object ID given by PHP + * @return int + */ + public function get_id(): int { + return $this->id; + } + + /** + * Sets the message payload to be delivered + * @param mixed $payload Payload for the message to carry + * @return $this Returns this object for chaining + */ + public function set_payload($payload) { + $this->payload = $payload; + return $this; + } + + /** + * Returns the payload contained in this message + * @return mixed Payload in the message + */ + public function get_payload() { + return $this->payload; + } + + /** + * Alias of get_payload and set_payload. When the parameter + * is used to call the method, the payload property of the object + * is set to the payload provided and this object is returned. When + * the method is called with no parameters given, the payload is + * returned to the caller. + * Payload the message object is delivering + * @param mixed $payload If set, payload is set to the value. Otherwise, the payload is returned. + * @return mixed If payload was given to call the method, this object is returned. If no value was provided the payload is returned. + * @see set_payload + * @see get_payload + */ + public function payload($payload = null) { + if (func_num_args() > 0) { + return $this->set_payload($payload); + } + return $this->get_payload(); + } + + /** + * Recursively convert this object or child object to an array. + * @param mixed $iterate Private value to be set while iterating over the object properties + * @return array Array representing the properties of this object + */ + public function to_array($iterate = null): array { + $array = []; + if ($iterate === null) { + $iterate = $this; + } + foreach ($iterate as $property => $value) { + if (is_array($value)) { + $value = $this->to_array($value); + } elseif (is_object($value) && method_exists($value, 'to_array')) { + $value = $value->to_array(); + } elseif (is_object($value) && method_exists($value, '__toArray')) { // PHP array casting + $value = $value->__toArray(); + } + $array[$property] = $value; + } + return $array; + } + + /** + * Returns a json string + * @return string + * @depends to_array + */ + public function to_json(): string { + return json_encode($this->to_array()); + } + + /** + * Returns an array representing this object or child object. + * @return array Array of object properties + */ + public function __toArray(): array { + return $this->to_array(); + } +} diff --git a/core/websockets/resources/classes/base_websocket_system_service.php b/core/websockets/resources/classes/base_websocket_system_service.php new file mode 100644 index 0000000000..8e891bb7ff --- /dev/null +++ b/core/websockets/resources/classes/base_websocket_system_service.php @@ -0,0 +1,271 @@ + + */ +abstract class base_websocket_system_service extends service implements websocket_service_interface { + + private static $websocket_port = null; + private static $websocket_host = null; + + /** + * Sets a time to fire the on_timer function + * @var int|null + */ + protected $timer_expire_time = null; + + /** + * Websocket client + * @var websocket_client $ws_client + */ + protected $ws_client; + + abstract protected function reload_settings(): void; + + protected static function display_version(): void { + echo "System Dashboard Service 1.0\n"; + } + + /** + * Set a timer to trigger the on_timer function every $seconds. To stop the timer, set the value to null + * @param int $seconds + * @return void + * @see on_timer + */ + protected function set_timer(?int $seconds): void { + if ($seconds !== null) $this->timer_expire_time = time() + $seconds; + else $this->timer_expire_time = null; + } + + /** + * When the set_timer is used to set a timer, this function will run. Override + * the function in the child class. + * @return void + * @see set_timer + */ + protected function on_timer(): void { + return; + } + + protected static function set_command_options() { + parent::append_command_option( + command_option::new() + ->description('Set the Port to connect to the websockets service') + ->short_option('w') + ->short_description('-w ') + ->long_option('websockets-port') + ->long_description('--websockets-port ') + ->callback('set_websockets_port') + ); + parent::append_command_option( + command_option::new() + ->description('Set the IP address for the websocket') + ->short_option('k') + ->short_description('-k ') + ->long_option('websockets-address') + ->long_description('--websockets-address ') + ->callback('set_websockets_host_address') + ); + } + + protected static function set_websockets_port($port): void { + self::$websocket_port = $port; + } + + protected static function set_websockets_host_address($host): void { + self::$websocket_host = $host; + } + + public function run(): int { + // re-read the config file to get any possible changes + parent::$config->read(); + + // re-connect to the websocket server + $this->connect_to_ws_server(); + + // Notify connected web server socket when we close + register_shutdown_function(function ($ws_client) { + if ($ws_client !== null) + $ws_client->disconnect(); + }, $this->ws_client); + + $this->register_topics(); + + // Register the authenticate request + $this->on_topic('authenticate', [$this, 'on_authenticate']); + + // Track the WebSocket Server Error Message so it doesn't flood the system logs + $suppress_ws_message = false; + + while ($this->running) { + $read = []; + // reconnect to websocket server + if ($this->ws_client === null || !$this->ws_client->is_connected()) { + // reconnect failed + if (!$this->connect_to_ws_server()) { + if (!$suppress_ws_message) $this->error("Unable to connect to websocket server."); + $suppress_ws_message = true; + } + } + + if ($this->ws_client !== null && $this->ws_client->is_connected()) { + $read[] = $this->ws_client->socket(); + $suppress_ws_message = false; + } + + // Check if we have sockets to read + if (!empty($read)) { + $write = $except = []; + // Wait for an event and timeout at 1/3 of a second so we can re-check all connections + if (false === stream_select($read, $write, $except, 0, 333333)) { + // severe error encountered so exit + $this->running = false; + // Exit with non-zero exit code + return 1; + } + // stream_select will update $read so re-check it + if (!empty($read)) { + $this->debug("Received event"); + // Iterate over each socket event + foreach ($read as $resource) { + // Web socket event + if ($resource === $this->ws_client->socket()) { + $this->handle_websocket_event($this->ws_client); + continue; + } + } + } + } + + // Timers can be set by child classes + if ($this->timer_expire_time !== null && time() >= $this->timer_expire_time) { + $this->on_timer(); + // Set another timer to fire again + $this->set_timer(3); + } + } + return 0; + } + + protected function debug(string $message) { + self::log($message, LOG_DEBUG); + } + + protected function warn(string $message) { + self::log($message, LOG_WARNING); + } + + protected function error(string $message) { + self::log($message, LOG_ERR); + } + + protected function info(string $message) { + self::log($message, LOG_INFO); + } + + /** + * Connects to the web socket server using a websocket_client object + * @return bool + */ + protected function connect_to_ws_server(): bool { + if ($this->ws_client !== null && $this->ws_client->is_connected()) return true; + + $host = self::$websocket_host ?? self::$config->get('websocket.host', '127.0.0.1'); + $port = self::$websocket_port ?? self::$config->get('websocket.port', 8080); + try { + // Create a websocket client + $this->ws_client = new websocket_client("ws://$host:$port"); + + // Block stream for handshake and authentication + $this->ws_client->set_blocking(true); + + // Connect to web socket server + $this->ws_client->connect(); + + // Disable the stream blocking + $this->ws_client->set_blocking(false); + + $this->debug(self::class . " RESOURCE ID: " . $this->ws_client->socket()); + } catch (\RuntimeException $re) { + //unable to connect + return false; + } + return true; + } + + /** + * Handles the message from the web socket client and triggers the appropriate requested topic event + * @param resource $ws_client + * @return void + */ + private function handle_websocket_event() { + // Read the JSON string + $json_string = $this->ws_client->read(); + + // Nothing to do + if ($json_string === null) { + $this->warn('Message received from Websocket is empty'); + return; + } + + $this->debug("Received message on websocket: $json_string (" . strlen($json_string) . " bytes)"); + + // Get the web socket message as an object + $message = websocket_message::create_from_json_message($json_string); + + // Nothing to do + if (empty($message->topic())) { + $this->error("Message received does not have topic"); + return; + } + + // Call the registered topic event + $this->trigger_topic($message->topic, $message, $this->ws_client); + } + + /** + * Call each of the registered events for the websocket topic that has arrived + * @param string $topic + * @param websocket_message $websocket_message + */ + private function trigger_topic(string $topic, websocket_message $websocket_message) { + if (empty($topic) || empty($websocket_message)) { + return; + } + + if (!empty($this->topics[$topic])) { + foreach ($this->topics[$topic] as $callback) { + call_user_func($callback, $websocket_message); + } + } + } + + protected function on_authenticate(websocket_message $websocket_message) { + $this->info("Authenticating with websocket server"); + // Create a service token + [$token_name, $token_hash] = websocket_client::create_service_token(active_calls_service::get_service_name(), static::class); + + // Request authentication as a service + $this->ws_client->authenticate($token_name, $token_hash); + } + + /** + * Allows the service to register a callback so when the topic arrives the callable is called + * @param type $topic + * @param type $callable + */ + protected function on_topic($topic, $callable) { + if (!isset($this->topics[$topic])) { + $this->topics[$topic] = []; + } + $this->topics[$topic][] = $callable; + } + + protected function respond(websocket_message $websocket_message): void { + websocket_client::send($this->ws_client->socket(), $websocket_message); + } + + abstract protected function register_topics(): void; +} diff --git a/core/websockets/resources/classes/file_not_found_exception.php b/core/websockets/resources/classes/file_not_found_exception.php new file mode 100644 index 0000000000..d83c20de17 --- /dev/null +++ b/core/websockets/resources/classes/file_not_found_exception.php @@ -0,0 +1,38 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * A file not found exception + * + * @author Tim Fry + */ +class file_not_found_exception extends \Exception { + public function __construct(string $message = "File not found", int $code = 0, ?\Throwable $previous = null) { + return parent::__construct($message, $code, $previous); + } +} diff --git a/core/websockets/resources/classes/permission_filter.php b/core/websockets/resources/classes/permission_filter.php new file mode 100644 index 0000000000..b1736a38ba --- /dev/null +++ b/core/websockets/resources/classes/permission_filter.php @@ -0,0 +1,79 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * Description of permission_filter + * + * @author Tim Fry + */ +class permission_filter implements filter { + + private $field_map; + private $permissions; + + public function __construct(array $event_field_key_to_permission_map, array $permissions = []) { + $this->field_map = $event_field_key_to_permission_map; + $this->add_permissions($permissions); + } + + public function __invoke(string $key, $value): ?bool { + $permission = $this->field_map[$key] ?? null; + if ($permission === null || $this->has_permission($permission)) { + return true; + } + return false; + } + + /** + * Adds an associative array of permissions where $key is the name of the permission and $value is ignored as it should always be set to true. + * @param array $permissions + */ + public function add_permissions(array $permissions) { + // Add all event key filters passed + foreach (array_keys($permissions) as $key) { + $this->add_permission($key); + } + } + + /** + * Adds a single permission + * @param string $key + */ + public function add_permission(string $key) { + $this->permissions[$key] = $key; + } + + /** + * Checks if the filter has a permission + * @param string $key + * @return bool + */ + public function has_permission(string $key): bool { + return isset($this->permissions[$key]); + } +} diff --git a/core/websockets/resources/classes/socket_disconnected_exception.php b/core/websockets/resources/classes/socket_disconnected_exception.php new file mode 100644 index 0000000000..b10491eb63 --- /dev/null +++ b/core/websockets/resources/classes/socket_disconnected_exception.php @@ -0,0 +1,38 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * A socket disconnects exception + * + * @author Tim Fry + */ +class socket_disconnected_exception extends \socket_exception { + public function __construct($resource_id, string $message = "Socket Disconnected", int $code = 0, ?\Throwable $previous = null) { + return parent::__construct($resource_id, $message, $code, $previous); + } +} diff --git a/core/websockets/resources/classes/socket_exception.php b/core/websockets/resources/classes/socket_exception.php new file mode 100644 index 0000000000..a53657cb55 --- /dev/null +++ b/core/websockets/resources/classes/socket_exception.php @@ -0,0 +1,41 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * General socket exception class + * + * @author Tim Fry + */ +class socket_exception extends \Exception { + public $id; + public function __construct($id = null, string $message = "", int $code = 0, ?\Throwable $previous = null) { + $this->id = $id; + return parent::__construct($message, $code, $previous); + } + public function getResourceId() { return $this->resource_id; } +} diff --git a/core/websockets/resources/classes/subscriber.php b/core/websockets/resources/classes/subscriber.php new file mode 100644 index 0000000000..9f77a11df2 --- /dev/null +++ b/core/websockets/resources/classes/subscriber.php @@ -0,0 +1,650 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * Description of subscriber + * @author Tim Fry + */ +class subscriber { + + public $show_all; + + /** + * The ID of the object given by PHP + * @var spl_object_id + */ + private $id; + private $socket; + + /** + * Stores the original socket ID used when the subscriber object was created. + * The resource is cast to an integer and then saved in order to match the + * a resource to the original socket. This is primarily used in the equals + * method to test for equality. + * @var int + */ + private $socket_id; + + private $remote_ip; + private $remote_port; + private $services; + private $permissions; + private $domain_name; + private $domain_uuid; + private $token_hash; + private $token_name; + private $token_time; + private $token_limit; + private $enable_token_time_limit; + private $service; + private $service_class; + private $service_name; + private $filter; + + /** + * Function or method name to call when sending information through the socket + * @var callable + */ + private $callback; + private $send_all; + private $subscriptions; + private $authenticated; + + /** + * Creates a subscriber object. + * @param resource|stream $socket Connected socket + * @param callable $frame_wrapper The callback used to wrap communication in a web socket frame. Sending NULL to the frame wrapper should send a disconnect. + * @throws \socket_exception Thrown when the passed socket is already closed + * @throws \InvalidArgumentException Thrown when the $callback is not a valid callback + */ + public function __construct($socket, callable $frame_wrapper) { + if (!is_resource($socket)) { + throw new \socket_exception('Socket must be a valid resource'); + } + // check for valid callback so we can send websocket data when required + if (!is_callable($frame_wrapper)) { + throw new \InvalidArgumentException('Websocket callable method must be a valid callable function or method'); + } + + // set object identifiers + $this->id = md5(spl_object_hash($this)); // PHP unique object hash is similar to 000000000000000f0000000000000000 so we use md5 + $this->socket = $socket; + $this->socket_id = (int) $socket; + + $this->domain_name = ''; + $this->domain_uuid = ''; + + // always use the same formula from the static functions + [$this->remote_ip, $this->remote_port] = self::get_remote_information_from_socket($socket); + + // set defaults + $this->authenticated = false; + $this->permissions = []; + $this->services = []; + $this->show_all = false; + $this->enable_token_time_limit = false; + $this->subscriptions = []; + $this->service = false; + $this->service_name = ''; + + // Save the websocket frame wrapper used to communicate to this subscriber + $this->callback = $frame_wrapper; + + // No filter initially + $this->filter = null; + } + + /** + * Gets or sets the subscribed to services + * @param array $services + * @return $this|array + */ + public function subscribed_to($services = []) { + if (func_num_args() > 0) { + $this->services = array_flip($services); + return $this; + } + return array_keys($this->services); + } + + public function service_class($service_class = null) { + if (func_num_args() > 0) { + $this->service_class = $service_class; + return $this; + } + return $this->service_class; + } + + public function set_filter(filter $filter) { + $this->filter = $filter; + return $this; + } + + public function get_filter() { + return $this->filter; + } + + /** + * When there is no more references to the object we ensure that we disconnect from the subscriber + */ + public function __destruct() { + // disconnect the socket + $this->disconnect(); + } + + /** + * Disconnects the socket resource used for this subscriber + * @return bool true on success and false on failure + */ + public function disconnect(): bool { + //return success if close was successful + if (is_resource($this->socket)) { + //self::$logger->info("Subscriber $this->id has been disconnected"); + // Send null to the frame wrapper to send a disconnect frame + call_user_func($this->callback, $this->socket_id, null); + return (@fclose($this->socket) !== false); + } + return false; + } + + /** + * Compares the current object with another object to see if they are exactly the same object + * @param subscriber|resource $object_or_resource_or_id + * @return bool + */ + public function equals($object_or_resource_or_id): bool { + // Compare by resource + if (is_resource($object_or_resource_or_id)) { + return $object_or_resource_or_id === $this->socket; + } + // Compare by spl_object_id or spl_object_hash + if (gettype($object_or_resource_or_id) === 'integer' || gettype($object_or_resource_or_id) === 'string') { + return $object_or_resource_or_id === $this->id; + } + // Ensure it is the same type of object + if (!($object_or_resource_or_id instanceof subscriber)) { + // Not a subscriber object + return false; + } + // Compare by object using the spl_object_id to match + return $object_or_resource_or_id->id() === $this->id; + } + + public function not_equals($object_or_resource): bool { + return !$this->equals($object_or_resource); + } + + /** + * Allow accessing copies of the private values + * @param string $name + * @return mixed + * @throws \InvalidArgumentException + */ + public function __get(string $name) { + switch ($name) { + case 'id': + case 'socket_id': + case 'remote_ip': + case 'remote_port': + case 'token_name': + case 'token_hash': + case 'token_time': + case 'domain_name': + case 'permissions': + case 'services': + return $this->{$name}; + default: + throw new \InvalidArgumentException("Property '$name' does not exist or direct access is prohibited. Try using '$name()' for access."); + } + } + + /** + * Returns the current ID of this subscriber. + * The ID is set in the constructor using the spl_object_id given by PHP + * @return string + */ + public function id(): string { + return "$this->id"; + } + + /** + * Checks if this subscriber has the permission given in $permission + * @param string $permission + * @return bool True when this subscriber has the permission and false otherwise + */ + public function has_permission(string $permission): bool { + // Do not allow empty names + if (empty($this->permissions) || strlen($permission) === 0) { + return false; + } + return isset($this->permissions[$permission]); + } + + public function get_permissions(): array { + return $this->permissions; + } + + public function get_domain_name(): string { + return $this->domain_name; + } + + /** + * Returns the current socket resource used to communicate with this subscriber + * @return resource|stream Resource Id or stream used + */ + public function socket() { + return $this->socket; + } + + /** + * Returns the socket ID that was cast to an integer when the object was + * created + */ + public function socket_id(): int { + return $this->socket_id; + } + + /** + * Validates the given token against the loaded token in the this subscriber + * @param array $token Must be an associative array with name and hash as the keys. + * @return bool + */ + public function is_valid_token(array $token): bool { + if (!is_array($token)) { + throw new \InvalidArgumentException('Token must be an array'); + } + + // get the name and hash from array + $token_name = $token['name'] ?? ''; + $token_hash = $token['hash'] ?? ''; + + // empty values are not allowed + if (empty($token_name) || empty($token_hash)) { + return false; + } + + // validate the name and hash + $valid = ($token_name === $this->token_name && $token_hash === $this->token_hash); + + // Get the current epoch time + $server_time = time(); + + // check time validation required + if ($this->enable_token_time_limit) { + // compare against time limit in minutes + $valid = $valid && ($server_time - $this->token_time < $this->token_limit * 60); + } + //self::$logger->debug("------------------ Token Compare ------------------"); + //self::$logger->debug("Subscriber token time: $this->token_time"); + //self::$logger->debug(" Server time: $server_time"); + //self::$logger->debug("Subscriber token name: $this->token_name"); + //self::$logger->debug(" Server token name: $token_name"); + //self::$logger->debug("Subscriber token hash: $this->token_hash"); + //self::$logger->debug(" Server token hash: $token_hash"); + //self::$logger->debug("Returning: " . ($valid ? 'true' : 'false')); + //self::$logger->debug("---------------------------------------------------"); + return $valid; + } + + /** + * Validates the given token array against the token previously saved in the file system. When the token is valid + * the token will be saved in this object and the file removed. This method should not be called a second time + * once a token has be authenticated. + * @param array $request_token + * @return bool + */ + public function authenticate_token(array $request_token): bool { + // Check connection + if (!$this->is_connected()) { + throw new \socket_disconnected_exception($this->id); + } + + // Check for required fields + if (empty($request_token)) { + $date = date('Y/m/d H:i:s', time()); + //self::$logger->warn("Empty token given for $this->id"); + return false; + } + + // Set local storage + $token_file = self::get_token_file($request_token['name'] ?? ''); + + // Set default return value of false + $valid = false; + + //self::$logger->debug("Using file: $token_file"); + // Ensure the file is there + if (file_exists($token_file)) { + //self::$logger->debug("Using $token_file for token"); + // Get the token using PHP engine parsing (fastest method) + $array = include($token_file); + + // Assign to local variables to reflect local storage + $token_name = $array['token']['name'] ?? ''; + $token_hash = $array['token']['hash'] ?? ''; + $token_time = intval($array['token']['time'] ?? 0); + $token_limit = intval($array['token']['limit'] ?? 0); + + // Compare the token given in the request with the one that was in local storage + $valid = $token_name === $request_token['name'] && $token_hash === $request_token['hash']; + + // If the token is supposed to have a time limit then check the token time + if ($token_limit > 0) { + // check time has expired or not and put it in valid + $valid = $valid && (time() - $token_time < $token_limit * 60); // token_time_limit * 60 seconds = 15 minutes + } + + // Debug information + if (true) { + //self::$logger->debug("------------------ Authenticate Token Compare ------------------"); + //self::$logger->debug(" Subscriber token name: ".$request_token['name']); + //self::$logger->debug(" Subscriber token hash: ".$request_token['hash']); + //self::$logger->debug(" Server token name: $token_name"); + //self::$logger->debug(" Server token hash: $token_hash"); + //self::$logger->debug(" Server token time: $token_time"); + //self::$logger->debug(" Server token limit: $token_limit"); + //self::$logger->debug("Valid: " . ($valid ? 'yes' : 'no')); + //self::$logger->debug("----------------------------------------------------------------"); + } + + // When token is valid + if ($valid) { + + // Store the valid token information in this object + $this->token_name = $token_name; + $this->token_hash = $token_hash; + $this->token_time = $token_time; + $this->enable_token_time_limit = $token_limit > 0; + $this->token_limit = $token_limit * 60; // convert to seconds for time() comparison + // Add the domain + $this->domain_name = $array['domain']['name'] ?? ''; + $this->domain_uuid = $array['domain']['uuid'] ?? ''; + + // Add subscriptions for services + $services = $array['services'] ?? []; + foreach ($services as $service) { + $this->subscribe($service); + } + + // Store the permissions + $this->permissions = $array['permissions'] ?? []; + + // Check for service + if (isset($array['service'])) { + // + // Set the service information in the object + // + $this->service_name = "" . ($array['service_name'] ?? ''); + $this->service_class = "" . ($array['service_class'] ?? ''); + + // + // Ensure we can call the method we need by checking for the interface. + // Using the interface instead of calling method_exists means we only have to check once + // for the interface instead of checking for each individual method required for it to be + // considered a service. We can also adjust the interface with new methods and this code + // remains the same. It is also possbile for us to use the 'instanceof' operator to check + // that the object is what we require. However, using the instanceof operator requires anc + // object first. Here we only check that the class has implemented the interface allowing + // us to call static methods without first creating an object. + // + $this->service = is_a($this->service_class, 'websocket_service_interface', true); + } + + //self::$logger->debug("Permission count(".count($this->permissions) . ")"); + } + + // Remove the token from local storage + @unlink($token_file); + } + // store the result + $this->authenticated = $valid; + + // return success or failed + return $valid; + } + + public function is_authenticated(): bool { + return $this->authenticated; + } + + public function set_authenticated(bool $authenticated): self { + return $this; + } + + public function set_domain(string $uuid, string $name): self { + if (is_uuid($uuid)) { + $this->uuid = $uuid; + } else { + throw new invalid_uuid_exception("UUID is not valid"); + } + $this->domain_name = $name; + return $this; + } + + public function is_service(): bool { + return $this->service; + } + + /** + * Get or set the service_name + * @param string|null $service_name + * @return string|$this + */ + public function service_name($service_name = null) { /* : string|self */ + if (func_num_args() > 0) { + $this->service_name = $service_name; + return $this; + } + return $this->service_name; + } + + public function service_equals(string $service_name): bool { + return ($this->service && $this->service_name === $service_name); + } + + /** + * Returns true if the socket/stream is still open (not at EOF). + * @return bool Returns true if connected and false if the connection has closed + */ + public function is_connected(): bool { + return is_resource($this->socket) && !feof($this->socket); + } + + /** + * Returns true if the subscriber is no longer connected + * @return bool Returns true if the subscriber is no longer connected + */ + public function is_not_connected(): bool { + return !$this->is_connected(); + } + + /** + * Checks if this subscriber is subscribed to the given service name + * @param string $service_name The service name ie. active.calls + * @return bool + * @see subscriber::subscribe + */ + public function has_subscribed_to(string $service_name): bool { + return isset($this->services[$service_name]); + } + + public function subscribe(string $service_name): self { + $this->services[$service_name] = true; + return $this; + } + + /** + * Sends a response to the subscriber using the provided callback web socket wrapper in the constructor + * @param string $json Valid JSON response to send to the connected client + * @throws subscriber_token_expired_exception Thrown when the time limit set in the token has expired + */ + public function send(string $json) { + //ensure the token is still valid + if (!$this->token_time_exceeded()) { + call_user_func($this->callback, $this->socket, $json); + } else { + throw new subscriber_token_expired_exception($this->id); + } + } + + /** + * Sends the given message through the websocket + * @param websocket_message $message + * @throws socket_disconnected_exception + */ + public function send_message(websocket_message $message) { + + // Filter the message + if ($this->filter !== null) { + $message->apply_filter($this->filter); + } + + if (empty($message->service_name())) { + return; + } + + // Check that we are subscribed to the event + if (!$this->has_subscribed_to($message->service_name())) { + //self::$logger->warn("Subscriber not subscribed to " . $message->service_name()); + throw new subscriber_not_subscribed_exception($this->id); + } + + // Ensure we are still connected + if (!$this->is_connected()) { + throw new \socket_disconnected_exception($this->id); + } + + $this->send((string) $message); + + return; + } + + public static function get_remote_information_from_socket($socket): array { + return explode(':', stream_socket_get_name($socket, true), 2); + } + + public static function get_remote_ip_from_socket($socket): string { + $array = explode(':', stream_socket_get_name($socket, true), 2); + return $array[0] ?? ''; + } + + public static function get_remote_port_from_socket($socket): string { + $array = explode(':', stream_socket_get_name($socket, true), 2); + return $array[1] ?? ''; + } + + public static function get_token_file($token_name): string { + // Try to store in RAM first + if (is_dir('/dev/shm') && is_writable('/dev/shm')) { + $token_file = '/dev/shm/' . $token_name . '.php'; + } else { + // Use the filesystem + $token_file = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $token_name . '.php'; + } + return $token_file; + } + + /** + * Saves the token array to local file system + * + * The web socket server runs in a separate process so it is unable to use + * sessions. Therefor, the token must be stored in a temp folder to be + * verified by the web socket server. It is possible to use a database + * but the database connection process is very slow compared to the file + * system. If the database resides on a remote system instead of local, + * the web socket service may not yet have access to the token before the + * web socket client requests authorization. + * + * @param array $token Standard token issued from the token object + * @param array $services A simple array list of service names to subscribe to + * @param int $time_limit_in_minutes Set a token time limit. Setting to zero will disable the time limit + * @see token::create() + */ + public static function save_token(array $token, array $services, int $time_limit_in_minutes = 0) { + + // + // Put the domain_name, permissions, and token in local storage so we can use all the information + // to authenticate an incoming connection from the websocket service. + // + $array['permissions'] = $_SESSION['permissions']; + + // + // Store the token service and events + // + $array['services'] = $services; + + // + // Store the name and hash of the token + // + $array['token']['name'] = $token['name']; + $array['token']['hash'] = $token['hash']; + + // + // Store the epoch time and time limit + // + $array['token']['time'] = "" . time(); + $array['token']['limit'] = $time_limit_in_minutes; + + // + // Store the domain name in this session + // + $array['domain']['name'] = $_SESSION['domain_name']; + $array['domain']['uuid'] = $_SESSION['domain_uuid']; + + // + // Get the full path and file name for storing the token + // + $token_file = self::get_token_file($token['name']); + + $file_contents = "enable_token_time_limit) + return false; + + //self::$logger->debug("------------- TOKEN TIME LIMIT -------------"); + //self::$logger->debug(" Token Limit: $this->token_limit"); + //self::$logger->debug(" Token Time: $this->token_time"); + //self::$logger->debug(" Current Time: " . time()); + //self::$logger->debug("time-token_time: " . (time() - $this->token_time)); + //self::$logger->debug(" Time Exceeded: " . ((time() - $this->token_time) > $this->token_limit ? 'Yes' : 'No')); + //self::$logger->debug("--------------------------------------------"); + //test the time on the token to ensure it is valid + return (time() - $this->token_time) > $this->token_limit; + } +} diff --git a/core/websockets/resources/classes/subscriber_exception.php b/core/websockets/resources/classes/subscriber_exception.php new file mode 100644 index 0000000000..7359d0082c --- /dev/null +++ b/core/websockets/resources/classes/subscriber_exception.php @@ -0,0 +1,42 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * Description of SubscriberException + * + * @author Tim Fry + */ +class subscriber_exception extends \Exception { + public $subscriber_id; + public function __construct($subscriber_id, string $message = "", int $code = 0, ?\Throwable $previous = null) { + parent::__construct($message, $code, $previous); + $this->subscriber_id = $subscriber_id; + } + + public function getSubscriberId() { return $this->subscriber_id; } +} diff --git a/core/websockets/resources/classes/subscriber_missing_permission_exception.php b/core/websockets/resources/classes/subscriber_missing_permission_exception.php new file mode 100644 index 0000000000..2a8929f7fa --- /dev/null +++ b/core/websockets/resources/classes/subscriber_missing_permission_exception.php @@ -0,0 +1,38 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * Description of SubscriberMissingPermissionException + * + * @author Tim Fry + */ +class subscriber_missing_permission_exception extends \subscriber_exception { + public function __construct($subscriber_id, string $message = "Subscriber is missing required permission", int $code = 0, ?\Throwable $previous = null): \Exception { + return parent::__construct($subscriber_id, $message, $code, $previous); + } +} diff --git a/core/websockets/resources/classes/subscriber_not_subscribed_exception.php b/core/websockets/resources/classes/subscriber_not_subscribed_exception.php new file mode 100644 index 0000000000..f664995ad7 --- /dev/null +++ b/core/websockets/resources/classes/subscriber_not_subscribed_exception.php @@ -0,0 +1,38 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * Description of subscriber_not_subscribed_exception + * + * @author Tim Fry + */ +class subscriber_not_subscribed_exception extends subscriber_exception { + public function __construct($subscriber_id, string $message = "Subscriber is not subscribed", int $code = 0, ?\Throwable $previous = null) { + parent::__construct($subscriber_id, $message, $code, $previous); + } +} diff --git a/core/websockets/resources/classes/subscriber_token_expired_exception.php b/core/websockets/resources/classes/subscriber_token_expired_exception.php new file mode 100644 index 0000000000..d0273d94dd --- /dev/null +++ b/core/websockets/resources/classes/subscriber_token_expired_exception.php @@ -0,0 +1,38 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * Description of TokenExpired + * + * @author Tim Fry + */ +class subscriber_token_expired_exception extends \subscriber_exception { + public function __construct($subscriber_id = null, string $message = "Subscriber token expired", int $code = 0, ?\Throwable $previous = null) { + return parent::__construct($subscriber_id, $message, $code, $previous); + } +} diff --git a/core/websockets/resources/classes/websocket_client.php b/core/websockets/resources/classes/websocket_client.php new file mode 100644 index 0000000000..3ee28b59d3 --- /dev/null +++ b/core/websockets/resources/classes/websocket_client.php @@ -0,0 +1,424 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * Simple WebSocket client class in pure PHP (PHP 8.1+). + * Provides connect, send_message, and disconnect methods. + */ +class websocket_client { + + protected $url; + protected $resource; + protected $host; + protected $port; + protected $path; + protected $origin; + protected $key; + private $stream_blocking; + + /** + * @param string $url WebSocket URL (e.g. ws://127.0.0.1:8080/) + */ + public function __construct(string $url) { + $this->url = $url; + //blocking should be enabled until we perform a handshake + $this->stream_blocking = true; + } + + public function socket() { + return $this->resource; + } + + /** + * Connects to the WebSocket server and performs handshake. + */ + public function connect(): void { + $parts = parse_url($this->url); + $this->host = $parts['host'] ?? ''; + $this->port = $parts['port'] ?? 80; + $this->path = $parts['path'] ?? '/'; + $this->origin = ($parts['scheme'] ?? 'http') . '://' . $this->host; + + $this->resource = stream_socket_client("tcp://{$this->host}:{$this->port}", $errno, $errstr, 5); + if (!$this->resource) { + throw new \RuntimeException("Unable to connect: ({$errno}) {$errstr}"); + } + + // block the stream + $is_blocking = $this->is_blocking(); + if (!$is_blocking) { + $this->block(); + } + + // generate WebSocket key + $this->key = base64_encode(random_bytes(16)); + + // send handshake request + $header = "GET {$this->path} HTTP/1.1\r\n"; + $header .= "Host: {$this->host}:{$this->port}\r\n"; + $header .= "Upgrade: websocket\r\n"; + $header .= "Connection: Upgrade\r\n"; + $header .= "Sec-WebSocket-Key: {$this->key}\r\n"; + $header .= "Sec-WebSocket-Version: 13\r\n"; + $header .= "Origin: {$this->origin}\r\n\r\n"; + fwrite($this->resource, $header); + + // read response headers + $response = ''; + while (!feof($this->resource)) { + $line = fgets($this->resource); + if ($line === "\r\n") { + break; + } + $response .= $line; + } + if (!preg_match('/Sec-WebSocket-Accept: (.*)\r\n/', $response, $m)) { + throw new \RuntimeException("Handshake failed: no Accept header"); + } + $accept = trim($m[1]); + $expected = base64_encode(sha1($this->key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true)); + if ($accept !== $expected) { + throw new \RuntimeException("Handshake failed: invalid Accept key"); + } + + // Put the blocking back to the previous state + if (!$is_blocking) { + $this->disable_block(); + } + } + + public function set_blocking(bool $block) { + if ($this->is_connected()) + stream_set_blocking($this->resource, $block); + } + + public function block() { + $this->set_blocking(true); + } + + public function unblock() { + $this->set_blocking(false); + } + + public function is_blocking(): bool { + if ($this->is_connected()) { + // + // We allow the socket() function to return the socket as a reference + // so we have to check the actual socket data to see if blocking was + // modified outside of the object. + // $meta_data['blocked'] = 0 // not blocking for event + // $meta_data['blocked'] = 1 // blocking for event + // + $meta_data = stream_get_meta_data($this->resource); + return !empty($meta_data['blocked']); + } + return false; + } + + /** + * Returns true if socket is connected. + */ + public function is_connected(): bool { + return isset($this->resource) && is_resource($this->resource) && !feof($this->resource); + } + + /** + * Sends text to the web socket server. + * The web socket client wraps the payload in a web frame socket before sending on the socket. + * @param string|null $payload + */ + public static function send($resource, ?string $payload): bool { + if (!is_resource($resource)) { + throw new \RuntimeException("Not connected"); + } + + // Check for a null message and send a disconnect frame + if ($payload === null) { + @fwrite($resource, chr(0x88) . chr(0x00)); + return true; + } + + $frame_header = "\x81"; // FIN=1, opcode=1 (text frame) + $length = strlen($payload); + + // Set mask bit and payload length + if ($length <= 125) { + $frame_header .= chr(0x80 | $length); // mask bit set + } elseif ($length <= 65535) { + $frame_header .= chr(0x80 | 126) . pack('n', $length); + } else { + $frame_header .= chr(0x80 | 127) . pack('J', $length); + } + + // must be masked when sending to the server + $mask = random_bytes(4); + $masked_payload = ''; + + for ($i = 0; $i < $length; ++$i) { + $masked_payload .= $payload[$i] ^ $mask[$i % 4]; + } + + $frame = $frame_header . $mask . $masked_payload; + + $written = @fwrite($resource, $frame); + + if ($written === false) { + echo "[ERROR] Failed to write to socket\n"; + return false; + } + + if ($written < strlen($frame)) { + echo "[WARNING] Partial frame sent ({$written}/" . strlen($frame) . " bytes)\n"; + return false; + } + + return true; + } + + /** + * Disconnects from the server. + */ + public function disconnect(): void { + if (isset($this->resource) && is_resource($this->resource)) { + @fwrite($this->resource, "\x88\x00"); // 0x88 = close frame, no payload + @fclose($this->resource); + } + } + + public static function get_token_file($token_name): string { + // Try to store in RAM first + if (is_dir('/dev/shm') && is_writable('/dev/shm')) { + $token_file = '/dev/shm/' . $token_name . '.php'; + } else { + // Use the filesystem + $token_file = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $token_name . '.php'; + } + return $token_file; + } + + private function send_control_frame(int $opcode, string $payload = ''): void { + $header = chr(0x80 | $opcode); // FIN=1, control frame + $payload_len = strlen($payload); + + // Payload length + if ($payload_len <= 125) { + $header .= chr($payload_len); + } elseif ($payload_len <= 65535) { + $header .= chr(126) . pack('n', $payload_len); + } else { + // Control frames should never be this large; truncate to 125 + $payload = substr($payload, 0, 125); + $header .= chr(125); + } + + @fwrite($this->resource, $header . $payload); + } + + /** + * Reads a web socket data frame and converts it to a regular string + * @param resource $this->resource + * @return string + */ + public function read(): ?string { + if (!is_resource($this->resource)) { + throw new \RuntimeException("Not connected"); + } + + $final_frame = false; + $payload_data = ''; + + while (!$final_frame) { + $header = $this->read_bytes(2); + if ($header === null) + return null; + + $byte1 = ord($header[0]); + $byte2 = ord($header[1]); + + $final_frame = ($byte1 >> 7) & 1; + $opcode = $byte1 & 0x0F; + $masked = ($byte2 >> 7) & 1; + $payload_len = $byte2 & 0x7F; + + // Extended payload length + if ($payload_len === 126) { + $extended = $this->read_bytes(2); + if ($extended === null) + return null; + $payload_len = unpack('n', $extended)[1]; + } elseif ($payload_len === 127) { + $extended = $this->read_bytes(8); + if ($extended === null) + return null; + $payload_len = 0; + for ($i = 0; $i < 8; $i++) { + $payload_len = ($payload_len << 8) | ord($extended[$i]); + } + } + + // Read mask + $mask = ''; + if ($masked) { + $mask = $this->read_bytes(4); + if ($mask === null) + return null; + } + + // Read payload + $payload = $this->read_bytes($payload_len); + if ($payload === null) { + echo "[ERROR] Incomplete payload received\n"; + return null; + } + + // Unmask if needed + if ($masked) { + $unmasked = ''; + for ($i = 0; $i < $payload_len; $i++) { + $unmasked .= $payload[$i] ^ $mask[$i % 4]; + } + $payload = $unmasked; + } + + // Handle control frames + switch ($opcode) { + case 0x9: // PING + // Respond with PONG using same payload + $this->send_control_frame(0xA, $payload); + echo "[INFO] Received PING, sent PONG\n"; + continue; // Skip returning PING + case 0xA: // PONG + echo "[INFO] Received PONG\n"; + continue; // Skip returning PONG + case 0x1: // TEXT frame + case 0x0: // Continuation frame + $payload_data .= $payload; + break; + default: + echo "[WARNING] Unsupported opcode: $opcode\n"; + return null; + } + } + + $meta = stream_get_meta_data($this->resource); + if ($meta['unread_bytes'] > 0) { + echo "[WARNING] {$meta['unread_bytes']} bytes left in socket after read\n"; + } + + return $payload_data; + } + + // Helper function to fully read N bytes + private function read_bytes(int $length): ?string { + $data = ''; + while (strlen($data) < $length) { + $chunk = fread($this->resource, $length - strlen($data)); + if ($chunk === false || $chunk === '') { + break; + } + $data .= $chunk; + } + return strlen($data) === $length ? $data : null; + } + + public function authenticate($token_name, $token_hash) { + return self::send($this->resource, json_encode(['service' => 'authentication', 'token' => ['name' => $token_name, 'hash' => $token_hash]])); + } + + /** + * Create a token for a service that can broadcast a message + * @param string $service_name + * @param string $service_class + * @param array $permissions + * @param int $time_limit_in_minutes + * @return array + */ + public static function create_service_token(string $service_name, string $service_class, array $permissions = [], int $time_limit_in_minutes = 0) { + + // + // Create a service token + // + $token = (new token())->create($service_name); + + // + // Put the permissions, and token in local storage so we can use all the information + // to authenticate an incoming connection from the websocket service. + // + $array = $permissions; + + // + // Store the name and hash of the token + // + $array['token']['name'] = $token['name']; + $array['token']['hash'] = $token['hash']; + + // + // Store the epoch time and time limit + // + $array['token']['time'] = "" . time(); + $array['token']['limit'] = $time_limit_in_minutes; + + // + // Store the service name used by web browser to subscribe + // and store the class name of this service + // + $array['service'] = true; + $array['service_name'] = $service_name; + $array['service_class'] = $service_class; + + // + // Get the full path and file name for storing the token + // + $token_file = self::get_token_file($token['name']); + + $file_contents = "connect(); +// $client->send_message('Hello from PHP client!'); +// // ... do more send_message() calls as needed +// $client->disconnect(); +//} catch (\Throwable $e) { +// echo "Error: " . $e->getMessage() . "\n"; +//} diff --git a/core/websockets/resources/classes/websocket_message.php b/core/websockets/resources/classes/websocket_message.php new file mode 100644 index 0000000000..48d0796306 --- /dev/null +++ b/core/websockets/resources/classes/websocket_message.php @@ -0,0 +1,429 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * A structured web socket message easily converted to and from a json string + * + * @author Tim Fry + * @param string $service_name; + * @param string $token_name; + * @param string $token_hash; + * @param string $status_string; + * @param string $status_code; + * @param string $request_id; + * @param string $resource_id; + * @param string $domain_uuid; + * @param string $permissions; + * @param string $topic; + */ +class websocket_message extends base_message { + + // By setting these to protected we ensure the __set and __get methods are used in the parent class + protected $service_name; + protected $token_name; + protected $token_hash; + protected $status_string; + protected $status_code; + protected $request_id; + protected $resource_id; + protected $domain_uuid; + protected $domain_name; + protected $permissions; + protected $topic; + + public function __construct($associative_properties_array = []) { + // Initialize empty default values + $this->service_name = ''; + $this->token_name = ''; + $this->token_hash = ''; + $this->status_string = ''; + $this->status_code = ''; + $this->request_id = ''; + $this->resource_id = ''; + $this->domain_uuid = ''; + $this->domain_name = ''; + $this->permissions = []; + $this->topic = ''; + // + // Send to parent (base_message) constructor + // + parent::__construct($associative_properties_array); + } + + public function has_permission($permission_name) { + return isset($this->permissions[$permission_name]); + } + + /** + * Alias of service_name. + * @param string $service_name + * @return $this + * @see service_name + */ + public function service($service_name = null) { + if (func_num_args() > 0) { + $this->service_name = $service_name; + return $this; + } + return $this->service_name; + } + + /** + * Gets or Sets the service name + * If no parameters are provided then the service_name is returned. If the service name is provided, then the + * service_name is set to the value provided. + * @param string $service_name + * @return $this + */ + public function service_name($service_name = null) { + if (func_num_args() > 0) { + $this->service_name = $service_name; + return $this; + } + return $this->service_name; + } + + /** + * Gets or sets the permissions array + * @param array $permissions + * @return $this + */ + public function permissions($permissions = []) { + if (func_num_args() > 0) { + $this->permissions = $permissions; + return $this; + } + return $this->permissions; + } + + /** + * Applies a filter to the payload of this message. + * When a filter returns null then the payload is set to null + * @param filter $filter + */ + public function apply_filter(?filter $filter) { + if ($filter !== null && is_array($this->payload)) { + foreach ($this->payload as $key => $value) { + $result = ($filter)($key, $value); + // Check if a filter requires dropping the payload + if ($result === null) { + $this->payload = null; + return; + } + // Remove a key if filter does not pass + elseif(!$result) { + unset($this->payload[$key]); + } + } + } + } + + /** + * Gets or sets the domain UUID + * @param string $domain_uuid + * @return $this or $domain_uuid + */ + public function domain_uuid($domain_uuid = '') { + if (func_num_args() > 0) { + $this->domain_uuid = $domain_uuid; + return $this; + } + return $this->domain_uuid; + } + + /** + * Gets or sets the domain name + * @param string $domain_name + * @return $this or $domain_name + */ + public function domain_name($domain_name = '') { + if (func_num_args() > 0) { + $this->domain_name = $domain_name; + return $this; + } + return $this->domain_name; + } + + /** + * Gets or Sets the service name + * If no parameters are provided then the service_name is returned. If the service name is provided, then the + * topic is set to the value provided. + * @param string $topic + * @return $this + */ + public function topic($topic = null) { + if (func_num_args() > 0) { + $this->topic = $topic; + return $this; + } + return $this->topic; + } + + /** + * Gets or sets the token array using the key values of 'name' and 'hash' + * @param array $token_array + * @return array|$this + * @see token_name + * @see token_hash + */ + public function token($token_array = []) { + if (func_num_args() > 0) { + $this->token_name($token_array['name'] ?? '')->token_hash($token_array['hash'] ?? ''); + return $this; + } + return ['name' => $this->token_name, 'hash' => $this->token_hash]; + } + + /** + * Sets the token name + * @param string $token_name + * @return $this + * @see token_hash + */ + public function token_name($token_name = '') { + if (func_num_args() > 0) { + $this->token_name = $token_name; + return $this; + } + return $this->token_name; + } + + /** + * Gets or sets the status code of this message + * @param int $status_code + * @return $this + */ + public function status_code($status_code = '') { + if (func_num_args() > 0) { + $this->status_code = $status_code; + return $this; + } + return $this->status_code; + } + + /** + * Gets or sets the resource id + * @param type $resource_id + * @return $this + */ + public function resource_id($resource_id = null) { + if (func_num_args() > 0) { + $this->resource_id = $resource_id; + return $this; + } + return $this->resource_id; + } + + /** + * Gets or sets the request ID + * @param type $request_id + * @return $this + */ + public function request_id($request_id = null) { + if (func_num_args() > 0) { + $this->request_id = $request_id; + return $this; + } + return $this->request_id; + } + + /** + * Gets or sets the status string + * @param type $status_string + * @return $this + */ + public function status_string( $status_string = null) { + if (func_num_args() > 0) { + $this->status_string = $status_string; + return $this; + } + return $this->status_string; + } + + /** + * Gets or sets the token hash + * @param type $token_hash + * @return $this + * @see token_name + */ + public function token_hash($token_hash = null) { + if (func_num_args() > 0) { + $this->token_hash = $token_hash; + return $this; + } + return $this->token_hash; + } + + /** + * Convert the 'statusString' key that comes from javascript + * @param type $status_string + * @return type + */ + public function statusString($status_string = '') { + return $this->status_string($status_string); + } + + /** + * Convert the 'statusCode' key that comes from javascript + * @param type $status_code + * @return $this + */ + public function statusCode($status_code = 200) { + return $this->status_code($status_code); + } + + /** + * Unwrap a JSON message to an associative array + * @param string $json_string + * @return array + */ + public static function unwrap($json_string = '') { + return json_decode($json_string, true); + } + + /** + * Helper function to respond with a connected message + * @param type $request_id + * @return type + */ + public static function connected($request_id = '') { + return static::request_authentication($request_id); + } + + /** + * Helper function to respond with a authentication message + * @param type $request_id + * @return type + */ + public static function request_authentication($request_id = '') { + $class = static::class; + return (new $class()) + ->request_id($request_id) + ->service_name('authentication') + ->status_code(407) + ->status_string('Authentication Required') + ->topic('authenticate') + ->__toString() + ; + } + + /** + * Helper function to respond with a bad request message + * @param type $request_id + * @param type $service + * @param type $topic + * @return type + */ + public static function request_is_bad($request_id = '', $service = '', $topic = '') { + $class = static::class; + return (new $class()) + ->request_id($request_id) + ->service_name($service) + ->topic($topic) + ->status_code(400) + ->__toString() + ; + } + + /** + * Helper function to respond with an authenticated message + * @param type $request_id + * @param type $service + * @param type $topic + * @return type + */ + public static function request_authenticated($request_id = '', $service = '', $topic = 'authenticated') { + $class = static::class; + return (new $class()) + ->request_id($request_id) + ->service_name($service) + ->topic($topic) + ->status_code(200) + ->status_string('OK') + ->__toString() + ; + } + + /** + * Helper function to respond with an unauthorized request message + * @param type $request_id + * @param type $service + * @param type $topic + * @return type + */ + public static function request_unauthorized($request_id = '', $service = '', $topic = 'unauthorized') { + $class = static::class; + return (new $class()) + ->request_id($request_id) + ->service_name($service) + ->topic($topic) + ->status_code(401) + ->__toString() + ; + } + + /** + * Helper function to respond with a forbidden message + * @param type $request_id + * @param type $service + * @param type $topic + * @return type + */ + public static function request_forbidden($request_id = '', $service = '', $topic = 'forbidden') { + $class = static::class; + return (new $class()) + ->request_id($request_id) + ->service_name($service) + ->topic($topic) + ->status_code(403) + ->__toString() + ; + } + + /** + * Returns a websocket_message object (or child object) using the provided JSON string or JSON array + * @param string|array $websocket_message_json JSON array or JSON string + * @return static|null Returns a new websocket_message object (or child object) + * @throws \InvalidArgumentException + */ + public static function create_from_json_message($websocket_message_json) { + if (empty($websocket_message_json)) { + // Nothing to do + return null; + } elseif (is_string($websocket_message_json)) { + $json_array = json_decode($websocket_message_json, true); + } elseif (is_array($websocket_message_json)) { + $json_array = $websocket_message_json; + } else { + throw new \InvalidArgumentException("create_from_websocket_message_json expected string or array but got " . gettype($websocket_message_json)); + } + + return new static($json_array); + } + +} diff --git a/core/websockets/resources/classes/websocket_server.php b/core/websockets/resources/classes/websocket_server.php new file mode 100644 index 0000000000..a1fb8b5090 --- /dev/null +++ b/core/websockets/resources/classes/websocket_server.php @@ -0,0 +1,571 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * Simple WebSocket server class. Supporting chunking, PING, PONG. + * + * The on_connect, on_disconnect, on_message events require a function to be passed + * so the websocket_server can call that function when the specific events occur. Each + * of the functions must accept one parameter for the resource that the event occurred on. + * Supports multiple clients and broadcasts messages from one to all others. + */ +class websocket_server { + + /** + * Address to bind to. (Default 8080) + * @var string + */ + protected $address; + + /** + * Port to bind to. (Default 0.0.0.0 - all PHP detected IP addresses of the system) + * @var int + */ + protected $port; + + /** + * Tracks if the server is running + * @var bool + */ + protected $running; + + /** + * Resource or stream of the server socket binding + * @var resource|stream + */ + protected $server_socket; + + /** + * List of connected client sockets + * @var array + */ + protected $clients; + + /** + * Used to track on_message events + * @var array + */ + private $message_callbacks; + + /** + * Used to track on_connect events + * @var array + */ + private $connect_callbacks; + + /** + * Used to track on_disconnect events + * @var array + */ + private $disconnect_callbacks; + + /** + * Used to track switch listeners or other socket connection types + * @var array + */ + private $listeners; + + /** + * Creates a websocket_server instance + * @param string $address IP to bind (default 0.0.0.0) + * @param int $port TCP port (default 8080) + */ + public function __construct(string $address = '127.0.0.1', int $port = 8080) { + $this->running = false; + $this->address = $address; + $this->port = $port; + + // Initialize arrays + $this->listeners = []; + $this->clients = []; + $this->message_callbacks = []; + $this->connect_callbacks = []; + $this->disconnect_callbacks = []; + } + + private function debug(string $message) { + self::log($message, LOG_DEBUG); + } + + private function warn(string $message) { + self::log($message, LOG_WARNING); + } + + private function error(string $message) { + self::log($message, LOG_ERR); + } + + private function info(string $message) { + self::log($message, LOG_INFO); + } + + /** + * Starts server: accepts new clients, reads frames, and broadcasts messages. + * @returns int A non-zero indicates an abnormal termination + */ + public function run(): int { + + $this->server_socket = stream_socket_server("tcp://{$this->address}:{$this->port}", $errno, $errstr); + if (!$this->server_socket) { + throw new \RuntimeException("Cannot bind socket ({$errno}): {$errstr}"); + } + stream_set_blocking($this->server_socket, false); + + // We are now running + $this->running = true; + + while ($this->running) { + $listeners = array_column($this->listeners, 0); + $read = array_merge([$this->server_socket], $listeners, $this->clients); + $write = $except = []; + // Server connection issue + if (false === stream_select($read, $write, $except, null)) { + $this->running = false; + break; + } + // new connection + if (in_array($this->server_socket, $read, true)) { + $conn = @stream_socket_accept($this->server_socket, 0); + if ($conn) { + // complete handshake on blocking socket + stream_set_blocking($conn, true); + $this->handshake($conn); + // switch to non-blocking for further reads + stream_set_blocking($conn, false); + // add them to the websocket list + $this->clients[] = $conn; + // notify websocket on_connect listeners + $this->trigger_connect($conn); + } + continue; + } + // handle other sockets + foreach ($read as $client_socket) { + + // check switch listeners + if (in_array($client_socket, $listeners, true)) { + // Process external listeners + $index = array_search($client_socket, $listeners, true); + try { + //send the switch event to the registered callback function + call_user_func($this->listeners[$index][1], $client_socket); + } catch (\socket_disconnected_exception $s) { + $this->info("[INFO] Removed client $s->id from list"); + $success = $this->disconnect_client($client_socket); + // By attaching the socket_disconnect error message to \socket_exception we can see where something went wrong + if (!$success) + throw new socket_exception('Socket does not exist in tracking array', 256, $s); + } + continue; + } + + // Process web socket client communication + $message = $this->receive_frame($client_socket); + if ($message === '') { + continue; + } + $this->trigger_message($client_socket, $message); + } + } + } + + /** + * Add a non-blocking socket to listen for traffic on + * @param resource $socket + * @param callable $on_data_ready_callback Callable function to call when data arrives on the socket + * @throws \InvalidArgumentException + */ + public function add_listener($socket, callable $on_data_ready_callback) { + if (!is_callable($on_data_ready_callback)) { + throw new \InvalidArgumentException('The callable on_data_ready_callback must be a valid callable function'); + } + $this->listeners[] = [$socket, $on_data_ready_callback]; + } + + /** + * Returns true if there are connected web socket clients. + * @return bool + */ + public function has_clients(): bool { + return !empty($this->clients); + } + + /** + * When a web socket message is received the $on_message_callback function is called. + * Multiple on_message functions can be specified. + * @param callable $on_message_callback Callable function to call when data arrives on the socket + * @throws InvalidArgumentException + */ + public function on_message(callable $on_message_callback) { + if (!is_callable($on_message_callback)) { + throw new \InvalidArgumentException('The callable on_message_callback must be a valid callable function'); + } + $this->message_callbacks[] = $on_message_callback; + } + + /** + * Calls all the on_message functions + * @param resource $socket + * @param string $message + * @return void + */ + private function trigger_message($socket, string $message) { + foreach ($this->message_callbacks as $callback) { + $response = call_user_func($callback, $socket, $message); + if ($response !== null) { + $this->send($socket, $response); + } + return; + } + } + + /** + * When a web socket handshake has completed, the $on_connect_callback function is called. + * Multiple on_connect functions can be specified. + * @param callable $on_connect_callback Callable function to call when a new connection occurs. + * @throws InvalidArgumentException + */ + public function on_connect(callable $on_connect_callback) { + if (!is_callable($on_connect_callback)) { + throw new \InvalidArgumentException('The callable on_connect_callback must be a valid callable function'); + } + $this->connect_callbacks[] = $on_connect_callback; + } + + /** + * Calls all the on_connect functions + * @param resource $socket + */ + private function trigger_connect($socket) { + foreach ($this->connect_callbacks as $callback) { + $response = call_user_func($callback, $socket); + if ($response !== null) { + self::send($socket, $response); + } + } + } + + /** + * When a web socket has disconnected, the $on_disconnect_callback function is called. + * Multiple functions can be specified with subsequent calls + * @param string|callable $on_disconnect_callback Callable function to call when a socket disconnects. The function must accept a single parameter for the socket that was disconnected. + * @throws InvalidArgumentException + */ + public function on_disconnect($on_disconnect_callback) { + if (!is_callable($on_disconnect_callback)) { + throw new \InvalidArgumentException('The callable on_disconnect_callback must be a valid callable function'); + } + $this->disconnect_callbacks[] = $on_disconnect_callback; + } + + /** + * Calls all the on_disconnect_callback functions + * @param type $socket + */ + private function trigger_disconnect($socket) { + foreach ($this->disconnect_callbacks as $callback) { + call_user_func($callback, $socket); + } + } + + /** + * Returns the socket used in the server connection + * @return resource + */ + public function get_socket() { + return $this->server_socket; + } + + /** + * Remove a client socket on disconnect. + * @return bool Returns true on client disconnect and false when the client is not found in the tracking array + */ + protected function disconnect_client($socket, $error = null): bool { + $index = array_search($resource, $this->clients, true); + if ($index !== false) { + self::disconnect($resource); + unset($this->clients[$index]); + $this->trigger_disconnect($socket); + return true; + } + return false; + } + + /** + * Sends a disconnect frame with no payload + * @param type $resource + */ + public static function disconnect($resource) { + if (is_resource($resource)) { + //send OPCODE + @fwrite($resource, "\x88\x00"); // 0x88 = close frame, no payload + @fclose($resource); + } + } + + /** + * Performs web socket handshake on new connection. + * @param type $socket Socket to perform the handshake on. + */ + protected function handshake($socket) { + // ensure blocking to read full header + stream_set_blocking($socket, true); + $request_header = ''; + while (($line = fgets($socket)) !== false) { + $request_header .= $line; + if (rtrim($line) === '') { + break; + } + } + if (!preg_match("/Sec-WebSocket-Key: (.*)\r\n/", $request_header, $matches)) { + throw new \RuntimeException("Invalid WebSocket handshake"); + } + $key = trim($matches[1]); + $accept_key = base64_encode( + sha1($key . "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", true) + ); + $response_header = "HTTP/1.1 101 Switching Protocols\r\n" + . "Upgrade: websocket\r\n" + . "Connection: Upgrade\r\n" + . "Sec-WebSocket-Accept: {$accept_key}\r\n\r\n"; + fwrite($socket, $response_header); + } + + /** + * Read specific number of bytes from a web socket + * @param resource $socket + * @param int $length + * @return string + */ + private function read_bytes($socket, int $length): string { + $data = ''; + while (strlen($data) < $length && is_resource($socket)) { + $chunk = fread($socket, $length - strlen($data)); + if ($chunk === false || $chunk === '' || !is_resource($socket)) { + $this->disconnect_client($socket); + return ''; + } + $data .= $chunk; + } + return $data; + } + + /** + * Reads a web socket data frame and converts it to a regular string + * @param resource $socket + * @return string + */ + private function receive_frame($socket): string { + if (!is_resource($socket)) { + throw new \RuntimeException("Not connected"); + } + + $final_frame = false; + $payload_data = ''; + + while (!$final_frame) { + $header = $this->read_bytes($socket, 2); + if ($header === null) + return null; + + $byte1 = ord($header[0]); + $byte2 = ord($header[1]); + + $final_frame = ($byte1 >> 7) & 1; + $opcode = $byte1 & 0x0F; + $masked = ($byte2 >> 7) & 1; + $payload_len = $byte2 & 0x7F; + + // Extended payload length + if ($payload_len === 126) { + $extended = $this->read_bytes($socket, 2); + if ($extended === null) + return null; + $payload_len = unpack('n', $extended)[1]; + } elseif ($payload_len === 127) { + $extended = $this->read_bytes($socket, 8); + if ($extended === null) + return null; + $payload_len = 0; + for ($i = 0; $i < 8; $i++) { + $payload_len = ($payload_len << 8) | ord($extended[$i]); + } + } + + // Read mask + $mask = ''; + if ($masked) { + $mask = $this->read_bytes($socket, 4); + if ($mask === null) + return null; + } + + // Read payload + $payload = $this->read_bytes($socket, $payload_len); + if ($payload === null) { + $this->error("[ERROR] Incomplete payload received"); + return null; + } + + // Unmask if needed + if ($masked) { + $unmasked = ''; + for ($i = 0; $i < $payload_len; $i++) { + $unmasked .= $payload[$i] ^ $mask[$i % 4]; + } + $payload = $unmasked; + } + + // Handle control frames + switch ($opcode) { + case 0x9: // PING + // Respond with PONG using same payload + $this->send_control_frame(0xA, $payload); + $this->info("Received PING, sent PONG"); + continue; // Skip returning PING + case 0x8: // CLOSE frame + $this->info("Received CLOSE frame, connection will be closed."); + $this->disconnect_client($socket); + return null; + case 0xA: // PONG + $this->info("Received PONG"); + $reason = $this->read_bytes($socket, 2); + $this->info("Reason: $reason"); + continue; // Skip returning PONG + case 0x1: // TEXT frame + case 0x0: // Continuation frame + $payload_data .= $payload; + break; + default: + $this->warn("Unsupported opcode: $opcode"); + return null; + } + } + + $meta = stream_get_meta_data($socket); + if ($meta['unread_bytes'] > 0) { + $this->warn("{$meta['unread_bytes']} bytes left in socket after read"); + } + + return $payload_data; + } + + /** + * Send text frame to client. If the socket connection is not a valid resource, the send + * method will fail silently and return false. + * @param resource $resource The socket or resource id to communicate on. + * @param string|null $payload The message to send to the clients. Sending null as the message sends a close frame packet. + * @return bool True if message was sent on the provided resource or false if there was an error. + */ + public static function send($resource, ?string $payload): bool { + if (!is_resource($resource)) { + throw new \socket_disconnected_exception($resource); + } + + // Check for a null message and send a disconnect frame + if ($payload === null) { + // 88 = CLOSE, 00 = NO REASON + @fwrite($resource, chr(0x88) . chr(0x00)); + return true; + } + + $chunk_size = 4096; // 4 KB + $payload_len = strlen($payload); + $offset = 0; + $first = true; + + while ($offset < $payload_len) { + $remaining = $payload_len - $offset; + $chunk = substr($payload, $offset, min($chunk_size, $remaining)); + $chunk_len = strlen($chunk); + + // Determine FIN bit and opcode + $fin = ($offset + $chunk_size >= $payload_len) ? 0x80 : 0x00; // 0x80 if final + $opcode = $first ? 0x1 : 0x0; // text for first frame, continuation for rest + $first = false; + + // Build header + $header = chr($fin | $opcode); + + // Payload length + if ($chunk_len <= 125) { + $header .= chr($chunk_len); + } elseif ($chunk_len <= 65535) { + $header .= chr(126) . pack('n', $chunk_len); + } else { + // 64-bit big-endian + $length_bytes = ''; + for ($i = 7; $i >= 0; $i--) { + $length_bytes .= chr(($chunk_len >> ($i * 8)) & 0xFF); + } + $header .= chr(127) . $length_bytes; + } + + // Send frame (header + chunk) + $bytes_written = @fwrite($resource, $header . $chunk); + if ($bytes_written === false) { + return false; + } + + $offset += $chunk_len; + } + + return true; + } + + /** + * Get the IP and port of the connected remote system. + * @param socket $socket The socket stream of the connection + * @return array An associative array of remote_ip and remote_port + */ + public static function get_remote_info($socket): array { + [$remote_ip, $remote_port] = explode(':', stream_socket_get_name($socket, true), 2); + return ['remote_ip' => $remote_ip, 'remote_port' => $remote_port]; + } + + /** + * Print socket information + * @param resource $resource + * @param bool $return If you would like to capture the output of print_r(), use the return parameter. When this + * parameter is set to true, print_r() will return the information rather than print it. + */ + public static function print_stream_info($resource, $return = false) { + if (is_resource($resource)) { + $meta_data = stream_get_meta_data($resource); + [$remote_ip, $remote_port] = explode(':', stream_socket_get_name($resource, true), 2); + $meta_data['remote_addr'] = $remote_ip; + $meta_data['remote_port'] = $remote_port; + + if ($return) + return $meta_data; + print_r($meta_data); + } + } +} diff --git a/core/websockets/resources/classes/websocket_service.php b/core/websockets/resources/classes/websocket_service.php new file mode 100644 index 0000000000..48fe75aaf2 --- /dev/null +++ b/core/websockets/resources/classes/websocket_service.php @@ -0,0 +1,917 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * Description of websocket_service + * + * @author Tim Fry + */ +class websocket_service extends service { + + /** + * Address to bind to. (Default 8080) + * @var string + */ + protected $ip; + + /** + * Port to bind to. (Default 0.0.0.0 - all PHP detected IP addresses of the system) + * @var int + */ + protected $port; + + /** + * Resource or stream of the server socket binding + * @var resource|stream + */ + protected $server_socket; + + /** + * List of connected client sockets + * @var array + */ + protected $clients; + + /** + * Used to track on_message events + * @var array + */ + protected $message_callbacks; + + /** + * Used to track on_connect events + * @var array + */ + protected $connect_callbacks; + + /** + * Used to track on_disconnect events + * @var array + */ + protected $disconnect_callbacks; + + /** + * Used to track switch listeners or other socket connection types + * @var array + */ + protected $listeners; + public static $logger; + + /** + * Subscriber Objects + * @var subscriber + */ + protected $subscribers; + + /** + * Array of registered services + * @var array + */ + private $services; + + public function is_debug_enabled(): bool { + return parent::$log_level === LOG_DEBUG; + } + + /** + * Reload settings + * @return void + * @throws \RuntimeException + * @access protected + */ + protected function reload_settings(): void { + // Initialize tracking arrays + $this->listeners = []; + $this->clients = []; + $this->message_callbacks = []; + $this->connect_callbacks = []; + $this->disconnect_callbacks = []; + $this->subscribers = []; + + $settings = new settings(['database' => database::new(['config' => config::load()])]); + + $ip = $settings->get('websocket_server', 'bind_ip_address', '127.0.0.1'); + if ($ip === null) { + throw new \RuntimeException("ERROR: Bind IP address not specified"); + } + + // Save the setting + $this->ip = $ip; + + $port = intval($settings->get('websocket_server', 'bind_port', 8080)); + if (empty($port)) { + throw new \RuntimeException("ERROR: Port address not specified"); + } + + // Save the setting + $this->port = $port; + } + + /** + * Display the version on the console + * @return void + * @access protected + */ + protected static function display_version(): void { + echo "Web Socket Service Version 1.00\n"; + } + + /** + * Set extra command options from the command line + * @access protected + */ + protected static function set_command_options() { + //TODO: ip address + //TODO: port + } + + /** + * Trigger disconnect callbacks + */ + protected function update_connected_clients() { + $disconnected_clients = []; + foreach ($this->clients as $index => $resource) { + if (!is_resource($resource) || feof($resource)) { + // Ensure resource is free + unset($this->clients[$index]); + $disconnected_clients[] = $resource; + } + } + + if (!empty($disconnected_clients)) { + foreach ($disconnected_clients as $dis_con) { + $this->trigger_disconnect($dis_con); + } + } + } + + private function get_subscriber_from_socket_id($socket): ?subscriber { + $subscriber = null; + // Get the subscriber based on their socket ID + foreach ($this->subscribers as $s) { + if ($s->equals($socket)) { + $subscriber = $s; + break; + } + } + return $subscriber; + } + + private function authenticate_subscriber(subscriber $subscriber, websocket_message $message) { + $this->info("Authenticating client: $subscriber->id"); + + // Already authenticated + if ($subscriber->is_authenticated()) { + return true; + } + + // Authenticate their token + if ($subscriber->authenticate_token($message->token)) { + $subscriber->send(websocket_message::request_authenticated($message->request_id, $message->service)); + if ($subscriber->is_service()) { + $this->info("Service $subscriber->id authenticated"); + $this->services[$subscriber->service_name()] = $subscriber; + } else { + $this->info("Client $subscriber->id authenticated"); + $this->info("Setting permissions on $subscriber->id"); + $subscriptions = $subscriber->subscribed_to(); + foreach ($subscriber->subscribed_to() as $subscribed_to) { + if (isset($this->services[$subscribed_to])) { + $service = $this->services[$subscribed_to]; + if (is_a($service, 'websocket_service_interface', true)) { + $class = $service->get_service_name(); + $filter = $class::create_filter_chain_for($subscriber); + if ($filter !== null) { + $subscriber->set_filter($filter); + } + } + $this->info("Set permissions for $subscriber->id for service " . $service->service_name()); + } + } + } + } else { + $subscriber->send(websocket_message::request_unauthorized($message->request_id, $message->service)); + // Disconnect them + $this->handle_disconnect($subscriber->socket_id()); + } + return; + } + + private function broadcast_service_message(subscriber $broadcaster, ?websocket_message $message = null) { + + $this->debug("Processing Broadcast"); + + // Ensure we have something to do + if ($message === null) { + $this->warn("Unable to broadcast empty message"); + return; + } + + $subscribers = array_filter($this->subscribers, function ($subscriber) use ($broadcaster) { + return $subscriber->not_equals($broadcaster); + }); + + if (empty($subscribers)) { + $this->debug("No subscribers to broadcast message to"); + return; + } + + // Ensure the service is not responding to a specific request + $request_id = $message->request_id; + if (empty($request_id)) { + + // Get the service name from the message + $service_name = $message->service_name; + + // Filter subscribers to only the ones subscribed to the service name + $send_to = $this->filter_subscribers($subscribers, $message, $service_name); + + // Send the message to the filtered subscribers + foreach ($send_to as $subscriber) { + try { + // Notify of the message we are broadcasting + $this->debug("Broadcasting message '" . $message->payload['event_name'] . "' for service '" . $message->service_name . "' to subscriber $subscriber->id"); + $message->apply_filter($subscriber->get_filter()); + $subscriber->send_message($message); + } catch (subscriber_token_expired_exception $ste) { + $this->info("Subscriber $ste->id token expired"); + // Subscriber token has expired so disconnect them + $this->handle_disconnect($subscriber->socket_id()); + } + } + } + // Route a specific request from a service back to a subscriber + else { + // Get the subscriber object hash + $object_id = $message->resource_id; + if (isset($this->subscribers[$object_id])) { + $subscriber = $this->subscribers[$object_id]; + // Remove the resource_id from the message + $message->resource_id(''); + // TODO: Fix removal of request_id + $message->request_id(''); + // Return the requested results back to the subscriber + $subscriber->send_message($message); + } + } + return; + } + + /** + * Filters subscribers based on the service name given + * @param array $subscribers + * @param websocket_message $message + * @param string $service_name + * @return array List of subscriber objects or an empty array if there are no subscribers to that service name + */ + private function filter_subscribers(array $subscribers, websocket_message $message, string $service_name): array { + $filtered = []; + + foreach ($subscribers as $subscriber) { + $caller_context = strtolower($message->caller_context ?? ''); + if (!empty($caller_context) && $subscriber->has_subscribed_to($service_name) && ($subscriber->show_all || $caller_context === $subscriber->domain_name || $caller_context === 'public' || $caller_context === 'default' + ) + ) { + $filtered[] = $subscriber; + } else { + if ($subscriber->has_subscribed_to($service_name)) + $filtered[] = $subscriber; + } + } + + return $filtered; + } + + /** + * Create a subscriber for each connection + * @param resource $socket + * @return void + */ + private function handle_connect($socket) { + // We catch only the socket disconnection exception as there is a general try/catch already + try { + $subscriber = new subscriber($socket, [websocket_service::class, 'send']); + $this->subscribers[$subscriber->id] = $subscriber; + $subscriber->send(websocket_message::connected()); + } catch (\socket_disconnected_exception $sde) { + $this->warning("Client $sde->id disconnected during connection"); + // remove the connected client + $this->handle_disconnect($sde->id); + } + return; + } + + /** + * Web socket client disconnected from the server or this service has requested a disconnect from the subscriber + * @param subscriber|resource|int|string $object_or_resource_or_id + */ + private function handle_disconnect($object_or_resource_or_id) { + // + // Notify user + // + $this->info("Disconnecting subscriber: '$object_or_resource_or_id'"); + + // + // Search for the socket using the equals method in subscriber + // + $subscriber = null; + + /* PHP 8 syntax: $subscriber = array_find($this->subscribers, fn ($subscriber) => $subscriber->equals($socket_id)); */ + + // Find the subscriber in our array + foreach ($this->subscribers as $s) { + if ($s->equals($object_or_resource_or_id)) { + $subscriber = $s; + } + } + + // We have found our subscriber to be disconnected + if ($subscriber !== null) { + // If they are still connected then disconnect them with the proper disconnect + if ($subscriber->is_connected()) { + $subscriber->disconnect(); + } + + // remove from the subscribers list + unset($this->subscribers[$subscriber->id]); + + // remove from services + unset($this->services[$subscriber->service_name()]); + + // notify user + $this->info("Disconnected subscriber: '$subscriber->id'"); + } + + // show the list for debugging + $this->debug("Current Subscribers: " . implode(', ', array_keys($this->subscribers))); + } + + /** + * When a message event occurs, send to all the subscribers + * @param resource $socket + * @param mixed $data + */ + private function handle_message($socket, $data) { + $subscriber = $this->get_subscriber_from_socket_id($socket); + + // Ensure we have someone to talk to + if ($subscriber === null) + return; + + $this->debug("Received message from " . $subscriber->id); + + // Convert the message from json string to a message array + $json_array = json_decode($data, true); + + if (is_array($json_array)) + try { + + // Check for an authenticating subscriber + if ($json_array['service'] === 'authentication') { + $this->authenticate_subscriber($subscriber, new websocket_message($json_array)); + return; + } + + // Create a websocket_message object using the json data sent + $message = websocket_message::create_from_json_message($json_array); + + if ($message === null) { + return; + } + + // Reject subscribers that do not have not validated + if (!$subscriber->is_authenticated()) { + $subscriber->send(websocket_message::request_authentication($message->request_id())); + return; + } + + // If the message comes from a service, broadcast it to all subscribers subscribed to that service + if ($subscriber->is_service()) { + $this->debug("Message is from service"); + $this->broadcast_service_message($subscriber, $message); + return; + } + + // Message is from the client so check the service_name that needs to get the message + if (!empty($message->service_name())) { + $this->handle_client_message($subscriber, $message); + } else { + // Message does not have a service name + $this->warning("The message does not have a service name. All messages must have a service name to direct their query to."); + $subscriber->send(websocket_message::request_is_bad($message->id, 'INVALID', $message->topic)); + } + } catch (socket_disconnected_exception $sde) { + $this->handle_disconnect($sde->id); + } + } + + private function handle_client_message(subscriber $subscriber, websocket_message $message) { + //find the service with that name + foreach ($this->subscribers as $service) { + //when we find the service send the request + if ($service->service_equals($message->service_name())) { + //attach the current subscriber permissions so the service can verify + $message->permissions($subscriber->get_permissions()); + + //attach the domain name + $message->domain_name($subscriber->get_domain_name()); + + //attach the client id so we can track the request + $message->resource_id = $subscriber->id; + + //send the modified web socket message to the service + $service->send((string) $message); + //continue searching for service providers + continue; + } + } + } + + /** + * Runs the web socket server binding to the ip and port set in default settings + * The run method will stop if the SIG_TERM or SIG_HUP signal is processed in the parent + * @return int + * @throws \RuntimeException + * @throws socket_exception + */ + public function run(): int { + // Reload all settings and initialize object properties + $this->reload_settings(); + + $this->server_socket = stream_socket_server("tcp://{$this->ip}:{$this->port}", $errno, $errstr); + if (!$this->server_socket) { + throw new \RuntimeException("Cannot bind socket ({$errno}): {$errstr}"); + } + stream_set_blocking($this->server_socket, false); + + // + // Register handlers + // The handlers can be registered outside this class because they are standard callbacks + // + $this->on_connect([self::class, 'handle_connect']); + $this->on_disconnect([self::class, 'handle_disconnect']); + $this->on_message([self::class, 'handle_message']); + + $stream_select_tries = 0; + + while ($this->running) { + + // + // Merge all sockets to a single array + // + $read = array_merge([$this->server_socket], $this->clients); + $write = $except = []; + + //$this->debug("Waiting on event. Connected Clients: (".count($this->clients).")", LOG_DEBUG); + // + // Wait for activity on the sockets and timeout about 3 times per second + // + $result = stream_select($read, $write, $except, 0, 333333); + if ($result === false) { + // Check for error status 3 times in a row + if (++$stream_select_tries > 3) { + throw new \RuntimeException("Error occured reading socket"); + } + // There was likely a disconnect during the wait state + $this->update_connected_clients(); + continue; + } + + // Reset stream_select counter + $stream_select_tries = 0; + + if ($result === 0) { + // Timeout no activity + continue; + } + + // + // Handle a socket activity + // + foreach ($read as $client_socket) { + // new connection + if ($client_socket === $this->server_socket) { + $conn = @stream_socket_accept($this->server_socket, 0); + if ($conn) { + // complete handshake on blocking socket + stream_set_blocking($conn, true); + $this->handshake($conn); + // switch to non-blocking for further reads + stream_set_blocking($conn, false); + // add them to the websocket list + $this->clients[] = $conn; + // notify websocket on_connect listeners + $this->trigger_connect($conn); + continue; + } + } + + // Process web socket client communication + $message = $this->receive_frame($client_socket); + if ($message === '') { + $this->debug("Empty message"); + continue; + } + + // Check for control frame + if (strlen($message) === 2) { + $value = bin2hex($message); + if ($value === '03e9') { + $this->disconnect_client($client_socket); + continue; + } + $this->debug("UNKNOWN CONTROL FRAME: '$value'", LOG_ERR); + die(); + } + + try { + $this->trigger_message($client_socket, $message); + } catch (subscriber_exception $se) { + // + // Here we are catching any type of subscriber exception and displaying the error in the log. + // This will disconnect the subscriber as we no longer know the state of the object. + // + + // + // Get the error details + // + $subscriber_id = $se->getSubscriberId(); + $message = $se->getMessage(); + $code = $se->getCode(); + $file = $se->getFile(); + $line = $se->getLine(); + + // + // Dump the details in the log + // + $this->err("ERROR FROM $subscriber_id: $message ($code) IN FILE $file (Line: $line)"); + $this->err($se->getTraceAsString()); + // + // Disconnect the subscriber + // + $subscriber = $this->subscribers[$subscriber_id] ?? null; + if ($subscriber !== null) $this->disconnect_client($subscriber->socket()); + } + } + } + } + + /** + * Overrides the parent class to shutdown all sockets + * @override service + */ + public function __destruct() { + //disconnect all clients + foreach ($this->clients as $socket) { + $this->disconnect_client($socket); + } + //finish destruct using the parent + parent::__destruct(); + } + + public function get_open_sockets(): array { + return $this->clients; + } + + /** + * Returns true if there are connected web socket clients. + * @return bool + */ + public function has_clients(): bool { + return !empty($this->clients); + } + + /** + * When a web socket message is received the $on_message_callback function is called. + * Multiple on_message functions can be specified. + * @param callable $on_message_callback + * @throws InvalidArgumentException + */ + public function on_message(callable $on_message_callback) { + if (!is_callable($on_message_callback)) { + throw new \InvalidArgumentException('The callable on_message_callback must be a valid callable function'); + } + $this->message_callbacks[] = $on_message_callback; + } + + /** + * Calls all the on_message functions + * @param resource $resource + * @param string $message + * @return void + * @access protected + */ + protected function trigger_message($resource, string $message) { + foreach ($this->message_callbacks as $callback) { + call_user_func($callback, $resource, $message); + return; + } + } + + /** + * When a web socket handshake has completed, the $on_connect_callback function is called. + * Multiple on_connect functions can be specified. + * @param callable $on_connect_callback + * @throws InvalidArgumentException + */ + public function on_connect(callable $on_connect_callback) { + if (!is_callable($on_connect_callback)) { + throw new \InvalidArgumentException('The callable on_connect_callback must be a valid callable function'); + } + $this->connect_callbacks[] = $on_connect_callback; + } + + /** + * Calls all the on_connect functions + * @param resource $resource + * @access protected + */ + protected function trigger_connect($resource) { + foreach ($this->connect_callbacks as $callback) { + call_user_func($callback, $resource); + } + } + + /** + * When a web socket has disconnected, the $on_disconnect_callback function is called. + * Multiple functions can be specified with subsequent calls + * @param string|callable $on_disconnect_callback + * @throws InvalidArgumentException + */ + public function on_disconnect($on_disconnect_callback) { + if (!is_callable($on_disconnect_callback)) { + throw new \InvalidArgumentException('The callable on_disconnect_callback must be a valid callable function'); + } + $this->disconnect_callbacks[] = $on_disconnect_callback; + } + + /** + * Calls all the on_disconnect_callback functions + * @param resource $socket + * @access protected + */ + protected function trigger_disconnect($socket) { + foreach ($this->disconnect_callbacks as $callback) { + call_user_func($callback, $socket); + } + } + + /** + * Returns the socket used in the server connection + * @return resource + */ + public function get_socket() { + return $this->server_socket; + } + + /** + * Remove a client socket on disconnect. + * @param resource $resource Resource for the socket connection + * @return bool Returns true on client disconnect and false when the client is not found in the tracking array + * @access protected + */ + protected function disconnect_client($resource): bool { + // Close the socket + if (is_resource($resource)) { + @fwrite($resource, chr(0x88) . chr(0x00)); // 0x88 = close frame, no reason + @fclose($resource); + } + + $this->debug("OLD Client List: " . var_dump($this->clients, true)); + + // Clean out the array + $clients = array_filter($this->clients, function ($resource) { + return is_resource($resource) && !feof($resource); + }); + + $this->debug("NEW Client List: " . var_dump($clients, true)); + + // Compare to the original array + $diff = array_diff($this->clients, $clients); + + $this->debug("DIFF Client List: " . var_dump($diff, true)); + + // Replace the old list with only the connected ones + $this->clients = $clients; + + // Trigger the disconnect for each closed socket + foreach ($diff as $socket) { + // We must check before closing the socket that it is a resource or a fatal error will occur + if (is_resource($socket)) { + @fwrite($resource, "\x88\x00"); // 0x88 = close frame, no payload + @fclose($socket); + } + // Trigger the disconnect so any hooks can clean up their lists + $this->trigger_disconnect($socket); + } + return true; + } + + /** + * Performs web socket handshake on new connection. + * @access protected + */ + protected function handshake($resource): void { + // ensure blocking to read full header + stream_set_blocking($resource, true); + $request_header = ''; + while (($line = fgets($resource)) !== false) { + $request_header .= $line; + if (rtrim($line) === '') { + break; + } + } + if (!preg_match("/Sec-WebSocket-Key: (.*)\r\n/", $request_header, $matches)) { + throw new \RuntimeException("Invalid WebSocket handshake"); + } + $key = trim($matches[1]); + $accept_key = base64_encode( + sha1($key . "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", true) + ); + $response_header = "HTTP/1.1 101 Switching Protocols\r\n" + . "Upgrade: websocket\r\n" + . "Connection: Upgrade\r\n" + . "Sec-WebSocket-Accept: {$accept_key}\r\n\r\n"; + fwrite($resource, $response_header); + } + + /** + * Read specific number of bytes from a websocket + * @param resource $socket + * @param int $length + * @return string + */ + private function read_bytes($socket, int $length): string { + $data = ''; + while (strlen($data) < $length && is_resource($socket)) { + $chunk = fread($socket, $length - strlen($data)); + if ($chunk === false || $chunk === '' || !is_resource($socket)) { + //$this->disconnect_client($socket); + return ''; + } + $data .= $chunk; + } + return $data; + } + + /** + * Reads a websocket data frame and converts it to a regular string + * @param resource $socket + * @return string + */ + private function receive_frame($socket): string { + // Read first two header bytes + $hdr = $this->read_bytes($socket, 2); + // Ensure we have the correct number of bytes + if (strlen($hdr) !== 2) { + $this->warning('Header is empty!'); + $this->update_connected_clients(); + return ''; + } + $bytes = unpack('Cfirst/Csecond', $hdr); + $fin = ($bytes['first'] >> 7) & 0x1; + $opcode = $bytes['first'] & 0x0F; + $masked = ($bytes['second'] >> 7) & 0x1; + $length = $bytes['second'] & 0x7F; + + // Determine actual payload length + if ($length === 126) { + $ext = $this->read_bytes($socket, 2); + // Ensure we have the correct number of bytes + if (strlen($ext) < 2) + return ''; + $length = unpack('n', $ext)[1]; + } elseif ($length === 127) { + $ext = $this->read_bytes($socket, 8); + // Ensure we have the correct number of bytes + if (strlen($ext) < 8) + return ''; + // unpack 64-bit BE; PHP 7.0+: use J, else fallback + $arr = unpack('J', $ext); + $length = $arr[1]; + } + + // Read mask key if client→server frame + $maskKey = $masked ? $this->read_bytes($socket, 4) : ''; + + // Read payload data + $data = $this->read_bytes($socket, $length); + + if (empty($data)) { + $this->warning("Received empty frame (ID# $socket)"); + return ''; + } + + // Unmask if needed + if ($masked) { + // Ensure we have the correct number of bytes + if (strlen($maskKey) < 4) + return ''; + $unmasked = ''; + for ($i = 0; $i < $length; $i++) { + $unmasked .= $data[$i] ^ $maskKey[$i % 4]; + } + $data = $unmasked; + } + + // Return completed data frame + return $data; + } + + private function debug(string $message) { + self::log($message, LOG_DEBUG); + } + + private function warning(string $message) { + self::log($message, LOG_WARNING); + } + + private function err(string $message) { + self::log($message, LOG_ERR); + } + + private function info(string $message) { + self::log($message, LOG_INFO); + } + + /** + * Send text frame to client. If the socket connection is not a valid resource, the send + * method will fail silently and return false. + * @param resource $resource The socket or resource id to communicate on. + * @param string|null $payload The string to wrap in a web socket frame to send to the clients + * @return bool + */ + public static function send($resource, ?string $payload): bool { + if (!is_resource($resource)) { + self::log("Cannot send: invalid resource", LOG_ERR); + return false; + } + + if ($payload === null) { + @fwrite($resource, "\x88\x00"); // 0x88 = close frame, no payload + return true; + } + + $payload_length = strlen($payload); + $frame_header = "\x81"; // FIN = 1, text frame + // Create frame header + if ($payload_length <= 125) { + $frame_header .= chr($payload_length); + } elseif ($payload_length <= 65535) { + $frame_header .= chr(126) . pack('n', $payload_length); + } else { + $frame_header .= chr(127) . pack('J', $payload_length); // PHP 7.1+ supports 'J' for 64-bit unsigned + } + + $frame = $frame_header . $payload; + + // Attempt to write full frame + $written = @fwrite($resource, $frame); + if ($written === false) { + self::log("fwrite() failed for socket " . (int) $resource, LOG_ERR); + throw new socket_disconnected_exception($resource); + } + + if ($written < strlen($frame)) { + self::log("Partial frame sent: {$written}/" . strlen($frame) . " bytes", LOG_WARNING); + return false; + } + + return true; + } + + /** + * Get the IP and port of the connected remote system. + * @param resource $resource The resource or stream of the connection + * @return array An associative array of remote_ip and remote_port + */ + public static function get_remote_info($resource): array { + [$remote_ip, $remote_port] = explode(':', stream_socket_get_name($resource, true), 2); + return ['remote_ip' => $remote_ip, 'remote_port' => $remote_port]; + } +} diff --git a/core/websockets/resources/service/debian-websockets.service b/core/websockets/resources/service/debian-websockets.service new file mode 100644 index 0000000000..83e8eb1b47 --- /dev/null +++ b/core/websockets/resources/service/debian-websockets.service @@ -0,0 +1,18 @@ +# +# Install with: +# +# cp debian-websockets.service /etc/systemd/system/websockets.service +# systemctl daemon-reload +# systemctl enable --now websockets.service +# +[Unit] +Description=Websocket Router Service + +[Service] +ExecStart=/usr/bin/php /var/www/fusionpbx/core/websockets/resources/service/websockets.php --no-fork +Restart=on-failure +User=www-data +Group=www-data + +[Install] +WantedBy=multi-user.target diff --git a/core/websockets/resources/service/websockets.php b/core/websockets/resources/service/websockets.php new file mode 100755 index 0000000000..dbc64c9382 --- /dev/null +++ b/core/websockets/resources/service/websockets.php @@ -0,0 +1,81 @@ +#!/usr/bin/env php + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ +declare(strict_types=1); + +if (version_compare(PHP_VERSION, '7.1.0', '<')) { + die("This script requires PHP 7.1.0 or higher. You are running " . PHP_VERSION . "\n"); +} + +// +// Only run from the command line +// +if (PHP_SAPI !== 'cli') { + die('This script can only be run from the command line.'); +} + +// +// Get the framework files +// +require_once dirname(__DIR__, 4) . '/resources/require.php'; + +try { + + // + // Create a web socket service + // + $ws_server = websocket_service::create(); + + // + // Exit with status code given by run return value + // + exit($ws_server->run()); +} catch (Throwable $ex) { + + //////////////////////////////////////////////////// + // Here we catch all exceptions and log the error // + //////////////////////////////////////////////////// + // + // Get the error details + // + $message = $ex->getMessage(); + $code = $ex->getCode(); + $file = $ex->getFile(); + $line = $ex->getLine(); + + // + // Show user the details + // + echo "FATAL ERROR: '$message' (ERROR CODE: $code) FROM $file (Line: $line)\n"; + echo $ex->getTraceAsString() . "\n"; + + // + // Exit with non-zero status code + // + exit($ex->getCode()); +} diff --git a/resources/classes/filter_chain.php b/resources/classes/filter_chain.php new file mode 100644 index 0000000000..949e58cdb8 --- /dev/null +++ b/resources/classes/filter_chain.php @@ -0,0 +1,110 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * Builds an event filter chain link of any class implementing an event_filter interface + * + * @author Tim Fry + */ +final class filter_chain { + + /** + * Builds a filter chain link for filter objects + * @param array $filters Array of filter objects + * @return filter + */ + public static function or_link(array $filters): filter { + + // Create an anonymous object to end the filter + $final = new class implements filter { + + public function __invoke(string $key, $value): bool { + return false; + } + }; + + // Add the final object + $chain = $final; + + // Iterate over the objects to add them in reverse order + for ($i = count($filters) - 1; $i >= 0; $i--) { + $current = $filters[$i]; + + // Remember the chain that will be called next + $next = $chain; + + // Create an anonymous object to start the filter + $chain = new class($current, $next) implements filter { + + private $current; + private $next; + + public function __construct(filter $current, filter $next) { + $this->current = $current; + $this->next = $next; + } + + public function __invoke(string $key, $value): ?bool { + if (($this->current)($key, $value)) { + // Any filter passed so return true + return true; + } + // Filter did not pass so we check the next one + return ($this->next)($key, $value); + } + }; + } + + // Return the completed filter chain + return $chain; + } + + public static function and_link(array $filters): filter { + return new class($filters) implements filter { + private $filters; + + public function __construct(array $filters) { + $this->filters = $filters; + } + + public function __invoke(string $key, $value): ?bool { + foreach ($this->filters as $filter) { + $result = ($filter)($key, $value); + // Check if a filter requires a null to be returned + if ($result === null) { + return null; + } elseif(!$result) { + return false; + } + } + // All filters passed so return true + return true; + } + }; + } +} diff --git a/resources/classes/invalid_uuid_exception.php b/resources/classes/invalid_uuid_exception.php new file mode 100644 index 0000000000..6d3f94f50a --- /dev/null +++ b/resources/classes/invalid_uuid_exception.php @@ -0,0 +1,38 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * Description of invalid_uuid + * + * @author Tim Fry + */ +class invalid_uuid_exception extends Exception { + public function __construct(string $message = "UUID is not valid", int $code = 0, ?\Throwable $previous = null): \Exception { + return parent::__construct($message, $code, $previous); + } +} diff --git a/resources/classes/service.php b/resources/classes/service.php index 215bec9053..77e1b183bb 100644 --- a/resources/classes/service.php +++ b/resources/classes/service.php @@ -391,6 +391,29 @@ abstract class service { } } + private static function log_level_to_string(int $level = LOG_NOTICE): string { + switch ($level){ + case 0: + return 'EMERGENCY'; + case 1: + return 'ALERT'; + case 2: + return 'CRITICAL'; + case 3: + return 'ERROR'; + case 4: + return 'WARNING'; + case 5: + return 'NOTICE'; + case 6: + return 'INFO'; + case 7: + return 'DEBUG'; + default: + return 'INFO'; + } + } + /** * Show memory usage to the user */ @@ -416,7 +439,8 @@ abstract class service { //enable sending message to the console directly if (self::$log_level === LOG_DEBUG || !self::$forking_enabled) { - echo $message . "\n"; + $time = date('Y-m-d H:i:s'); + echo "[$time] [" . self::log_level_to_string($level) . "] " . $message . "\n"; } // Log the message to syslog diff --git a/resources/interfaces/filter.php b/resources/interfaces/filter.php new file mode 100644 index 0000000000..f05add0276 --- /dev/null +++ b/resources/interfaces/filter.php @@ -0,0 +1,45 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * + * @author Tim Fry + */ +interface filter { + /** + * Uses the __invoke magic method to create a filter + * There are three possible return values for a filter: + * 1. True - Passed + * 2. False - Failed + * 3. null - Invalid + * @param string $key + * @param mixed $value + * @return bool|null + */ + public function __invoke(string $key, $value): ?bool; +} diff --git a/resources/interfaces/filterable_payload.php b/resources/interfaces/filterable_payload.php new file mode 100644 index 0000000000..3cd280abff --- /dev/null +++ b/resources/interfaces/filterable_payload.php @@ -0,0 +1,35 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * + * @author Tim Fry + */ +interface filterable_payload { + public function apply_filter(filter $filter); +} diff --git a/resources/interfaces/websocket_service_interface.php b/resources/interfaces/websocket_service_interface.php new file mode 100644 index 0000000000..b3c842a3e2 --- /dev/null +++ b/resources/interfaces/websocket_service_interface.php @@ -0,0 +1,36 @@ + + * Portions created by the Initial Developer are Copyright (C) 2008-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Mark J Crane + * Tim Fry + */ + +/** + * + * @author Tim Fry + */ +interface websocket_service_interface { + public static function create_filter_chain_for(subscriber $subscriber): ?filter; + public static function get_service_name(): string; +}