Reorganizing & codestyle compliance

dev
gravel 1 year ago
parent 6fc91007a4
commit ceea186ded
Signed by: gravel
GPG Key ID: C0538F3C906B308F

@ -0,0 +1,16 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = tab
tab_width = 4
trim_trailing_whitespace = true
[Makefile]
indent_style = tab
[*.md]
indent_style = space
indent_size = 2

@ -33,8 +33,9 @@ Recommended:
- Add the [default include paths](.phpenv) (`.`, `php`) to your PHP intellisense.
- Symlink the commit hooks provided in [`etc/hooks`](etc/hooks/) to `.git/hooks/<hook>` to run a full test cycle when committing to main.
- Get [EditorConfig](https://editorconfig.org/#pre-installed) for your IDE if not pre-installed.
- Symlink the commit hooks provided in [`etc/hooks`](etc/hooks/) to `.git/hooks/<hook>` to run a full test cycle when committing to main.
### Running your own copy
@ -55,6 +56,33 @@ Recommended:
**Comments and documentation**: [PHPDoc](https://en.wikipedia.org/wiki/PHPDoc)
**Whitespace**:
- The following exceptions apply to PHP expressions embedded within HTML:
- Flow control statements within HTML (`<?php if ($condition): >`)
shall have zero indentation, akin to C macros or sh heredocs.
- Self-contained PHP `include` and variable shorthand statements
in multi-line HTML child node position shall be followed by an extra line:
```php
<body>
<div>
<?php if ($bowl->has_food()): >
<?= $bowl->describe_food() ?>
<?php else: >
<?php include 'bowl-empty.php'; >
<?php endif; >
</div>
</body>
```
**Other**:
- Strings shall be surrounded by single quotes `''`
where no variable expansion is taking place
### HTML & CSS
**Identifier casing**: `kebab-case`, legacy `snake_case`

@ -9,9 +9,9 @@ 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;
echo "Results for $url:";
echo;
grep "$url" log.txt;
echo ">";
read -r;
done

@ -3,8 +3,8 @@
# add_header Cache-Control "max-age=3600";
}
location /qr-codes {
# while an underestimate,
# while an underestimate,
# this prevents us from tracking users across visits
expires 60m;
# add_header Cache-Control "max-age=3600";
}
}

@ -15,17 +15,17 @@ Communities are each identified by a short string. You can copy this identifier
Language labels for communities are grouped by server for easy navigation:
```php
// https://open.getsession.org/
$server_languages[] = array(
// https://open.getsession.org/
$server_languages[] = array(
"crypto+a03c" => "๐Ÿ‡ฌ๐Ÿ‡ง",
"lokinet+a03c" => "๐Ÿ‡ฌ๐Ÿ‡ง",
"oxen+a03c" => "๐Ÿ‡ฌ๐Ÿ‡ง",
"session-farsi+a03c" => "๐Ÿ‡ฎ๐Ÿ‡ท",
"session-updates+a03c" => "๐Ÿ‡ฌ๐Ÿ‡ง",
"session+a03c" => "๐Ÿ‡ฌ๐Ÿ‡ง"
);
);
```
To label a Community, you would search for the `xxxx` suffix (in this case `a03c`) in the [flags file](./language_flags.php) and add the appropriate entry.
To label a Community, you would search for the `xxxx` suffix (in this case `a03c`) in the [flags file](./language-flags.php) and add the appropriate entry.
If you cannot any labels for this code, copy an existing block from another server & replace the entries with your own. If you have trouble entering language flags on your keyboard, you may find it helpful to copy them from <https://www.alt-codes.net/flags>.

