diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bf929dc --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 221faf1..a745c5d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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/` 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/` 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 (``) +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 + +
+has_food()): > + describe_food() ?> + + + + + +
+ +``` + +**Other**: + +- Strings shall be surrounded by single quotes `''` +where no variable expansion is taking place + ### HTML & CSS **Identifier casing**: `kebab-case`, legacy `snake_case` diff --git a/etc/fetch-compare-known.sh b/etc/fetch-compare-known.sh index e75350b..e0de0f4 100755 --- a/etc/fetch-compare-known.sh +++ b/etc/fetch-compare-known.sh @@ -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 diff --git a/etc/nginx/main.conf b/etc/nginx/main.conf index 184c9cb..ff8819f 100644 --- a/etc/nginx/main.conf +++ b/etc/nginx/main.conf @@ -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"; - } \ No newline at end of file + } diff --git a/languages/README.md b/languages/README.md index 1a6167b..6b073e9 100644 --- a/languages/README.md +++ b/languages/README.md @@ -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 . diff --git a/languages/language_flags.php b/languages/language-flags.php similarity index 100% rename from languages/language_flags.php rename to languages/language-flags.php diff --git a/output/main.js b/output/main.js index 0b53555..0b2fba6 100644 --- a/output/main.js +++ b/output/main.js @@ -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()); \ No newline at end of file +document.addEventListener('DOMContentLoaded', () => onLoad()); diff --git a/output/qrcode-solid.svg b/output/qrcode-solid.svg index ec99749..6a063c9 100644 --- a/output/qrcode-solid.svg +++ b/output/qrcode-solid.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/php/assets/room-icons.php b/php/assets/room-icons.php new file mode 100644 index 0000000..e170fdb --- /dev/null +++ b/php/assets/room-icons.php @@ -0,0 +1,127 @@ + + */ + 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); +?> diff --git a/php/utils/room-invites.php b/php/assets/room-invites.php similarity index 93% rename from php/utils/room-invites.php rename to php/assets/room-invites.php index 50ab8e0..2248473 100644 --- a/php/utils/room-invites.php +++ b/php/assets/room-invites.php @@ -1,6 +1,6 @@ \ No newline at end of file +?> diff --git a/php/assets/server-icons.php b/php/assets/server-icons.php new file mode 100644 index 0000000..ae442af --- /dev/null +++ b/php/assets/server-icons.php @@ -0,0 +1,25 @@ +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); + } +?> diff --git a/php/fetch-servers.php b/php/fetch-servers.php index e36e265..abc9e77 100644 --- a/php/fetch-servers.php +++ b/php/fetch-servers.php @@ -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. diff --git a/php/generate-listings.php b/php/generate-listings.php index b704a6e..74290ae 100644 --- a/php/generate-listings.php +++ b/php/generate-listings.php @@ -1,121 +1,121 @@ 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(); -?> \ No newline at end of file + generate_listings(); +?> diff --git a/php/getenv.php b/php/getenv.php index 33e3f11..742c08a 100644 --- a/php/getenv.php +++ b/php/getenv.php @@ -21,4 +21,4 @@ require_once "$PROJECT_ROOT/.phpenv"; // set_include_path(get_include_path() . PATH_SEPARATOR . $PROJECT_ROOT); -?> \ No newline at end of file +?> diff --git a/php/utils/servers-rooms.php b/php/servers/servers-rooms.php similarity index 99% rename from php/utils/servers-rooms.php rename to php/servers/servers-rooms.php index 93b9486..b6bc705 100644 --- a/php/utils/servers-rooms.php +++ b/php/servers/servers-rooms.php @@ -1,10 +1,10 @@ run_all(); return $reachable_servers; @@ -1138,4 +1138,4 @@ ); } } -?> \ No newline at end of file +?> diff --git a/php/servers/sources.php b/php/servers/sources.php new file mode 100644 index 0000000..dfe6f2b --- /dev/null +++ b/php/servers/sources.php @@ -0,0 +1,318 @@ +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 $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]; + } + } +?> diff --git a/php/servers/tags.php b/php/servers/tags.php new file mode 100644 index 0000000..2018745 --- /dev/null +++ b/php/servers/tags.php @@ -0,0 +1,185 @@ +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); + } + } +?> diff --git a/php/update-listing.php b/php/update-listing.php index 7db661b..3d8e691 100644 --- a/php/update-listing.php +++ b/php/update-listing.php @@ -2,4 +2,4 @@ require_once 'fetch-servers.php'; require_once 'generate-html.php'; require_once 'generate-listings.php'; -?> \ No newline at end of file +?> diff --git a/php/utils/fetching-coroutines.php b/php/utils/fetching-coroutines.php index 889929a..3736dc0 100644 --- a/php/utils/fetching-coroutines.php +++ b/php/utils/fetching-coroutines.php @@ -1,332 +1,332 @@ $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 $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 Coroutine returning - */ - public static function from_url(string $url, array $curlopts = []): \FetchingCoroutine { - /** - * @var Generator $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 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 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 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 - */ - 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 $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 $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 Coroutine returning + */ + public static function from_url(string $url, array $curlopts = []): \FetchingCoroutine { + /** + * @var Generator $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 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 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 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 + */ + 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); + } + } + } ?> diff --git a/php/utils/getopt.php b/php/utils/getopt.php index 33f726c..fe3920c 100644 --- a/php/utils/getopt.php +++ b/php/utils/getopt.php @@ -1,5 +1,5 @@ \ No newline at end of file +?> diff --git a/php/utils/room-icons.php b/php/utils/room-icons.php deleted file mode 100644 index 5ccbdb8..0000000 --- a/php/utils/room-icons.php +++ /dev/null @@ -1,127 +0,0 @@ - - */ - 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); -?> \ No newline at end of file diff --git a/php/utils/server-icons.php b/php/utils/server-icons.php deleted file mode 100644 index be13986..0000000 --- a/php/utils/server-icons.php +++ /dev/null @@ -1,25 +0,0 @@ -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); - } -?> \ No newline at end of file diff --git a/php/utils/sources.php b/php/utils/sources.php deleted file mode 100644 index 6626479..0000000 --- a/php/utils/sources.php +++ /dev/null @@ -1,318 +0,0 @@ -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 $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]; - } - } -?> \ No newline at end of file diff --git a/php/utils/tags.php b/php/utils/tags.php deleted file mode 100644 index 42e7991..0000000 --- a/php/utils/tags.php +++ /dev/null @@ -1,185 +0,0 @@ -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); - } - } -?> \ No newline at end of file diff --git a/sites/+components/communities-json-ld.php b/sites/+components/communities-json-ld.php index 593e22c..b903dfb 100644 --- a/sites/+components/communities-json-ld.php +++ b/sites/+components/communities-json-ld.php @@ -1,31 +1,32 @@ "https://www.w3.org/ns/activitystreams", - "summary" => "Active Session Communities", - "type" => "Collection", - "totalItems" => count($rooms), - "items" => array_map(function(\CommunityRoom $room) { - $values = array( - "type" => "Group", - "name" => $room->name, - "summary" => $room->description, - "startTime" => $room->created ? date('Y-m-d\TH:i:s', intval($room->created)) : null, - "url" => $room->get_preview_url(), - "icon" => room_icon($room, '64x64') - ); - return array_filter($values, function ($value) { - return $value != null; - }); - }, $rooms) - ); + $json_ld_data = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'summary' => 'Active Session Communities', + 'type' => 'Collection', + 'totalItems' => count($rooms), + 'items' => array_map(function(\CommunityRoom $room) { + $values = array( + 'type' => 'Group', + 'name' => $room->name, + 'summary' => $room->description, + 'startTime' => $room->created ? date('Y-m-d\TH:i:s', intval($room->created)) : null, + 'url' => $room->get_preview_url(), + 'icon' => room_icon($room, '64x64') + ); + return array_filter($values, function ($value) { + return $value != null; + }); + }, $rooms) + ); ?> \ No newline at end of file + + + diff --git a/sites/+components/page-head.php b/sites/+components/page-head.php index 59e3a63..a9b51bf 100644 --- a/sites/+components/page-head.php +++ b/sites/+components/page-head.php @@ -3,9 +3,11 @@ diff --git a/sites/+components/qr_modals.php b/sites/+components/qr-modals.php similarity index 98% rename from sites/+components/qr_modals.php rename to sites/+components/qr-modals.php index d0d3343..5cb651a 100644 --- a/sites/+components/qr_modals.php +++ b/sites/+components/qr-modals.php @@ -1,5 +1,5 @@
diff --git a/sites/+components/tbl_communities.php b/sites/+components/tbl-communities.php similarity index 94% rename from sites/+components/tbl_communities.php rename to sites/+components/tbl-communities.php index 5adf64b..087c133 100644 --- a/sites/+components/tbl_communities.php +++ b/sites/+components/tbl-communities.php @@ -1,9 +1,9 @@ + @@ -79,9 +80,9 @@

Session Communities

- + - +
diff --git a/sites/instructions.php b/sites/instructions.php index 53acb02..b6b2385 100644 --- a/sites/instructions.php +++ b/sites/instructions.php @@ -1,5 +1,5 @@ @@ -11,12 +11,12 @@ " class="language-selection" @@ -60,11 +60,10 @@ file_language($file); ?> - - +
- $file): ?> + $file): ?>
- +
- \ No newline at end of file +