Merge branch 'dev'

dev
gravel 2 years ago
commit f071b3669f
Signed by: gravel
GPG Key ID: C0538F3C906B308F

3
.gitignore vendored

@ -7,6 +7,9 @@ output/qr-codes
# Served icons
output/icons
# Served listings
listings/lp-output
# Server-side cache
cache

@ -11,30 +11,13 @@
$ROOM_ICONS="$DOCUMENT_ROOT/icons";
$ROOM_ICONS_RELATIVE="icons";
$REPOSITORY_CANONICAL_URL="https://codeberg.org/gravel/sessioncommunities.online";
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"]);
if (isset($options["v"]) or isset($options["verbose"])) {
$LOGGING_VERBOSITY = LoggingVerbosity::Debug;
}
if (isset($options["fast"])) {
$FAST_FETCH_MODE = true;
}
$LISTING_PROVIDER_ROOT="$PROJECT_ROOT/listings";
$LISTINGS_INI="$LISTING_PROVIDER_ROOT/listings.ini";
$LISTING_PROVIDER_OUTPUT="$LISTING_PROVIDER_ROOT/lp-output";
$LISTING_PROVIDER_LISTING_SUMMARY="$LISTING_PROVIDER_OUTPUT/listings";
$LISTING_PROVIDER_LISTINGS="$LISTING_PROVIDER_OUTPUT/listing";
if (isset($options["no-color"])) {
LoggingVerbosity::$showColor = false;
}
// 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)
$REPOSITORY_CANONICAL_URL="https://codeberg.org/gravel/sessioncommunities.online";
// 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

@ -12,16 +12,28 @@ list:
## Using make dependencies is duplicating behaviour but reads better.
# /bin/php php/update-listing.php
# Refresh listing and generate HTML.
all: fetch html
sco: fetch html
# Refresh listing, generate HTML and update listing provider.
all:
/bin/php php/update-listing.php
# Fetch room listing.
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)
# Generate listing provider endpoints from data.
listing:
/bin/php php/generate-listings.php $(FLAGS)
# Serve a local copy which responds to file changes.
dev: FLAGS = --verbose
dev: open
@ -47,7 +59,7 @@ lan-server:
# Open locally served page in browser.
open:
xdg-open "http://localhost:$(PORT)" >/dev/null 2>/dev/null & disown
nohup xdg-open "http://localhost:$(PORT)" >/dev/null 2>/dev/null
# Update HTML on file change.
watchdog:

@ -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

@ -0,0 +1,106 @@
[regional]
name=Regional Communities
rating=safe
rooms[]=brasil+118d
rooms[]=deutsch+118d
rooms[]=espanol+118d
rooms[]=francais+118d
rooms[]=iom+118d
rooms[]=italiano+118d
rooms[]=nl+118d
rooms[]=persian+118d
rooms[]=portugues+118d
rooms[]=uk+118d
rooms[]=russian+118d
rooms[]=bangladesh+13f6
rooms[]=czsk+13f6
rooms[]=india+13f6
rooms[]=indonesia+13f6
rooms[]=korea+13f6
rooms[]=poland+13f6
rooms[]=scandinavia+2812
rooms[]=fi+2812
rooms[]=br+2b92
rooms[]=germanpoliticssocietyphilosophy+8183
sogs[]=fe93941471c07f294255391dba92ae3cf356efc4fdd287d8ba1ffef096dbaf56
[tech]
name=Tech Communities
rating=safe
rooms[]=android+118d
rooms[]=ai+118d
rooms[]=gaming+118d
rooms[]=im+118d
rooms[]=linux+118d
rooms[]=webdev+118d
rooms[]=programming+13f6
rooms[]=tech+2054
rooms[]=ev-en+2812
rooms[]=pv-en+2812
rooms[]=gee+2b92
rooms[]=mathnodes-dvpn-oxen-dero+8585
rooms[]=plex+c01b
[china]
name=Chinese-language Communities
rating=unknown
sogs[]=45674f4135e2dd929279a72e1e73957eed741c7bec00c87b1797ad52c235ea36
sogs[]=7f55510a392324988041e7a97191f618a32dc05f2f6343de917fcfe1cb424e51
[powerusers]
name=Communities for Powerusers
rating=safe
rooms[]=snops+118d
rooms[]=sogops+118d
rooms[]=indexloki+2cbd
rooms[]=SNappsDevs+2cbd
rooms[]=sessiondev+e56f
[misc]
name=Miscellanous
rating=unknown
rooms[]=health+118d
rooms[]=music+118d
rooms[]=modernsurvival+118d
rooms[]=philosophy+118d
rooms[]=pol+2054
rooms[]=biz+2b92
rooms[]=guns+2b92
rooms[]=hall+7242
rooms[]=camping+c01b
rooms[]=offtopic+e56f
rooms[]=AISFW+fc30
[oxen]
name=Official Communities
rating=safe
sogs[]=a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238
[cryptocurrency]
name=Cryptocurrency
rooms[]=bitcoinaustralia+4bec
rooms[]=moneroaustralia+4bec
rooms[]=crypto+a03c
[privsec]
name=Privacy & Security
rating=safe
rooms[]=im+118d
rooms[]=privacy+118d
rooms[]=security+118d
rooms[]=degoogle+48e9
rooms[]=privacyaustralia+4bec
[casual]
name=Casual Communities
rating=safe
rooms[]=general-chat+8c12
rooms[]=midnight-madness+9be4
[nsfw]
name=18+ Communities
rating=nsfw
rooms[]=womanbodybeauty+13f6
rooms[]=piao+2cd5
rooms[]=AINSFW+fc30