@ -346,8 +346,8 @@ function hideBadCommunities() {
for (const category of ['tests', 'offensive']) {
numberOfHiddenCommunities +=
filteredCommunities[category]
.map(hideCommunity)
.reduce((a, b) => a + b);
.map(hideCommunity)
.reduce((a, b) => a + b);
}
const summary = dom.servers_hidden();
@ -564,4 +564,4 @@ function sortTable(column) {
// `html.js` selector for styling purposes
document.documentElement.classList.add("js");
document.addEventListener('DOMContentLoaded', () => onLoad());
document.addEventListener('DOMContentLoaded', () => onLoad());

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path style="fill: grey;" d="M48 32C21.5 32 0 53.5 0 80v96c0 26.5 21.5 48 48 48h96c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48H48zm80 64v64H64V96h64zM48 288c-26.5 0-48 21.5-48 48v96c0 26.5 21.5 48 48 48h96c26.5 0 48-21.5 48-48V336c0-26.5-21.5-48-48-48H48zm80 64v64H64V352h64zM256 80v96c0 26.5 21.5 48 48 48h96c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48H304c-26.5 0-48 21.5-48 48zm64 16h64v64H320V96zm32 352v32h32V448H352zm96 0H416v32h32V448zM416 288v32H352V288H256v96 96h64V384h32v32h96V352 320 288H416z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path style="fill: grey;" d="M48 32C21.5 32 0 53.5 0 80v96c0 26.5 21.5 48 48 48h96c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48H48zm80 64v64H64V96h64zM48 288c-26.5 0-48 21.5-48 48v96c0 26.5 21.5 48 48 48h96c26.5 0 48-21.5 48-48V336c0-26.5-21.5-48-48-48H48zm80 64v64H64V352h64zM256 80v96c0 26.5 21.5 48 48 48h96c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48H304c-26.5 0-48 21.5-48 48zm64 16h64v64H320V96zm32 352v32h32V448H352zm96 0H416v32h32V448zM416 288v32H352V288H256v96 96h64V384h32v32h96V352 320 288H416z"/></svg>

Before

Width:  |  Height:  |  Size: 745 B

After

Width:  |  Height:  |  Size: 746 B

@ -0,0 +1,127 @@
<?php
require_once 'servers/known-servers.php';
/**
* Return local path to room icon.
* @param string $room_id Id of room to locate icon for.
*/
function room_icon_path(string $room_id): string {
global $ROOM_ICONS_CACHE;
return "$ROOM_ICONS_CACHE/$room_id";
}
/**
* Return local path to resized room icon.
* @param string $room_id Id of room to locate icon for.
* @param string $size Image dimensions.
*/
function room_icon_path_resized(string $room_id, string $size): string {
global $ROOM_ICONS;
return "$ROOM_ICONS/$room_id-$size";
}
/**
* Return server path to room icon.
* @param string $room_id Id of room to locate icon for.
* @param string $size Image dimensions.
*/
function room_icon_path_relative(string $room_id, string $size): string {
global $ROOM_ICONS_RELATIVE;
return "$ROOM_ICONS_RELATIVE/$room_id-$size";
}
/**
* @return \Generator<int,CurlHandle,CurlHandle|false,void>
*/
function fetch_room_icon_coroutine(\CommunityRoom $room): Generator {
if (room_icon_safety($room) < 0) {
return;
}
$room_id = $room->get_room_identifier();
$icon_cached = room_icon_path($room_id);
$icon_expired = file_exists($icon_cached) && filemtime($icon_cached) < strtotime("-1 day");
// Re-fetch icons periodically.
if (!file_exists($icon_cached) || $icon_expired) {
$icon_url = $room->get_icon_url();
if (empty($icon_url)) {
return null;
}
log_debug("Fetching icon for $room_id.");
$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.");
}
// Never overwrite with an empty file.
if (!(file_exists($icon_cached) && filesize($icon_cached) > 0 && empty($icon))) {
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)) {
file_put_contents($icon_resized, "");
return "";
}
// Resize image
$gd_image = imagecreatefromstring($icon_cached_contents);
$gd_resized = imagescale($gd_image, $width, $height);
if (!imagewebp($gd_resized, $icon_resized)) {
log_info("Converting image for $room_id to $size failed");
}
}
if (filesize($icon_resized) == 0) {
return "";
}
return room_icon_path_relative($room_id, $size);
}
function room_icon_safety(\CommunityRoom $room): int {
global $ICON_ALLOWLIST, $ICON_BLOCKLIST;
if (in_array($room->get_room_identifier(), $ICON_BLOCKLIST)) {
return -1;
}
if (in_array($room->server->get_hostname(), $ICON_ALLOWLIST)) {
return 1;
}
if (in_array($room->server->get_hostname(), $ICON_BLOCKLIST)) {
return -1;
}
if ($room->has_nsfw_keywords()) {
return -1;
}
return 0;
}
file_exists($ROOM_ICONS_CACHE) or mkdir($ROOM_ICONS_CACHE, 0755, true);
file_exists($ROOM_ICONS) or mkdir($ROOM_ICONS, 0755, true);
?>

