server = $server; $this->active_users = $details['active_users']; $this->active_users_cutoff = $details['active_users_cutoff']; $this->name = $details['name']; $this->token = $details['token']; $this->admins = $details['admins']; $this->moderators = $details['moderators']; $this->created = $details['created']; $this->description = $details['description'] ?? ""; $this->image_id = $details['image_id']; $this->info_updates = $details['info_updates']; $this->message_sequence = $details['message_sequence']; $this->read = $details['read']; $this->write = $details['write']; $this->upload = $details['upload']; $room_identifier = $this->get_room_identifier(); /** @var string[] */ $this->tags = isset($details['tags']) ? $details['tags'] : []; $this->language_flag = isset($languages[$room_identifier]) ? $languages[$room_identifier] : ""; } /** * Return information for JSON serialization. */ function jsonSerialize(): array { $details = get_object_vars($this); unset($details['server']); return $details; } /** * Create a CommunityRoom instance from loaded data. * @param CommunityServer $server */ public static function from_details($server, array $details) { return new CommunityRoom($server, $details); } /** * Create an array of CommunityRoom instances from loaded data. * @param array[] $details * @return CommunityRoom[] */ public static function from_details_array($server, array $details) { return array_map(function($room_data) use ($server) { return CommunityRoom::from_details($server, $room_data); }, $details); } /** * Sorts Community rooms in-place by the given string property. * @param \CommunityRoom[] $rooms Rooms to sort by given key. * @param string $key String property of CommunityRoom to sort by. */ public static function sort_rooms_str(array &$rooms, string $key) { usort($rooms, function(\CommunityRoom $a, \CommunityRoom $b) use ($key) { return strcmp( $a->$key, $b->$key ); }); } /** * Sorts Community rooms in-place by their server's public key. * @param \CommunityRoom[] $rooms Rooms to sort by server pubkey. */ public static function sort_rooms_by_pubkey(array &$rooms) { usort($rooms, function(\CommunityRoom $a, \CommunityRoom $b) { return strcmp( $a->server->get_pubkey(), $b->server->get_pubkey() ); }); } /** * Returns array of staff Session IDs. * @return string[] */ function get_staff() { return array_unique( [...$this->admins, ...$this->moderators] ); } /** * Returns seconds elapsed since room was created. */ function get_age(): float { return time() - $this->created; } /** * Formats the period over which active users are counted as a duration string. * @return string Active user cutoff period for this room, expressed in days. */ function format_user_cutoff_period(): ?string { global $WEEK_SECONDS, $DAY_SECONDS, $HOUR_SECONDS, $MINUTE_SECONDS; $active_users_cutoff = $this->active_users_cutoff; if ($active_users_cutoff >= $WEEK_SECONDS) { return floor($active_users_cutoff / $WEEK_SECONDS) . ' week(s)'; } if ($active_users_cutoff >= $DAY_SECONDS) { return floor($active_users_cutoff / $DAY_SECONDS) . ' day(s)'; } if ($active_users_cutoff >= $HOUR_SECONDS) { return floor($active_users_cutoff / $HOUR_SECONDS) . ' hour(s)'; } if ($active_users_cutoff >= $MINUTE_SECONDS) { return floor($active_users_cutoff / $MINUTE_SECONDS) . 'minute(s)'; } return floor($active_users_cutoff) . 's'; } /** * Return the browser preview URL for this room. */ function get_preview_url(): string { $base_url = $this->server->base_url; $token = $this->token; return "$base_url/r/$token"; } /** * Return the QR code invite URL for this room. */ function get_invite_url(): string { $base_url = $this->server->base_url; $token = $this->token; return "$base_url/r/$token/invite.png"; } /** * Return the in-app join URL for this room. */ function get_join_url(): string { $base_url = $this->server->base_url; $pubkey = $this->server->pubkey; $token = $this->token; return "$base_url/$token?public_key=$pubkey"; } /** * Return the URL of this room's designated icon. */ function get_icon_url(): string | bool { $image_id = $this->image_id; if ($image_id == null) return false; $base_url = $this->server->base_url; $token = $this->token; return "$base_url/room/$token/file/$image_id"; } /** * Return our format of room identifier. * @return string String in the form `token+pubkey[:4]`. */ function get_room_identifier(): string { $token = $this->token; $pubkey_4 = substr($this->server->pubkey, 0, 4); return "$token+$pubkey_4"; } /** * @var string[] RESERVED_TAGS * Array of derived tags unavailable for manual tagging. */ const RESERVED_TAGS = ["official"]; /** * Checks whether the given manual tag can be accepted. */ static function is_reserved_tag(string $tag): bool { return in_array(strtolower($tag), CommunityRoom::RESERVED_TAGS); } /** * Checks whether this room belongs to a Session-owned server. */ function is_official_room(): bool { return $this->server->is_official_server(); } /** * Return the tags associated with this room. * @return string[] Tags as string array. */ function get_room_tags(): array { $user_tags = array_filter( $this->tags, function($tag) { return !CommunityRoom::is_reserved_tag($tag); } ); $derived_tags = []; if ($this->is_official_room()) { $derived_tags[] = "official"; } return [...$derived_tags, ...$user_tags]; } } /** * Class representing Session Community server hosting Community rooms. */ class CommunityServer implements JsonSerializable { /** @var string $base_url The root URL of this server. */ public string $base_url = ""; /** @var string $pubkey The SOGS protocol pubkey of this server. */ public string $pubkey = ""; /** @var ?\CommunityRoom[] Array of rooms hosted by this server. */ public ?array $rooms = null; /** * @var string[] $room_hints * This array contains fallback room tokens collected from links. * Used only if fetching rooms list fails. */ private array $room_hints = []; /** * @var bool $merge_error * * Flag specifying whether the server is invalidated as a result of merging. */ private bool $merge_error = false; private function __construct() {} /** * Compare two CommunityServer instances by base URL. * @param CommunityServer $a First server to compare URLs. * @param CommunityServer $b Second server to compare URLs. * @return int A number less than, equal to, or greater than zero * when the servers are in correct order, interchangable, or in reverse order, * respectively. */ static function compare_by_url($a, $b): int { return strcmp( $a->get_hostname(), $b->get_hostname() ); } /** * Sort an array of servers in place based on URL. * @param CommunityServer[] &$servers */ static function sort_by_url(array &$servers) { usort($servers, 'CommunityServer::compare_by_url'); } /** * Compare two CommunityServer instances by public key. * @param CommunityServer $a First server to compare public keys. * @param CommunityServer $b Second server to compare public keys. * @return int A number less than, equal to, or greater than zero * when the servers are in correct order, interchangable, or in reverse order, * respectively. */ static function compare_by_pubkey($a, $b): int { return strcmp($a->pubkey, $b->pubkey); } /** * Sorts an array of servers in place by public key. * @param CommunityServer[] $servers */ public static function sort_by_pubkey(&$servers) { usort($servers, 'CommunityServer::compare_by_pubkey'); } /** * Absorbs extra info from another instance of the same server. * @param CommunityServer $server * * @return True if successful, false in case of mistmatch. */ private function merge_from($server): bool { // Merge room hint information. $this->room_hints = [ ...$this->room_hints, ...$server->room_hints ]; // Merge public key information. // In case of error, set the `merge_error` flag. if (!$this->set_pubkey($server->pubkey)) { if ($this->merge_error) { return false; } $base_url = $this->base_url; $other_base_url = $server->base_url; $pubkey_old = $this->pubkey; $pubkey_new = $server->pubkey; log_error( "Key collision for $base_url:" . "Have $pubkey_old, fetched $pubkey_new" . "from server $other_base_url" ); $this->merge_error = true; return false; } // Prefer HTTPS URLs over HTTP. if (str_starts_with($server->base_url, "https:")) { $this->base_url = $server->get_scheme() . "://" . $this->get_hostname(); } // Prefer domain names over IPs (connections to SOGS survive relocation). if (filter_var($this->get_hostname(), FILTER_VALIDATE_IP)) { $this->base_url = $this->get_scheme() . "://" . $server->get_hostname(); } return true; } /** * Re-introduces the servers to a consistent state after merging. * @param CommunityServer[] $servers * @return CommunityServer[] */ private static function ensure_merge_consistency(array $servers) { // Exclude servers with merge errors. $servers = array_filter($servers, function(\CommunityServer $server) { return !$server->merge_error; }); // Remove duplicate room hints; does not require sorting. foreach ($servers as $server) { $server->room_hints = array_unique($server->room_hints); } return $servers; } /** * Merges consecutive servers in array in place on equality of given attribute. * @param CommunityServer[] $servers Servers sorted by given attribute. * @param string $method Method name to retrieve attribute from server. */ private static function merge_by(&$servers, string $method) { // Backwards-merging to preserve indexing for unprocessed servers. // Merging only makes sense for pairs, so stop at $i = 1. for ($i = count($servers) - 1; $i >= 1; $i--) { if ($servers[$i]->$method() == $servers[$i - 1]->$method()) { // Merge this server into the previous one, discarding it. $servers[$i - 1]->merge_from($servers[$i]); array_splice($servers, $i, 1); } } } /** * Write details about this server to debug log. */ private function log_details() { $base_url = $this->base_url; $count_rooms = count($this->rooms ?? []); $count_room_hints = count($this->room_hints); $pubkey = $this->pubkey ? truncate($this->pubkey, 4) : "unknown"; log_debug("Server $base_url"."[$count_rooms/$count_room_hints] { pubkey: $pubkey }"); } /** * Filters the given servers to remove URL duplicates. * @param CommunityServer[] $servers Servers to merge by URL. * @return CommunityServer[] Merged URL-unique servers. */ public static function dedupe_by_url($servers) { CommunityServer::sort_by_url($servers); CommunityServer::merge_by($servers, "get_hostname"); $servers = CommunityServer::ensure_merge_consistency($servers); return $servers; } /** * Filters the given servers to remove pubkey duplicates. * @param CommunityServer[] $servers Servers to merge by public key. * @return CommunityServer[] Merged pubkey-unique servers. */ public static function dedupe_by_pubkey($servers) { CommunityServer::sort_by_pubkey($servers); CommunityServer::merge_by($servers, "get_pubkey"); $servers = CommunityServer::ensure_merge_consistency($servers); return $servers; } /** * Return information for JSON serialization. */ function jsonSerialize(): array { return get_object_vars($this); } /** * Create server instances located on hardcoded hosts. * @param string[] $hosts Array of base URLs for known servers. * @param string[] $pubkeys * Associative array from hostnames to SOGS public keys. * @return CommunityServer[] Array of resulting Community servers. */ static function from_known_hosts(array $hosts, array $pubkeys) { $servers = []; foreach ($hosts as $base_url) { $server = new CommunityServer(); $server->base_url = $base_url; $hostname = url_get_base($base_url, false); $server->pubkey = $pubkeys[$hostname]; $servers[] = $server; } return $servers; } /** * Create server instances from given room join URLs. * Resulting servers will know of the embedded room tokens. * @param string[] $join_urls Join URLs found in the wild. * @return CommunityServer[] Array of resulting Community servers. */ static function from_join_urls(array $join_urls) { $servers = []; foreach ($join_urls as $join_url) { $server = new CommunityServer(); // Call must succeed with no default public key. $server->initialize_from_url($join_url); $servers[] = $server; } return $servers; } /** * Create Community server instance from loaded server data. * @param array $details Decoded JSON associative data about server. * @return CommunityServer Server represented by given data. */ static function from_details(array $details) { $server = new CommunityServer(); $server->base_url = $details['base_url']; $server->pubkey = $details['pubkey']; $server->rooms = CommunityRoom::from_details_array($server, $details['rooms']); return $server; } /** * Create Community server instance from array loaded server data. * @param array $details Decoded JSON associative arrays about server. * @return CommunityServer[] Servers represented by given data. */ static function from_details_array(array $details_array) { $servers = []; foreach ($details_array as $details) { $servers[] = CommunityServer::from_details($details); } return $servers; } /** * Add to the given servers additional data extracted from our sources. * @param \CommunityServer[] $servers * @param \CommunitySources $source */ static function source_additional_info(array $servers, \CommunitySources $source): void { foreach ($servers as $server) { foreach ($server->rooms as $room) { $sourced_tags = $source->get_room_tags($room->get_room_identifier()); $room->tags = [...$room->tags, ...$sourced_tags]; } } } /** * Collect the rooms among the given Community servers. * @param CommunityServer[] $servers Array of Community servers. * @return CommunityRoom[] * Array of all rooms contained in the given servers. */ static function enumerate_rooms($servers) { $rooms = []; foreach ($servers as $server) { $rooms[] = $server->rooms; } return array_merge([], ...$rooms); } /** * Polls given servers for rooms and public key and saves this info. * Servers will be disqualified if no rooms can be found, * and/or if no public key is obtained or hardcoded. * @param CommunityServer[] $servers Servers to fetch. * @return CommunityServer[] Servers polled successfully. */ public static function poll_reachable(array $servers): array { $reachable_servers = []; // Synchronous for-loop for now. foreach ($servers as $server) { if (!($server->fetch_rooms())) continue; if (!($server->fetch_pubkey())) continue; $reachable_servers[] = $server; } return $reachable_servers; } /** * Returns the URL scheme of this server. * @return string "http" or "https". */ function get_scheme() { return parse_url($this->base_url, PHP_URL_SCHEME); } /** * Reduces this server's base URL to HTTP. */ function downgrade_scheme() { $base_url = $this->base_url; $this->base_url = "http://" . $this->get_hostname(); log_info("Downgrading $base_url to HTTP."); } /** * Returns the hostname for this server. * @return string URL with hostname and port, if applicable. * Scheme not included. */ function get_hostname() { return url_get_base($this->base_url, include_scheme: false); } /** * Returns the server's root URL. * @return string URL with scheme, hostname, and port, if applicable. */ function get_base_url() { return $this->base_url; } /** * Returns the server's public key. * @return string SOGS pubkey as used in the Session protocol. */ function get_pubkey() { return $this->pubkey; } /** * Attempts to set the server public key. * @param string $pubkey SOGS public key. * @return bool True if successful, false in case of mismatch. */ function set_pubkey(string $pubkey): bool { if ($this->has_pubkey() && $this->pubkey != $pubkey) { return false; } $this->pubkey = $pubkey; return true; } /** * Attempts to read the server public key from a join URL. * @param string $join_url Join URL for any of the server's rooms. * @return bool True if successful, false in case of mismatch. */ function set_pubkey_from_url(string $join_url): bool { return $this->set_pubkey(url_get_pubkey($join_url)); } /** * Learns server info from a room's join URL. * The base URL and public key are saved, * and the room token is added as a fallback for room polling. * @param string $join_url Room join URL to initialize with. * @return bool True if successful, false in case of public key mismatch. */ function initialize_from_url($join_url): bool { if (!$this->set_pubkey_from_url($join_url)) { return false; } $this->base_url = url_get_base($join_url); $this->room_hints[] = url_get_token($join_url); return true; } /** * Checks whether the current server SOGS public key is initialized. * @return bool False if the public key is empty, true otherwise. */ function has_pubkey(): bool { return $this->pubkey != ""; } /** * Attempts to fetch the current server's room listing. * Downgrades the server's scheme to HTTP if necessary. * @return array|false Associative data about rooms if successful. */ private function fetch_room_list(): array|bool { global $FAST_FETCH_MODE; $base_url = $this->base_url; list($rooms, $downgrade) = curl_get_contents_downgrade("$base_url/rooms?all=1", retries: $FAST_FETCH_MODE ? 2 : 4); if (!$rooms) { log_info("Failed fetching /rooms."); return false; } if ($downgrade) $this->downgrade_scheme(); $room_data = json_decode($rooms, true); if ($room_data == null) { log_info("Failed parsing /rooms."); return false; } log_debug("Fetched /rooms successfully"); return $room_data; } /** * Attempts to fetch the current server's rooms using observed room names. * Downgrades the server's scheme to HTTP if necessary. * @return ?array Associative data about rooms if successful. */ private function fetch_room_hints(): ?array { $base_url = $this->base_url; $rooms = []; foreach ($this->room_hints as $token) { log_debug("Testing room /$token."); list($room_raw, $downgrade) = curl_get_contents_downgrade("$base_url/room/$token", retries: 2); if (!$room_raw) { log_info("Room /$token not reachable."); continue; } if ($downgrade) $this->downgrade_scheme(); $room_data = json_decode($room_raw, true); if ($room_data == null) { if (count($rooms) == 0) { log_info("Room /$token not parsable."); break; } else { log_debug("Room /$token not parsable, continuing."); continue; } } $rooms[] = $room_data; } // Mark no rooms as failure. if (count($rooms) == 0) { log_debug("No room hints were valid."); return null; } return $rooms; } /** * Attempt to fetch rooms for tbe current server using SOGS API. * * @return bool True if successful, false otherwise. */ function fetch_rooms(): bool { global $FAST_FETCH_MODE; $this->log_details(); $base_url = $this->base_url; // Check reachability before polling too much. if (count($this->room_hints) >= 2) { log_info("Checking reachability for $base_url first..."); log_value($this->room_hints); if (!url_is_reachable($base_url, retries: $FAST_FETCH_MODE ? 1 : 4)) { log_warning("Reachability test failed by $base_url."); return false; } } log_info("Fetching rooms for $base_url."); $room_data = $this->fetch_room_list(); if (!$room_data) $room_data = $this->fetch_room_hints(); if ($room_data == null) { log_warning("Could not fetch rooms for $base_url."); return false; } $this->rooms = CommunityRoom::from_details_array($this, $room_data); return true; } /** * Attempt to fetch server public key by parsing SOGS HTML preview. * * @return bool True iff no key conflict has arised and we have a pubkey. */ function fetch_pubkey() { global $FAST_FETCH_MODE; if (empty($this->rooms)) { log_warning("Server has no rooms to poll for public key"); return false; } $has_pubkey = $this->has_pubkey(); if ($has_pubkey && $FAST_FETCH_MODE) { return true; } $preview_url = $this->rooms[0]->get_preview_url(); log_info("Fetching pubkey from $preview_url"); $room_view = curl_get_contents($preview_url, retries: $has_pubkey || $FAST_FETCH_MODE ? 1 : 5); if (!$room_view) { log_debug("Failed to fetch room preview from $preview_url."); return $has_pubkey; } $links = parse_join_links($room_view); $link = $links[0]; if (!isset($link)) { log_debug("Could not locate join link in preview at $preview_url."); return $has_pubkey; } if (!$this->set_pubkey_from_url($link)) { // More information needs to be logged for errors // in case of lack of context due to lower verbosity. $base_url = $this->base_url; $pubkey_old = $this->pubkey; $pubkey_new = url_get_pubkey($link); log_error( "Key collision for $base_url:" . "Have $pubkey_old, fetched $pubkey_new from $preview_url" ); return false; } return true; } /** * Checks whether this server belongs to Session / OPTF. */ function is_official_server() { global $KNOWN_PUBKEYS; return ( $this->base_url == "https://open.getsession.org" && $this->pubkey == $KNOWN_PUBKEYS['open.getsession.org'] ); } } ?>