|
|
|
<?php
|
|
|
|
|
|
|
|
include_once "$PROJECT_ROOT/languages/language_flags.php";
|
|
|
|
|
|
|
|
class CommunityRoom implements JsonSerializable {
|
|
|
|
/** @var CommunityServer $server */
|
|
|
|
public readonly object $server;
|
|
|
|
public readonly ?int $active_users;
|
|
|
|
public readonly ?int $active_users_cutoff;
|
|
|
|
public readonly string $token;
|
|
|
|
public readonly ?string $name;
|
|
|
|
public readonly ?array $admins;
|
|
|
|
public readonly ?array $moderators;
|
|
|
|
public readonly ?float $created;
|
|
|
|
public readonly ?string $description;
|
|
|
|
public readonly ?int $image_id;
|
|
|
|
public readonly ?int $info_updates;
|
|
|
|
public readonly ?int $message_sequence;
|
|
|
|
public readonly ?bool $read;
|
|
|
|
public readonly ?bool $upload;
|
|
|
|
public readonly ?bool $write;
|
|
|
|
/** @var string[] $tags */
|
|
|
|
public readonly array $tags;
|
|
|
|
|
|
|
|
// Custom properties
|
|
|
|
public readonly string $language_flag;
|
|
|
|
|
|
|
|
private function __construct($server, array $details) {
|
|
|
|
global $languages;
|
|
|
|
|
|
|
|
$this->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 all room data to be serialized to JSON
|
|
|
|
*/
|
|
|
|
function jsonSerialize(): array {
|
|
|
|
$details = get_object_vars($this);
|
|
|
|
unset($details['server']);
|
|
|
|
return $details;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Create a CommunityRoom instance from data.
|
|
|
|
* @param CommunityServer $server
|
|
|
|
*/
|
|
|
|
public static function from_details($server, array $details) {
|
|
|
|
return new CommunityRoom($server, $details);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Create an array of CommunityRoom instances from 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);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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;
|
|
|
|
}
|
|
|
|
|
|
|
|
function get_preview_url(): string {
|
|
|
|
$base_url = $this->server->base_url;
|
|
|
|
$token = $this->token;
|
|
|
|
return "$base_url/r/$token";
|
|
|
|
}
|
|
|
|
|
|
|
|
function get_invite_url(): string {
|
|
|
|
$base_url = $this->server->base_url;
|
|
|
|
$token = $this->token;
|
|
|
|
return "$base_url/r/$token/invite.png";
|
|
|
|
}
|
|
|
|
|
|
|
|
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";
|
|
|
|
}
|
|
|
|
|
|
|
|
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";
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns our format of room identifier,
|
|
|
|
* i.e. token+pubkey[:4]
|
|
|
|
*/
|
|
|
|
function get_room_identifier(): string {
|
|
|
|
$token = $this->token;
|
|
|
|
$pubkey_4 = substr($this->server->pubkey, 0, 4);
|
|
|
|
return "$token+$pubkey_4";
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
$SERVER_STRINGIFY_MODE = 0;
|
|
|
|
|
|
|
|
class CommunityServer implements JsonSerializable {
|
|
|
|
// public static int $STRINGIFY_MODE = 0;
|
|
|
|
|
|
|
|
public string $base_url = "";
|
|
|
|
public string $pubkey = "";
|
|
|
|
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 = [];
|
|
|
|
|
|
|
|
private function __construct() {}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Compare two CommunityServer instances by URL.
|
|
|
|
* @param CommunityServer $a
|
|
|
|
* @param CommunityServer $b
|
|
|
|
*/
|
|
|
|
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
|
|
|
|
* @param CommunityServer $b
|
|
|
|
*/
|
|
|
|
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 describing the same server.
|
|
|
|
* @param CommunityServer $server
|
|
|
|
*/
|
|
|
|
private function merge_from($server) {
|
|
|
|
$this->room_hints = [
|
|
|
|
...$this->room_hints,
|
|
|
|
...$server->room_hints
|
|
|
|
];
|
|
|
|
|
|
|
|
if (!$this->has_pubkey() && $server->has_pubkey()) {
|
|
|
|
$this->pubkey = $server->pubkey;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (str_starts_with($server->base_url, "https:")) {
|
|
|
|
$this->base_url = $server->get_scheme() . "://" . $this->get_hostname();
|
|
|
|
}
|
|
|
|
|
|
|
|
// Overwrite IPs
|
|
|
|
if (filter_var($this->get_hostname(), FILTER_VALIDATE_IP)) {
|
|
|
|
$this->base_url = $this->get_scheme() . "://" . $server->get_hostname();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Re-introduces the server to a consistent state after merging.
|
|
|
|
*/
|
|
|
|
private function merge_consistency() {
|
|
|
|
$this->room_hints = array_unique($this->room_hints);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Merges consecutive servers on equality of given attribute.
|
|
|
|
* @param CommunityServer[] $servers Servers sorted by attribute-
|
|
|
|
* @param string $key Method call whose result to merge servers by.
|
|
|
|
*/
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
* @return CommunityServer[]
|
|
|
|
*/
|
|
|
|
public static function dedupe_by_url($servers) {
|
|
|
|
CommunityServer::sort_by_url($servers);
|
|
|
|
|
|
|
|
CommunityServer::merge_by($servers, "get_hostname");
|
|
|
|
|
|
|
|
foreach ($servers as $server) $server->merge_consistency();
|
|
|
|
|
|
|
|
return $servers;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Filters the given servers to remove pubkey duplicates.
|
|
|
|
* @param CommunityServer[] $servers
|
|
|
|
* @return CommunityServer[]
|
|
|
|
*/
|
|
|
|
public static function dedupe_by_pubkey($servers) {
|
|
|
|
CommunityServer::sort_by_pubkey($servers);
|
|
|
|
|
|
|
|
CommunityServer::merge_by($servers, "get_pubkey");
|
|
|
|
|
|
|
|
foreach ($servers as $server) $server->merge_consistency();
|
|
|
|
|
|
|
|
return $servers;
|
|
|
|
}
|
|
|
|
|
|
|
|
function jsonSerialize(): array {
|
|
|
|
return get_object_vars($this);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @return CommunityServer[]
|
|
|
|
*/
|
|
|
|
static function from_known_hosts($hosts, $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;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @return CommunityServer[]
|
|
|
|
*/
|
|
|
|
static function from_join_urls(array $join_urls) {
|
|
|
|
$servers = [];
|
|
|
|
|
|
|
|
foreach ($join_urls as $join_url) {
|
|
|
|
$server = new CommunityServer();
|
|
|
|
|
|
|
|
$server->initialize_from_url($join_url);
|
|
|
|
|
|
|
|
$servers[] = $server;
|
|
|
|
}
|
|
|
|
|
|
|
|
return $servers;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param array $details
|
|
|
|
* @return CommunityServer
|
|
|
|
*/
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param array[] $details
|
|
|
|
* @return CommunityServer[]
|
|
|
|
*/
|
|
|
|
static function from_details_array(array $details_array) {
|
|
|
|
$servers = [];
|
|
|
|
|
|
|
|
foreach ($details_array as $details) {
|
|
|
|
$servers[] = CommunityServer::from_details($details);
|
|
|
|
}
|
|
|
|
|
|
|
|
return $servers;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param CommunityServer[] $servers
|
|
|
|
* @return CommunityRoom[]
|
|
|
|
*/
|
|
|
|
static function enumerate_rooms($servers) {
|
|
|
|
$rooms = [];
|
|
|
|
foreach ($servers as $server) {
|
|
|
|
$rooms[] = $server->rooms;
|
|
|
|
}
|
|
|
|
return array_merge([], ...$rooms);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Polls all servers for rooms.
|
|
|
|
* @param CommunityServer[] $servers Servers to poll.
|
|
|
|
* @return CommunityServer[] Reachable servers.
|
|
|
|
*/
|
|
|
|
public static function poll_reachable(array $servers): array {
|
|
|
|
$reachable_servers = [];
|
|
|
|
|
|
|
|
// Synchronous for-loop for now.
|
|
|
|
foreach ($servers as $server) {
|
|
|
|
if (!($server->fetch_rooms())) continue;
|
|
|
|
// Accept failures to fetch pubkey if already known.
|
|
|
|
if (!$server->fetch_or_has_pubkey()) continue;
|
|
|
|
$reachable_servers[] = $server;
|
|
|
|
}
|
|
|
|
|
|
|
|
return $reachable_servers;
|
|
|
|
}
|
|
|
|
|
|
|
|
function get_scheme() {
|
|
|
|
return parse_url($this->base_url, PHP_URL_SCHEME);
|
|
|
|
}
|
|
|
|
|
|
|
|
function downgrade_scheme() {
|
|
|
|
$base_url = $this->base_url;
|
|
|
|
$this->base_url = "http://" . $this->get_hostname();
|
|
|
|
log_info("Downgrading $base_url to HTTP.");
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function get_hostname() {
|
|
|
|
return url_get_base($this->base_url, include_scheme: false);
|
|
|
|
}
|
|
|
|
|
|
|
|
function get_base_url() {
|
|
|
|
return $this->base_url;
|
|
|
|
}
|
|
|
|
|
|
|
|
function get_pubkey() {
|
|
|
|
return $this->pubkey;
|
|
|
|
}
|
|
|
|
|
|
|
|
function set_pubkey($pubkey) {
|
|
|
|
if ($this->has_pubkey() && $this->pubkey != $pubkey) {
|
|
|
|
$base_url = $this->base_url;
|
|
|
|
throw new ValueError("Pubkey mismatch for $base_url");
|
|
|
|
}
|
|
|
|
|
|
|
|
$this->pubkey = $pubkey;
|
|
|
|
}
|
|
|
|
|
|
|
|
function set_pubkey_from_url($join_url) {
|
|
|
|
$url_components = parse_url($join_url);
|
|
|
|
parse_str($url_components['query'], $query_components);
|
|
|
|
$this->pubkey = $query_components['public_key'];
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Sets base url, pubkey, and room hint from join URL.
|
|
|
|
*/
|
|
|
|
function initialize_from_url($join_url) {
|
|
|
|
$this->base_url = url_get_base($join_url);
|
|
|
|
$this->set_pubkey_from_url($join_url);
|
|
|
|
$this->room_hints[] = url_get_token($join_url);
|
|
|
|
}
|
|
|
|
|
|
|
|
function has_pubkey() {
|
|
|
|
return $this->pubkey != "";
|
|
|
|
}
|
|
|
|
|
|
|
|
private function fetch_room_list() {
|
|
|
|
$base_url = $this->base_url;
|
|
|
|
list($rooms, $downgrade) = curl_get_contents_downgrade("$base_url/rooms?all=1");
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
|
|
|
private function fetch_room_hints() {
|
|
|
|
$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 self using SOGS API.
|
|
|
|
*
|
|
|
|
* @return bool True if successful, false otherwise.
|
|
|
|
*/
|
|
|
|
function fetch_rooms() {
|
|
|
|
$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)) {
|
|
|
|
log_warning("Reachability test failed by $base_url.");
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
|
|
|
function fetch_or_has_pubkey() {
|
|
|
|
$base_url = $this->base_url;
|
|
|
|
// Do not use 'or' here; I learned the hard way.
|
|
|
|
$result = $this->fetch_pubkey() || $this->has_pubkey();
|
|
|
|
if (!$result) log_warning("Failed to fetch pubkey for $base_url.");
|
|
|
|
return $result;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Attempt to fetch own public key by parsing SOGS HTML preview.
|
|
|
|
*
|
|
|
|
* @return bool True if successful, false otherwise.
|
|
|
|
*/
|
|
|
|
function fetch_pubkey() {
|
|
|
|
if (empty($this->rooms)) {
|
|
|
|
log_warning("Server has no rooms to poll for public key");
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
$has_pubkey = $this->has_pubkey();
|
|
|
|
|
|
|
|
$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 ? 1 : 5);
|
|
|
|
|
|
|
|
if (!$room_view) {
|
|
|
|
log_debug("Failed to fetch room preview from $preview_url.");
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
$links = parse_join_links($room_view);
|
|
|
|
|
|
|
|
if (!isset($links[0])) {
|
|
|
|
log_debug("Could not locate join link in preview at $preview_url.");
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
$this->set_pubkey_from_url($links[0]);
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
?>
|