@ -2,6 +2,7 @@
// requires php-curl
require_once 'getenv.php';
require_once 'utils/getopt.php';
require_once 'utils/utils.php';
require_once 'servers/known-servers.php';
require_once 'utils/servers-rooms.php';
@ -22,7 +23,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 +62,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));
}
/**

@ -2,6 +2,7 @@
// Perform static site generation.
require_once "getenv.php";
require_once "utils/getopt.php";
// https://stackoverflow.com/a/17161106
function rglob($pattern, $flags = 0) {

@ -0,0 +1,116 @@
<?php
require_once "getenv.php";
require_once "$PROJECT_ROOT/php/utils/servers-rooms.php";
require_once "$PROJECT_ROOT/php/utils/logging.php";
class CommunityListing implements JsonSerializable {
public readonly string $name;
public readonly string $rating;
public readonly array $rooms;
/**
* @param \CommunityRoom[] $rooms
*/
public function __construct(string $name, ?string $rating, array $rooms) {
$this->name = $name;
$this->rating = $rating ?? "unknown";
$this->rooms = $rooms;
}
public function jsonSerialize(): mixed {
// TODO: Careful serialization
$details = get_object_vars($this);
$details['rooms'] = array_map(function(\CommunityRoom $room){
return $room->to_listing_data();
}, $this->rooms);
return $details;
}
public function to_summary(): array {
return array(
'name' => $this->name,
'rating' => $this->rating,
'rooms' => count($this->rooms)
);
}
}
/**
* @return \CommunityListing[]
*/
function resolve_listings_config(): array {
global $LISTINGS_INI, $ROOMS_FILE;
$listings_raw = parse_ini_file($LISTINGS_INI, process_sections: true);
$servers_raw = file_get_contents($ROOMS_FILE);
$server_data = json_decode($servers_raw, true);
$servers = CommunityServer::from_details_array($server_data);
$rooms_by_id = [];
foreach (CommunityServer::enumerate_rooms($servers) as $room) {
$rooms_by_id[$room->get_room_identifier()] = $room;
}
$sogs_by_pubkey = [];
foreach ($servers as $server) {
$sogs_by_pubkey[$server->get_pubkey()] = $server;
}
$listings = [];
foreach ($listings_raw as $id => $listing_props) {
$rooms = [];
if (isset($listing_props['rooms'])) {
foreach ($listing_props['rooms'] as $room_id) {
if (isset($rooms_by_id[$room_id])) {
$rooms[] = $rooms_by_id[$room_id];
} else {
log_warning("Could not find room $room_id from listing $id.");
}
}
}
if (isset($listing_props['sogs'])) {
foreach ($listing_props['sogs'] as $public_key) {
if (isset($sogs_by_pubkey[$public_key])) {
/** @var \CommunityServer $sogs */
$sogs = $sogs_by_pubkey[$public_key];
array_push($rooms, ...$sogs->rooms);
} else {
log_warning("Could not find sogs $public_key from listing $id.");
}
}
}
$listings[$id] = new CommunityListing(
$listing_props['name'],
$listing_props['rating'],
$rooms
);
}
return $listings;
}
function generate_listings() {
global $LISTING_PROVIDER_LISTING_SUMMARY, $LISTING_PROVIDER_LISTINGS;
log_info("Generating listings...");
$listings_resolved = resolve_listings_config();
log_value($listings_resolved);
$summaries = array_map(function(\CommunityListing $listing) {
return $listing->to_summary();
}, $listings_resolved);
file_put_contents($LISTING_PROVIDER_LISTING_SUMMARY, json_encode($summaries));
foreach ($listings_resolved as $id => $listing) {
file_put_contents(
"$LISTING_PROVIDER_LISTINGS/$id",
json_encode($listing)
);
}
$listings_count = count($listings_resolved);
log_info("Generated $listings_count listings.");
}
file_exists($LISTING_PROVIDER_LISTINGS) or mkdir($LISTING_PROVIDER_LISTINGS, 0755, true);
$options = getopt("v", ["verbose"]);
if (isset($options["v"]) or isset($options["verbose"])) {
$LOGGING_VERBOSITY = LoggingVerbosity::Debug;
}
generate_listings();
?>