@ -1,6 +1,6 @@
<?php
/**
* Return local path to room invite code.
/**
* Return local path to room invite code.
* @param string $room_id Id of room to locate QR code for.
*/
function room_qr_code_path(string $room_id): string {
@ -8,8 +8,8 @@
return "$QR_CODES/$room_id.png";
}
/**
* Return server-relative path to room invite code.
/**
* Return server-relative path to room invite code.
* @param string $room_id Id of room to locate QR code for.
*/
function room_qr_code_path_relative(string $room_id): string {
@ -56,4 +56,4 @@
}
file_exists($QR_CODES) or mkdir($QR_CODES, 0755);
?>
?>

@ -0,0 +1,25 @@
<?php
require_once 'servers/known-servers.php';
require_once 'assets/room-icons.php';
/**
* Fetch the icon of the given Community server and return its relative path.
* @param \CommunityServer $server
* @param string $size Image dimensions.
* @return string Relative path or null if icon is absent.
*/
function server_icon(\CommunityServer $server, string $size): ?string {
global $SERVER_ICON_MAPPING;
$hostname = $server->get_hostname();
if (!isset($SERVER_ICON_MAPPING[$hostname])) {
return "";
}
$room_token = $SERVER_ICON_MAPPING[$hostname];
$room = $server->get_room_by_token($room_token);
if (!$room) {
log_warning("Room $room_token on $hostname does not exist, cannot be used as icon.");
return "";
}
return room_icon($room, $size);
}
?>

@ -5,11 +5,9 @@
require_once 'utils/getopt.php';
require_once 'utils/utils.php';
require_once 'servers/known-servers.php';
require_once 'utils/servers-rooms.php';
require_once 'utils/sources.php';
// Not required
include_once "$LANGUAGES_ROOT/language_flags.php";
require_once 'servers/servers-rooms.php';
require_once 'servers/sources.php';
require_once 'languages/language-flags.php';
/**
* Fetch online Communities and write the resulting data to disk.

@ -1,121 +1,121 @@
<?php
require_once "getenv.php";
require_once "php/utils/servers-rooms.php";
require_once "php/utils/logging.php";
require_once "getenv.php";
require_once "utils/logging.php";
require_once "servers/servers-rooms.php";
class CommunityListing implements JsonSerializable {
public readonly string $id;
public readonly string $name;
public readonly string $rating;
public readonly array $rooms;
class CommunityListing implements JsonSerializable {
public readonly string $id;
public readonly string $name;
public readonly string $rating;
public readonly array $rooms;
/**
* @param \CommunityRoom[] $rooms
*/
public function __construct(string $id, string $name, ?string $rating, array $rooms) {
$this->id = $id;
$this->name = $name;
$this->rating = $rating ?? "unknown";
$this->rooms = $rooms;
}
/**
* @param \CommunityRoom[] $rooms
*/
public function __construct(string $id, string $name, ?string $rating, array $rooms) {
$this->id = $id;
$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 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(
'id' => $this->id,
'name' => $this->name,
'rating' => $this->rating,
'rooms' => count($this->rooms)
);
}
}
public function to_summary(): array {
return array(
'id' => $this->id,
'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, scanner_mode: INI_SCANNER_RAW);
$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[] = new CommunityListing(
$id,
$listing_props['name'],
$listing_props['rating'],
$rooms
);
}
/**
* @return \CommunityListing[]
*/
function resolve_listings_config(): array {
global $LISTINGS_INI, $ROOMS_FILE;
$listings_raw = parse_ini_file($LISTINGS_INI, process_sections: true, scanner_mode: INI_SCANNER_RAW);
$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[] = new CommunityListing(
$id,
$listing_props['name'],
$listing_props['rating'],
$rooms
);
}
return $listings;
}
return $listings;
}
function generate_listings() {
global $LISTING_PROVIDER_LISTING_SUMMARY, $LISTING_PROVIDER_LISTINGS;
log_info("Generating 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 $listing) {
$id = $listing->id;
file_put_contents(
"$LISTING_PROVIDER_LISTINGS/$id",
json_encode($listing)
);
}
$listings_count = count($listings_resolved);
log_info("Generated $listings_count 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 $listing) {
$id = $listing->id;
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);
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;
}
$options = getopt("v", ["verbose"]);
if (isset($options["v"]) or isset($options["verbose"])) {
$LOGGING_VERBOSITY = LoggingVerbosity::Debug;
}
generate_listings();
?>
generate_listings();
?>

