Merge branch 'parallel-fetching' into dev

dev
gravel 1 year ago
commit d342d97c22
Signed by: gravel
GPG Key ID: C0538F3C906B308F

@ -16,14 +16,14 @@
include_once "$PROJECT_ROOT/php/utils/logging.php";
// Read the -v|--verbose option increasing logging verbosity to debug.
$options = getopt("v", ["verbose", "fast", "no-color"]);
$options = getopt("vn", ["verbose", "fast", "no-color", "dry-run"]);
if (isset($options["v"]) or isset($options["verbose"])) {
$LOGGING_VERBOSITY = LoggingVerbosity::Debug;
}
if (isset($options["fast"])) {
$FAST_FETCH_MODE = true;
}
$FAST_FETCH_MODE = (isset($options["fast"]));
$DO_DRY_RUN = (isset($options["n"]) || isset($options["dry-run"]));
if (isset($options["no-color"])) {
LoggingVerbosity::$showColor = false;
@ -32,9 +32,16 @@
// set timeout for file_get_contents()
ini_set('default_socket_timeout', 6); // in seconds, default is 60
// curl timeout is millisecons
$curl_connecttimeout_ms = 3000; // time for initiation of the connection
$curl_timeout_ms = 6000; // max time for whole connection (incl. transfer)
// curl timeout in milliseconds
// max time for initiation of the connection
$CURL_CONNECT_TIMEOUT_MS = 2000;
// max time for each connection (incl. transfer)
$CURL_TIMEOUT_MS = $FAST_FETCH_MODE ? 1500 : 3000;
// delay between retries in miliseconds
$CURL_RETRY_SLEEP = 2000;
// do not report warnings (timeouts, SSL/TLS errors)
error_reporting(E_ALL & ~E_WARNING);

@ -9,14 +9,6 @@
- `entr` to watch for file changes
- `xdg-open` link handler to invoke browser
- `libgd` (`php-gd`, `phpX.Y-gd`) for downsizing images
- patience
### Cloning or updating the repository
Ensure the consistency of the `languages` submodule by using the following options:
- `git clone --recurse-submodules <repository-url>`
- `git pull --recurse-submodules`
### Official repositories
@ -35,14 +27,14 @@ Run at least once: `make fetch` to query servers. This can take around 5 minutes
Run when developing: `make dev` to watch for changes & serve HTML locally in browser.
Symlink the commit hook provided in [`etc/hooks`](etc/hooks/) to `.git/hooks/<hook>` to run a full test cycle when committing to main.
Symlink the commit hooks provided in [`etc/hooks`](etc/hooks/) to `.git/hooks/<hook>` to run a full test cycle when committing to main.
See [`Makefile`](Makefile) for more details.
### Running your own copy
- point your webserver at the [`output`](output/) folder
- install and enable systemd services from the [`etc/systemd`](etc/systemd/) folder or an equivalent timer
- install and enable systemd services from the [`etc/systemd`](etc/systemd/) folder or an equivalent timer for periodic updates
## Code style guidelines

@ -18,6 +18,10 @@ all: fetch html
fetch:
/bin/php php/fetch-servers.php $(FLAGS)
# Fetch room listing without writing to disk.
fetch-dry:
/bin/php php/fetch-servers.php $(FLAGS) --dry-run
# Generate HTML from data.
html:
/bin/php php/generate-html.php $(FLAGS)

@ -0,0 +1,17 @@
#!/bin/sh
cat <<EOF
Dry-running fetch script
EOF
/bin/php php/fetch-servers.php --verbose --dry-run > log.txt 2>&1;
cat <<EOF
Grep of log for each known server URL:
EOF
for url in $(jq -r 'map(.base_url) | .[] | ltrimstr("http://") | ltrimstr("https://")' cache/rooms.json); do
echo "Results for $url:";
echo;
grep "$url" log.txt;
echo ">";
read -r;
done

@ -22,7 +22,7 @@
* 6. De-dupe servers based on pubkey
*/
function main() {
global $CACHE_ROOT, $ROOMS_FILE, $KNOWN_SERVERS, $KNOWN_PUBKEYS;
global $CACHE_ROOT, $ROOMS_FILE, $KNOWN_SERVERS, $KNOWN_PUBKEYS, $DO_DRY_RUN;
// Create default directories..
file_exists($CACHE_ROOT) or mkdir($CACHE_ROOT, 0700);
@ -61,7 +61,7 @@
);
// Output fetching results to file.
file_put_contents($ROOMS_FILE, json_encode($servers));
if (!$DO_DRY_RUN) file_put_contents($ROOMS_FILE, json_encode($servers));
}
/**

@ -0,0 +1,332 @@
<?php
require_once 'utils.php';
/**
* @template TReturn
*/
class FetchingCoroutine {
/**
* @var \Generator<int,CurlHandle,CurlHandle|false,TReturn> $generator
*/
private Generator $generator;
private bool $consumed = false;
/**
* @var \Closure():bool $response_filter
*/
private Closure $response_filter;
/**
* Creates a new Fetching Couroutine instance.
* @param \Generator<int,CurlHandle,CurlHandle|false,TReturn> $generator
* An instantiated generator yielding `string => CurlHandle` pairs.
*/
public function __construct(\Generator $generator) {
$this->generator = $generator;
$this->response_filter = function(CurlHandle $handle): bool {
$code = curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$url = curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);
log_debug("Got code $code for $url in default request arbitrator.");
return $code < 300 && $code != 0;
};
}
/**
* Create a new FetchingCoroutine to fetch the contents of a URL.
* @param string $url URL to fetch.
* @param array $curlopts Addition cURL options.
* @return \FetchingCoroutine<CurlHandle|false> Coroutine returning
*/
public static function from_url(string $url, array $curlopts = []): \FetchingCoroutine {
/**
* @var Generator<int,CurlHandle,CurlHandle|false,CurlHandle|false> $oneshot
*/
$oneshot = (function() use ($url, $curlopts) {
return yield make_curl_handle($url, $curlopts);
})();
return new FetchingCoroutine($oneshot);
}
/**
* Set callback deciding valid responses.
* @param Closure $response_filter Predicate on a processed CurlHandle.
* @return \FetchingCoroutine
*/
public function set_response_filter(Closure $response_filter): \FetchingCoroutine {
$this->response_filter = $response_filter;
return $this;
}
private function assert_not_consumed() {
if ($this->consumed) {
throw new Error("This FetchingCoroutine has been used up by a transforming call");
}
}
private function consume() {
$this->assert_not_consumed();
$this->consumed = true;
}
/**
* Modifies the current coroutine to halt on failed fetches. Consumes current coroutine.
* Resulting coroutine will not produce further fetches.
* @return \FetchingCoroutine<TReturn|null> New FetchingCoroutine instance.
*/
public function stop_on_failure(): \FetchingCoroutine {
$this->consume();
$haltable = function () {
foreach ($this->generator as $id => $handle) {
if (!(yield $id => $handle)) {
return;
}
}
return $this->generator->getReturn();
};
return $this->project_coroutine_parameters(new FetchingCoroutine($haltable()));
}
/**
* Modifies the current coroutine to retry fetches. Consumes current coroutine.
* @param int $retries Number of additional retries made for curl handles returned.
* @param bool $tallied_retries If true, the retry count applies to the whole coroutine.
* If false, each request is afforded the given retries.
* @return \FetchingCoroutine<TReturn> New FetchingCoroutine instance.
*/
public function retryable(int $retries, bool $tallied_retries = true): \FetchingCoroutine {
$this->consume();
$coroutine = $this;
$retryable = function () use ($retries, $coroutine, $tallied_retries) {
processing_new_coroutine:
while ($coroutine->valid()) {
$retries_current = $retries;
$id = $coroutine->current_key();
$handle = $coroutine->current_request();
$attempt_no = 1;
do {
if (!($attempt_handle = curl_copy_handle($handle))) {
log_error("Failed to clone cURL handle");
$coroutine->send(false);
goto processing_new_coroutine;
}
/** @var CurlHandle|false $response_handle */
$response_handle = yield $id => $attempt_handle;
$url = curl_getinfo($attempt_handle, CURLINFO_EFFECTIVE_URL);
if ($response_handle) {
$retcode = curl_getinfo($response_handle, CURLINFO_HTTP_CODE);
$url = curl_getinfo($response_handle, CURLINFO_EFFECTIVE_URL) ?? $url;
log_debug("Attempt #$attempt_no for $url returned code $retcode.");
$coroutine->send($response_handle);
goto processing_new_coroutine;
}
log_debug("Attempt #$attempt_no for $url failed or was rejected upstream.");
$attempt_no++;
} while ($retries_current-- > 0);
// failed to fetch handle
$coroutine->send(false);
// decrease the remaining retries
if ($tallied_retries) {
$retries = $retries_current;
}
}
return $coroutine->return_value();
};
return $this->project_coroutine_parameters(new FetchingCoroutine($retryable()));
}
/**
* Modifies the current coroutine to attempt HTTPS->HTTP downgrade after failure.
* Consumes current coroutine.
* @param bool $did_downgrade Set to true if a downgrade to HTTP has taken place.
* @return \FetchingCoroutine<TReturn> New FetchingCoroutine instance.
*/
public function downgradeable(mixed &$did_downgrade = NULL): \FetchingCoroutine {
$this->consume();
$coroutine = $this;
$has_downgrade_ref = func_num_args() >= 1;
if ($has_downgrade_ref) $did_downgrade = false;
$downgradeable = function () use ($coroutine, &$did_downgrade, $has_downgrade_ref) {
while ($coroutine->valid()) {
$id = $coroutine->current_key();
$handle = $coroutine->current_request();
$handle_downgraded = curl_handle_downgrade($handle);
// Try HTTPS first
if ($handle_downgraded) {
// Skip to next handle on success
if ($coroutine->send(yield $id => $handle)) {
continue;
}
if ($has_downgrade_ref) $did_downgrade = true;
$handle = $handle_downgraded;
}
// Use HTTP
$coroutine->send(yield $id => $handle);
}
return $coroutine->return_value();
};
return $this->project_coroutine_parameters(new FetchingCoroutine($downgradeable()));
}
/**
* Assign non-generator parameters to given FetchingCoroutine.
*/
private function project_coroutine_parameters(\FetchingCoroutine $coroutine): \FetchingCoroutine {
return $coroutine->set_response_filter($this->response_filter);
}
private function is_valid_response(CurlHandle $handle) {
$response_filter = $this->response_filter;
return $response_filter($handle);
}
/**
* Get the key of the handle yielded at this point in the coroutine, if applicable.
*/
public function current_key() {
return $this->generator->key();
}
/**
* Get the cURL handle yielded at this point in the coroutine, if applicable.
*/
public function current_request(): CurlHandle|null {
return $this->generator->current();
}
private function valid(): bool {
return $this->generator->valid();
}
/**
* Invoke the current coroutine. Consumes coroutine.
* @return \Generator<int,CurlHandle,CurlHandle|false,TReturn>
*/
public function run() {
$this->consume();
// passthrough
return yield from $this->generator;
}
/**
* Get the return value of the wrapped generator object once finished.
* @return TReturn
*/
public function return_value(): mixed {
return $this->generator->getReturn();
}
/**
* Step coroutine until next yield point or end.
* Coroutine must not be consumed by any transformations.
* @param CurlHandle|false $response
* Processed handle corresponding to yielded handle or false in case of failure.
*/
public function advance(CurlHandle|false $response_handle): bool {
$this->assert_not_consumed();
return $this->send($response_handle);
}
private function send(CurlHandle|false $handle): bool {
if ($handle && $this->is_valid_response($handle)) {
$this->generator->send($handle);
return true;
} else {
$this->generator->send(false);
return false;
}
}
}
class FetchingCoroutineRunner {
/**
* Collection of enroled transfers.
*/
private CurlMultiHandle $transfers;
/**
* Coroutines executed by runner.
* @var \FetchingCoroutine[] $coroutines
*/
private array $coroutines;
/**
* Create new FetchingCoroutineRunner instance with the given coroutines.
* @param \FetchingCoroutine[] $coroutines Coroutines to run in parallel.
*/
public function __construct(array $coroutines = []) {
$this->coroutines = $coroutines;
$this->initialize_coroutines();
}
/**
* Launches all coroutines in parallel.
* @return int CURLM_* status.
*/
public function run_all(): int {
do {
$curlm_status = curl_multi_exec($this->transfers, $curlm_active_transfer);
if ($curlm_active_transfer) {
// Block 1 second for pending transfers
curl_multi_select($this->transfers, timeout: 1.0);
// curl_multi_select($transfers, timeout: 6.0);
}
$this->process_curl_activity();
} while ($curlm_active_transfer && $curlm_status == CURLM_OK);
return $curlm_status;
}
/**
* Enrol initial transfers from all coroutines.
*/
private function initialize_coroutines() {
$this->transfers = curl_multi_init();
foreach ($this->coroutines as $id => $coroutine) {
$this->poll_coroutine_for_transfer($id);
}
}
/**
* Enrol latest transfer from coroutine with given id.
*/
private function poll_coroutine_for_transfer(int $id) {
$coroutine = $this->coroutines[$id];
$handle = $coroutine->current_request();
if (!$handle) return;
curl_setopt($handle, CURLOPT_PRIVATE, $id);
curl_multi_add_handle($this->transfers, $handle);
}
/**
* Respond to new activity on enroled transfers.
*/
private function process_curl_activity() {
while (false !== ($info = curl_multi_info_read($this->transfers))) {
if ($info['msg'] != CURLMSG_DONE) continue;
/**
* @var \CurlHandle $handle
*/
$handle = $info['handle'];
curl_multi_remove_handle($this->transfers, $handle);
$coroutine_id = curl_getinfo($handle, CURLINFO_PRIVATE);
if (!isset($this->coroutines[$coroutine_id])) {
throw new Error("Invalid coroutine ID: " + $coroutine_id);
}
$this->coroutines[$coroutine_id]->advance($handle);
$this->poll_coroutine_for_transfer($coroutine_id);
}
}
}
?>