@ -1,4 +1,5 @@
<?php
require_once 'fetch-servers.php';
require_once 'generate-html.php';
require_once 'generate-listings.php';
?>

@ -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);
}
}
}
?>

@ -0,0 +1,31 @@
<?php
include_once "$PROJECT_ROOT/php/utils/logging.php";
// Read the -v|--verbose option increasing logging verbosity to debug.
$options = getopt("vn", ["verbose", "fast", "no-color", "dry-run"]);
if (isset($options["v"]) or isset($options["verbose"])) {
$LOGGING_VERBOSITY = LoggingVerbosity::Debug;
}
$FAST_FETCH_MODE = (isset($options["fast"]));
$DO_DRY_RUN = (isset($options["n"]) || isset($options["dry-run"]));
if (isset($options["no-color"])) {
LoggingVerbosity::$showColor = false;
}
// set timeout for file_get_contents()
ini_set('default_socket_timeout', 6); // in seconds, default is 60
// 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 ? 3000 : 9000;
// delay between retries in miliseconds
$CURL_RETRY_SLEEP = 2000;
?>

@ -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;
@ -113,6 +116,25 @@
return $details;
}
/**
* Return information for JSON serialization in listing.
*/
function to_listing_data(): array {
$details = get_object_vars($this);
unset($details['server']);
$details['join_url'] = $this->get_join_url();
unset($details['tags']);
unset($details['language_flag']);
return array(
"api" => $details,
"extras" => array(
"join_url" => $this->get_join_url(),
"language_flag" => $this->language_flag,
"tags" => $this->get_raw_tags()
)
);
}
/**
* Create a CommunityRoom instance from loaded data.
* @param CommunityServer $server
@ -304,6 +326,15 @@
return count($this->get_staff()) < $minimal_staff_count;
}
/**
* @return string[]
*/
function get_raw_tags(): array {
return array_map(function(\CommunityTag $tag) {
return $tag->text;
}, $this->tags);
}
/**
* Return the tags associated with this room.
* @return \CommunityTag[] Tags as string array.
@ -598,7 +629,10 @@
* Return information for JSON serialization.
*/
function jsonSerialize(): array {
return get_object_vars($this);
$details = get_object_vars($this);
unset($details['room_hints']);
unset($details['merge_error']);
return $details;
}
/**
@ -715,13 +749,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;
}
@ -759,6 +804,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.
@ -832,111 +893,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;
}
@ -949,7 +1075,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.");
@ -980,6 +1113,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.
*/