@ -21,4 +21,4 @@
require_once "$PROJECT_ROOT/.phpenv";
// set_include_path(get_include_path() . PATH_SEPARATOR . $PROJECT_ROOT);
?>
?>

@ -1,10 +1,10 @@
<?php
require_once 'languages/language_flags.php';
require_once 'languages/language-flags.php';
require_once 'servers/known-servers.php';
require_once 'tags.php';
require_once 'fetching-coroutines.php';
require_once 'room-icons.php';
require_once 'room-invites.php';
require_once 'servers/tags.php';
require_once 'utils/fetching-coroutines.php';
require_once 'assets/room-icons.php';
require_once 'assets/room-invites.php';
$MINUTE_SECONDS = 60;
$HOUR_SECONDS = 60 * $MINUTE_SECONDS;
@ -761,7 +761,7 @@
}
$runner = new FetchingCoroutineRunner($coroutines);
$runner->run_all();
return $reachable_servers;
@ -1138,4 +1138,4 @@
);
}
}
?>
?>

@ -0,0 +1,318 @@
<?php
require_once 'utils/utils.php';
require_once 'servers/tags.php';
class SDIRCommunitySource {
private function __construct(string $contents) {
$this->contents = $contents;
}
/**
* Create new instance of this source from contents.
* Returns false if processing the source fails.
* @return \SDIRCommunitySource|false
*/
public static function from_contents(string $contents) {
$source = new SDIRCommunitySource($contents);
if (!$source->sdir_process_tags()) {
return false;
}
return $source;
}
private readonly string $contents;
/**
* @var string[][] $tags Array associating room IDs with string tag arrays.
*/
private array $tags;
private static function sdir_validate_entry(
array $room_entry,
bool &$missing_url,
bool &$missing_tags
): bool {
if (!isset($room_entry['url']) || !is_string($room_entry['url'])) {
log_value($room_entry);
$missing_url = true;
return false;
}
if (!isset($room_entry['tags']) || !is_string($room_entry['tags'])) {
log_value($room_entry);
$missing_tags = true;
return false;
}
return true;
}
private static function sdir_report_errors(bool $entry_missing_url, bool $entry_missing_tags) {
if ($entry_missing_url) {
log_error("One or more room entries from session.directory is missing the 'url' parameter.");
}
if ($entry_missing_tags) {
log_error("One or more room entries from session.directory is missing the 'tags' parameter.");
}
}
private function get_sdir_entries(): array|bool {
try {
return json_decode($this->contents, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return false;
}
}
private function sdir_process_tags(): bool {
$entry_missing_url = false;
$entry_missing_tags = false;
$rooms = SDIRCommunitySource::get_sdir_entries($this->contents);
if (!$rooms) {
log_error("Could not parse entries from session.directory.");
return false;
}
foreach ($rooms as $room_entry) {
if (!SDIRCommunitySource::sdir_validate_entry(
$room_entry, $entry_missing_url, $entry_missing_tags
)) {
continue;
}
$url = $room_entry['url'];
$tags = $room_entry['tags'];
$room_id = url_get_room_id($url);
$this->tags[$room_id] = explode(',', $tags);
}
SDIRCommunitySource::sdir_report_errors($entry_missing_url, $entry_missing_tags);
return true;
}
/**
* @return string[][] Array associating room IDs with string tag arrays.
*/
public function get_tags(): array {
return $this->tags;
}
}
class ASGLCommunitySource {
private function __construct(string $contents) {
$this->contents = $contents;
}
private readonly string $contents;
/**
* @var string[][] $tags;
*/
private array $tags = [];
/**
* @return \ASGLCommunitySource|false
*/
public static function from_contents(string $contents) {
$source = new ASGLCommunitySource($contents);
if(!$source->asgl_process_tags()) {
return false;
}
return $source;
}
private function asgl_process_tags(): bool {
$lines = explode("\n", $this->contents);
// $last_headings = [];
$last_room_id = null;
$room_tags = [];
foreach ($lines as $line) {
ASGLCommunitySource::asgl_process_line($line, $last_room_id, $room_tags);
if ($last_room_id != null && count($room_tags) > 0) {
$this->tags[$last_room_id] = $room_tags;
$last_room_id = null;
$room_tags = [];
}
}
return true;
}
private static function asgl_process_line(
?string $line,
?string &$last_room_id,
array &$room_tags
) {
$line = trim($line);
if (strlen($line) == 0) {
$last_room_id = null;
return;
}
$urls = parse_join_links($line);
if (count($urls) == 1 && $urls[0] == $line) {
$last_room_id = url_get_room_id($urls[0]);
$room_tags = [];
return;
}
if (str_starts_with($line, "hashtag")) {
$room_tags = ASGLCommunitySource::read_asgl_tags($line);
}
}
private static function read_asgl_tags(string $line): array {
$matches = [];
preg_match_all('/`#([^`]+)`/', $line, $matches);
// Return first group matches.
return $matches[1];
}
public function get_tags(): array {
return $this->tags;
}
}
class CommunitySources {
private const SOURCES = array(
'ASGL' => 'https://raw.githubusercontent.com/GNU-Linux-libre/Awesome-Session-Group-List/main/README.md',
'LOKI' => 'https://lokilocker.com/Mods/Session-Groups/wiki/Session-Open-Groups',
'SDIR' => 'https://session.directory/?all=groups',
'SDIR-BASE' => 'https://session.directory/',
'SDIR-PATTERN' => '/view_session_group_user_lokinet\.php\?id=\d+/',
'SDIR-JSON' => 'https://session.directory/scrape.php',
'FARK' => 'https://freearkham.cc/'
);
private readonly string $contents_asgl;
private readonly string $contents_loki;
private readonly string $contents_sdir;
private readonly string $contents_fark;
private readonly string $contents_aggregated;
/**
* Arraying associating room identifiers with arrays of raw tags.
* @var array<string,string[]> $room_tags
*/
private array $room_tags = [];
/**
* Fetches and saves known sources of Session Community join links.
*/
public function __construct() {
log_info("Requesting Awesome Session Group list...");
$this->contents_asgl = CommunitySources::fetch_source('ASGL');
log_info("Requesting Lokilocker Mods Open Group list...");
$this->contents_loki = CommunitySources::fetch_source('LOKI');
log_info("Requesting session.directory list...");
$this->contents_sdir = CommunitySources::fetch_source('SDIR-JSON');
log_info("Requesting FreeArkham.cc list...");
$this->contents_fark = CommunitySources::fetch_source('FARK');
log_info("Parsing extra information...");
if (!$this->process_sources()) {
log_error("Could not parse extra information from one or more sources.");
}
log_info('Done fetching sources.');
$this->contents_aggregated =
$this->contents_asgl .
$this->contents_fark .
$this->contents_loki .
// Slashes are escaped when served, unescape them
str_replace("\\/", "/", $this->contents_sdir);
}
private static function fetch_source(string $source_key) {
$url = CommunitySources::SOURCES[$source_key];
$contents = file_get_contents($url);
log_debug($http_response_header[0]);
if (!$contents) {
log_error("Could not fetch source from $url.");
return "";
}
return $contents;
}
/**
* @param string[][] $tags Array associating room IDs to tag arrays
*/
private function add_tags(array $tags) {
foreach ($tags as $room_id => $room_tags) {
if (!isset($this->room_tags[$room_id])) {
$this->room_tags[$room_id] = [];
}
$this->room_tags[$room_id] = [
...$this->room_tags[$room_id],
...$room_tags
];
}
}
private function process_sources(): bool {
$source_sdir = SDIRCommunitySource::from_contents($this->contents_sdir);
$source_asgl = ASGLCommunitySource::from_contents($this->contents_asgl);
$source_sdir && $this->add_tags($source_sdir->get_tags());
$source_asgl && $this->add_tags($source_asgl->get_tags());
if (!$source_sdir) {
return false;
}
if (!$source_asgl) {
return false;
}
return true;
}
/**
* Returns all join URLs found.
* @return string[] Join URLs.
*/
public function get_join_urls(): array {
return array_unique(
parse_join_links($this->contents_aggregated)
);
}
/**
* Return known tags for the given room.
* @param string $room_id Room identifier.
* @return \CommunityTag[] Array of string tags.
*/
public function get_room_tags($room_id): array {
if (!isset($this->room_tags[$room_id])) {
return [];
}
return $this->room_tags[$room_id];
}
}
?>

@ -0,0 +1,185 @@
<?php
require_once 'utils/utils.php';
class TagType {
private function __construct() {}
const USER_TAG = 0;
const RESERVED_TAG = 1;
const WARNING_TAG = 2;
}
class CommunityTag implements JsonSerializable {
public function __construct(
string $text,
int $tag_type = TagType::USER_TAG,
string $description = ""
) {
$this->text = $text;
$this->type = $tag_type;
$this->description =
empty($description) ? "Tag: $text" : $description;
}
public readonly int $type;
public readonly string $text;
public readonly string $description;
/**
* Returns a lowercase representation of the tag for purposes of de-duping.
*/
public function __toString(): string {
return strtolower($this->text);
}
/**
* Returns a lowercase representation of the tag for use in display.
*/
public function get_text(): string {
return strtolower($this->text);
}
public function jsonSerialize(): mixed {
// Only used for passing to DOM
$details = get_object_vars($this);
$details['text'] = html_sanitize($this->get_text());
$details['description'] = html_sanitize($details['description']);
$details['type'] = $this->get_tag_type();
return $details;
}
private static function preprocess_tag(?string $tag) {
$tag = trim($tag);
if (strlen($tag) == 0) {
return $tag;
}
$tag = html_sanitize(html_entity_decode($tag));
if ($tag[0] == '#') {
return substr($tag, 1);
}
return $tag;
}
/**
* @param string[] $tag_array
* @return \CommunityTag[]
*/
private static function from_tag_array(array $tag_array) {
$tags = array_map(function(?string $tag) {
return CommunityTag::preprocess_tag($tag);
}, $tag_array);
$tags = array_filter(
$tags, function(?string $tag) {
return strlen($tag) != 0;
}
);
return array_map(function(string $tag) {
return new CommunityTag($tag);
}, $tags);
}
/**
* Returns the user tags given, without any reserved tags.
* @param string[] $tags
* @param bool $remove_redundant Removes duplicate and obvious tags.
* @return \CommunityTag[]
*/
public static function from_user_tags(
array $tags, bool $remove_redundant = false
): array {
$tags_user = array_filter(
$tags,
function($tag) {
return !CommunityTag::is_reserved_tag($tag);
}
);
$tags_built = CommunityTag::from_tag_array($tags_user);
if ($remove_redundant) {
$tags_built = CommunityTag::dedupe_tags($tags_built);
$tags_built = array_filter($tags_built, function(\CommunityTag $tag) {
$text = strtolower($tag->text);
return !in_array($text, CommunityTag::REDUNDANT_TAGS);
});
}
return $tags_built;
}
/**
* @param string[] $details_array Array of string tags.
* @return \CommunityTag[]
*/
public static function from_details_array(array $details_array): array {
return CommunityTag::from_user_tags($details_array);
}
/**
* @param \CommunityTag[] $tags
* @return \CommunityTag[]
*/
public static function dedupe_tags(array $tags) {
return array_unique($tags);
}
public function get_tag_classname(): string {
$tag_type = $this->get_tag_type();
$classname = "room-label-$tag_type";
if (CommunityTag::is_showcased_tag($this->text)) {
$classname .= " room-label-showcased";
}
return $classname;
}
public function get_tag_type(): string {
return match($this->type) {
TagType::USER_TAG => 'user',
TagType::RESERVED_TAG => 'reserved',
TagType::WARNING_TAG => 'warning'
};
}
/**
* @var string[] RESERVED_TAGS
* Array of derived tags unavailable for manual tagging.
*/
private const RESERVED_TAGS = [
"official",
"nsfw",
"new",
"modded",
"not modded",
"read-only",
"uploads off",
"we're here"
];
private const SHOWCASED_TAGS = ["official", "new", "we're here"];
private const REDUNDANT_TAGS = ["session"];
public const NSFW_KEYWORDS = ["nsfw", "porn", "erotic", "18+"];
public const CHECK_MARK = "โœ…";
public const WARNING_ICON = "โš ๏ธ";
/**
* Checks whether the given manual tag can be accepted.
*/
public static function is_reserved_tag(string $tag): bool {
return in_array(strtolower($tag), CommunityTag::RESERVED_TAGS);
}
public static function is_showcased_tag(string $tag): bool {
return in_array(strtolower($tag), CommunityTag::SHOWCASED_TAGS);
}
}
?>

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

@ -1,332 +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);
}
}
}
require_once 'utils/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 {