@ -31,22 +31,15 @@
}
/**
* Fetch the icon of the given room and return its relative path.
* @param \CommunityRoom $room
* @param string $size Image dimensions.
* @return string Relative path or null if icon is absent.
* @return \Generator<int,CurlHandle,CurlHandle|false,void>
*/
function room_icon(\CommunityRoom $room, string $size): ?string {
list($width, $height) = explode("x", $size);
$width = intval($width);
$height = intval($height);
assert(!empty($width) && !empty($height));
function fetch_room_icon_coroutine(\CommunityRoom $room): Generator {
if (room_icon_safety($room) < 0) {
return null;
return;
}
$room_id = $room->get_room_identifier();
$icon_cached = room_icon_path($room_id);
$icon_resized = room_icon_path_resized($room_id, $size);
$icon_expired = file_exists($icon_cached) && filemtime($icon_cached) < strtotime("-1 day");
// Re-fetch icons periodically.
@ -56,7 +49,8 @@
return null;
}
log_debug("Fetching icon for $room_id.");
$icon = file_get_contents($icon_url);
$icon_response = yield from FetchingCoroutine::from_url($icon_url)->run();
$icon = $icon_response ? curl_multi_getcontent($icon_response) : null;
if (empty($icon)) {
log_info("$room_id returned an empty icon.");
}
@ -65,6 +59,33 @@
file_put_contents($icon_cached, $icon);
}
}
}
/**
* Fetch the icon of the given room and return its relative path.
* @param \CommunityRoom $room
* @param string $size Image dimensions.
* @return string Relative path or null if icon is absent.
*/
function room_icon(\CommunityRoom $room, string $size): ?string {
list($width, $height) = explode("x", $size);
$width = intval($width);
$height = intval($height);
assert(!empty($width) && !empty($height));
if (room_icon_safety($room) < 0) {
return null;
}
$room_id = $room->get_room_identifier();
$icon_cached = room_icon_path($room_id);
$icon_resized = room_icon_path_resized($room_id, $size);
$icon_expired = file_exists($icon_cached) && filemtime($icon_cached) < strtotime("-1 day");
if (!file_exists($icon_cached)) {
log_debug("Missing icon asset for $room_id");
return "";
}
if (!file_exists($icon_resized) || $icon_expired) {
$icon_cached_contents = file_get_contents($icon_cached);
if (empty($icon_cached_contents)) {

@ -19,11 +19,9 @@
/**
* Fetch QR invite of the given room and return its relative path.
* @param \CommunityRoom $room
* @return string
* @return \Generator<int,CurlHandle,CurlHandle|false,void>
*/
function room_qr_code($room): string {
function fetch_qr_code_coroutine(\CommunityRoom $room): Generator {
$room_id = $room->get_room_identifier();
$png_cached = room_qr_code_path($room_id);
$image_expired = file_exists($png_cached) &&
@ -32,7 +30,8 @@
return room_qr_code_path_relative($room_id);
}
log_debug("Fetching QR code for $room_id.");
$png = file_get_contents($room->get_invite_url());
$png_response = yield from FetchingCoroutine::from_url($room->get_invite_url())->run();
$png = $png_response ? curl_multi_getcontent($png_response) : null;
if (empty($png)) {
log_warning("$room_id returned an empty QR code.");
}
@ -40,6 +39,19 @@
if (!(file_exists($png_cached) && filesize($png_cached) > 0 && empty($png))) {
file_put_contents($png_cached, $png);
}
}
/**
* Fetch QR invite of the given room and return its relative path.
* @param \CommunityRoom $room
* @return string
*/
function room_qr_code(\CommunityRoom $room): string {
$room_id = $room->get_room_identifier();
if (!file_exists(room_qr_code_path($room_id))) {
log_warning("Missing QR code asset for $room_id.");
return "";
}
return room_qr_code_path_relative($room_id);
}

@ -1,8 +1,11 @@
<?php
include_once "$PROJECT_ROOT/languages/language_flags.php";
require_once "$PROJECT_ROOT/languages/language_flags.php";
include_once "$PROJECT_ROOT/php/servers/known-servers.php";
include_once 'tags.php';
require_once "$PROJECT_ROOT/php/servers/known-servers.php";
require_once 'tags.php';
require_once 'fetching-coroutines.php';
require_once 'room-icons.php';
require_once 'room-invites.php';
$MINUTE_SECONDS = 60;
$HOUR_SECONDS = 60 * $MINUTE_SECONDS;
@ -718,13 +721,24 @@
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;
$fetch_job = function() use ($server, &$reachable_servers): Generator {
if (!yield from $server->fetch_rooms_coroutine()) return;
if (!yield from $server->fetch_pubkey_coroutine()) return;
$reachable_servers[] = $server;
};
// passthrough hack
// all nested coroutines are allowed to do their own filtering
$coroutines[] = (new FetchingCoroutine($fetch_job()))
->set_response_filter(function(CurlHandle $handle) {
return true;
});
}
$runner = new FetchingCoroutineRunner($coroutines);
$runner->run_all();
return $reachable_servers;
}
@ -762,6 +776,22 @@
return $this->base_url;
}
/**
* Returns the URL to the endpoint listing this server's rooms.
*/
function get_rooms_api_url(): string {
$base_url = $this->base_url;
return "$base_url/rooms?all=1";
}
/**
* Returns the URL for the endpoint of the particular room.
*/
function get_room_api_url(string $token): string {
$base_url = $this->base_url;
return "$base_url/room/$token";
}
/**
* Returns the server's public key.
* @return string SOGS pubkey as used in the Session protocol.
@ -835,111 +865,176 @@
}
/**
* 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.
* @return \Generator<int,CurlHandle,CurlHandle|false,array|null>
*/
private function fetch_room_list(): array|bool {
private function fetch_room_list_coroutine(): Generator {
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;
/** @var CurlHandle|false $rooms_api_response */
$rooms_api_response =
yield from FetchingCoroutine
::from_url($this->get_rooms_api_url())
->retryable($FAST_FETCH_MODE ? 2 : 4)
->downgradeable($did_downgrade)
->run();
$rooms_raw = $rooms_api_response ? curl_multi_getcontent($rooms_api_response) : null;
if (!$rooms_raw) {
log_info("Failed fetching /rooms for $base_url.");
return null;
}
if ($downgrade) $this->downgrade_scheme();
$room_data = json_decode($rooms, true);
if ($did_downgrade) $this->downgrade_scheme();
$room_data = json_decode($rooms_raw, true);
if ($room_data == null) {
log_info("Failed parsing /rooms.");
return false;
log_info("Failed parsing /rooms for $base_url.");
return null;
}
log_debug("Fetched /rooms successfully");
log_debug("Fetched /rooms successfully for $base_url");
// log_value($room_data);
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.
* @return Generator<int,CurlHandle,CurlHandle|false,array|null>
*/
private function fetch_room_hints(): ?array {
private function fetch_room_hints_coroutine(): Generator {
global $FAST_FETCH_MODE;
$base_url = $this->base_url;
$rooms = [];
if (empty($this->room_hints)) {
log_debug("No room hints to scan for $base_url.");
return null;
}
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);
log_debug("Testing room /$token at $base_url.");
// Note: This fetches room hints sequentially per each server
// Would need to allow yielding handle arrays
// More than good enough for now
$room_api_response = yield from FetchingCoroutine
::from_url($this->get_room_api_url($token))
// Afford more attempts thanks to reachability test
// TODO Move retryability to outer invocation
->retryable(retries: $FAST_FETCH_MODE ? 2 : 4)
->downgradeable($did_downgrade)
->run();
$room_raw = $room_api_response ? curl_multi_getcontent($room_api_response) : null;
if (!$room_raw) {
log_info("Room /$token not reachable.");
log_info("Room /$token not reachable at $base_url.");
continue;
}
if ($downgrade) $this->downgrade_scheme();
if ($did_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.");
log_info("Room /$token not parsable at $base_url.");
break;
} else {
log_debug("Room /$token not parsable, continuing.");
log_debug("Room /$token not parsable at $base_url, continuing.");
continue;
}
}
$rooms[] = $room_data;
}
// Mark no rooms as failure.
if (count($rooms) == 0) {
log_debug("No room hints were valid.");
if (empty($rooms)) {
log_debug("No room hints were valid at $base_url.");
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 {
function check_reachability_coroutine() {
global $FAST_FETCH_MODE;
$base_url = $this->base_url;
log_info("Checking reachability for $base_url first...");
/** @var CurlHandle|false $response_handle */
$response_handle =
yield from FetchingCoroutine
::from_url($base_url, [CURLOPT_NOBODY => true])
->set_response_filter(function (CurlHandle $handle) {
$code = curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$url = curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);
log_debug("Got $code for $url in custom filter.");
return $code != 0;
})
->retryable(retries: $FAST_FETCH_MODE ? 2 : 4)
->downgradeable($did_downgrade)
->run();
if (!$response_handle) {
log_warning("Reachability test failed by $base_url.");
return false;
}
if ($did_downgrade) $this->downgrade_scheme();
return true;
}
/**
* @return \Generator<int,CurlHandle,CurlHandle|false,bool>
*/
function fetch_rooms_coroutine(): Generator {
$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...");
if (!url_is_reachable($base_url, retries: $FAST_FETCH_MODE ? 1 : 4)) {
log_warning("Reachability test failed by $base_url.");
if (!yield from $this->check_reachability_coroutine()) {
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) {
/** @var array|null $room_data */
$room_data =
(yield from $this->fetch_room_list_coroutine()) ??
(yield from $this->fetch_room_hints_coroutine());
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.
* @return \Generator<int,CurlHandle,CurlHandle|false,bool>
*/
function fetch_pubkey() {
function fetch_pubkey_coroutine(): Generator {
global $FAST_FETCH_MODE;
$base_url = $this->base_url;
if (empty($this->rooms)) {
log_warning("Server has no rooms to poll for public key");
log_warning("Server $base_url has no rooms to poll for public key");
return false;
}
@ -952,7 +1047,14 @@
$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);
$room_view_response = yield from FetchingCoroutine
::from_url($preview_url)
->retryable($has_pubkey || $FAST_FETCH_MODE ? 1 : 5)
->run();
$room_view = $room_view_response
? curl_multi_getcontent($room_view_response)
: null;
if (!$room_view) {
log_debug("Failed to fetch room preview from $preview_url.");
@ -983,6 +1085,23 @@
return true;
}
/**
* @param \CommunityServer $servers
*/
public static function fetch_assets(array $servers) {
// Sequential in each server, see note in fetch_room_hints_coroutine()
$coroutines = [];
foreach (CommunityServer::enumerate_rooms($servers) as $room) {
$coroutines[] = new FetchingCoroutine((function() use ($room) {
yield from fetch_qr_code_coroutine($room);
yield from fetch_room_icon_coroutine($room);
})());
}
(new FetchingCoroutineRunner($coroutines))->run_all();
}
/**
* Checks whether this server belongs to Session / OPTF.
*/

@ -122,30 +122,14 @@
* to an unreachable host.
*/
function curl_get_response(string $url, int $retries, $stop_on_codes = [404], $curlopts = []) {
global $FAST_FETCH_MODE;
global $CURL_RETRY_SLEEP;
// use separate timeouts to reliably get data from Chinese server with repeated tries
$connecttimeout = 2; // wait at most X seconds to connect
$timeout = $FAST_FETCH_MODE ? 1.5 : 3; // can't take longer than X seconds for the whole curl process
$sleep = 2; // sleep between tries in seconds
// takes at most ($timeout + $sleep) * $retries seconds
$contents = false;
$retcode = -1;
for ($counter = 1; $counter <= $retries; $counter++) {
$curl = curl_init($url);
// curl_setopt($curl, CURLOPT_VERBOSE, true);
curl_setopt($curl, CURLOPT_AUTOREFERER, true);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, $connecttimeout);
curl_setopt($curl, CURLOPT_TIMEOUT, $timeout);
foreach ($curlopts as $opt => $val) curl_setopt($curl, $opt, $val);
$curl = make_curl_handle($url, $curlopts);
$contents = curl_exec($curl);
$retcode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
@ -153,12 +137,47 @@
log_debug("Attempt #" . $counter . " for " . $url . " returned code " . $retcode . ".");
if ($contents != null || $retcode == 200 || in_array($retcode, $stop_on_codes)) break;
sleep($sleep);
sleep($CURL_RETRY_SLEEP / 1E3);
}
return [$retcode, $retcode == 200 ? $contents : false];
}
function make_curl_handle(string $url, $curlopts = []) {
global $CURL_CONNECT_TIMEOUT_MS, $CURL_TIMEOUT_MS;
$curl = curl_init($url);
// curl_setopt($curl, CURLOPT_VERBOSE, true);
curl_setopt($curl, CURLOPT_AUTOREFERER, true);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, $CURL_CONNECT_TIMEOUT_MS / 1E3);
curl_setopt($curl, CURLOPT_TIMEOUT, $CURL_TIMEOUT_MS / 1E3);
curl_setopt_array($curl, $curlopts);
foreach ($curlopts as $opt => $val) curl_setopt($curl, $opt, $val);
return $curl;
}
/**
* Downgrades a HTTPS-facing cURL handle to HTTP.
* @return CurlHandle|null Handle copy if can downgrade, or null if not applicable.
*/
function curl_handle_downgrade(CurlHandle $handle): CurlHandle|null {
$url = curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);
$scheme = parse_url($url, PHP_URL_SCHEME);
if ($scheme != 'https') return null;
$handle_copy = curl_copy_handle($handle);
$url = 'http' . substr($url, strlen('https'));
curl_setopt($handle_copy, CURLOPT_URL, $url);
return $handle_copy;
}
/**
* Returns the base path of a URL.
* @param string $url The URL to slice the path from.

@ -1,5 +1,5 @@
<?php
include "$PROJECT_ROOT/php/utils/room-icons.php";
include_once "$PROJECT_ROOT/php/utils/room-icons.php";
/**
* @var \CommunityRoom[] $rooms

@ -13,6 +13,9 @@
// Re-build server instances from cached server data.
$servers = CommunityServer::from_details_array($server_data);
// Fetch all server assets ahead of time.
CommunityServer::fetch_assets($servers);
// List all rooms from the cached servers.
$rooms = CommunityServer::enumerate_rooms($servers);

Loading…
Cancel
Save