@ -35,128 +35,39 @@
: $str;
}
/**
* Check whether URL is reachable, downgrading to HTTP if needed.
* @param string $url URL to check.
* @param int $retries [optional] Number of connection attempts.
* @return bool Whether or not the server responded with a non-5XX HTTP code.
*/
function url_is_reachable(string $url, int $retries = 4): bool {
$retcode = curl_get_response_downgrade(
$url, retries: $retries,
curlopts: [CURLOPT_NOBODY => true], stop_on_codes: [404]
)[0];
return $retcode != 0 && floor($retcode / 100) != 5 ;
}
function make_curl_handle(string $url, $curlopts = []) {
global $CURL_CONNECT_TIMEOUT_MS, $CURL_TIMEOUT_MS;
$curl = curl_init($url);
/**
* Fetch URL repeatedly to obtain contents, downgrading to HTTP if needed.
* @param string $url URL to fetch.
* @param int $retries [optional] Number of connection attempts.
* @param int[] $stop_on_codes [optional]
* If one of these HTTP codes is encountered, fetching stops early.
* @return array Fetched contents (if applicable),
* and whether a downgrade to HTTP took place.
* A code of 0 corresponds to an unreachable host.
*/
function curl_get_contents_downgrade(string $url, int $retries = 4, $stop_on_codes = [404]) {
list($retcode, $content, $downgrade) = curl_get_response_downgrade($url, $retries, $stop_on_codes);
return [$retcode == 200 ? $content : null, $downgrade];
}
// curl_setopt($curl, CURLOPT_VERBOSE, true);
/**
* Fetch URL repeatedly to obtain URL contents.
* @param string $url URL to fetch.
* @param int $retries [optional] Number of connection attempts.
* @param int[] $stop_on_codes [optional]
* If one of these HTTP codes is encountered, fetching stops early.
* @return ?string Fetched contents, if applicable.
*/
function curl_get_contents(string $url, int $retries = 4, $stop_on_codes = [404]): ?string {
return curl_get_response($url, retries: $retries, stop_on_codes: $stop_on_codes)[1];
}
curl_setopt($curl, CURLOPT_AUTOREFERER, true);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
/**
* Fetch URL repeatedly, downgrading to HTTP if needed.
* @param string $url URL to fetch.
* @param int $retries [optional] Number of connection attempts.
* @param int[] $stop_on_codes [optional]
* If one of these HTTP codes is encountered, fetching stops early.
* @param int[] $curlopts Associative array of options for `curl_setopt`.
* @return array Return code, fetched contents (if applicable),
* and whether a downgrade to HTTP took place.
* A code of 0 corresponds to an unreachable host.
*/
function curl_get_response_downgrade(
string $url, $retries = 4, $stop_on_codes = [404], $curlopts = []
) {
$scheme = parse_url($url, PHP_URL_SCHEME);
if ($scheme == "https") {
list($retcode, $content) = curl_get_response($url, floor($retries / 2), $stop_on_codes, $curlopts);
if ($retcode == 200) return [$retcode, $content, false];
log_debug("Downgrading to HTTP");
list($retcode, $content) = curl_get_response(
substr_replace($url, "http:", 0, strlen("https:")),
ceil($retries / 2),
$stop_on_codes, $curlopts
);
return [$retcode, $content, true];
} else {
list($retcode, $content) = curl_get_response($url, $retries, $stop_on_codes, $curlopts);
return [$retcode, $content, false];
}
}
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, $CURL_CONNECT_TIMEOUT_MS / 1E3);
curl_setopt($curl, CURLOPT_TIMEOUT, $CURL_TIMEOUT_MS / 1E3);
// Low default retries value so this doesn't run for 30 minutes
// FIXME: Does not seem to handle 308's, behaviour not transparent.
// TODO: Parallelize & use in CommunityServer::poll_reachable()
/**
* Fetch URL repeatedly to obtain a valid response.
* @param string $url URL to fetch.
* @param int $retries Number of connection attempts.
* @param string[] $stop_on_codes [optional]
* If one of these HTTP codes is encountered, fetching stops early.
* @param int[] $curlopts Associative array of options for `curl_setopt`.
* @return array Return code and fetched contents, if applicable. A code of 0 corresponds
* to an unreachable host.
*/
function curl_get_response(string $url, int $retries, $stop_on_codes = [404], $curlopts = []) {
global $FAST_FETCH_MODE;
// 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_setopt_array($curl, $curlopts);
$contents = curl_exec($curl);
$retcode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
foreach ($curlopts as $opt => $val) curl_setopt($curl, $opt, $val);
curl_close($curl);
log_debug("Attempt #" . $counter . " for " . $url . " returned code " . $retcode . ".");
if ($contents != null || $retcode == 200 || in_array($retcode, $stop_on_codes)) break;
sleep($sleep);
}
return $curl;
}
return [$retcode, $retcode == 200 ? $contents : false];
/**
* 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;
}
/**

@ -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

@ -3,6 +3,7 @@
require_once "+getenv.php";
require_once "$PROJECT_ROOT/php/utils/utils.php";
require_once "$PROJECT_ROOT/php/utils/servers-rooms.php";
require_once "$PROJECT_ROOT/php/utils/getopt.php";
// Read the server data from disk.
$servers_raw = file_get_contents($ROOMS_FILE);
@ -13,6 +14,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