Merge branch 'community-labels'

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

1
.gitignore vendored

@ -2,7 +2,6 @@
output/*.html
# Downloaded QR codes
output/qr-codes
# Server-side cache

@ -11,11 +11,15 @@
include_once "$PROJECT_ROOT/php/utils/logging.php";
// Read the -v|--verbose option increasing logging verbosity to debug.
$options = getopt("v", ["verbose"]);
$options = getopt("v", ["verbose", "fast"]);
if (isset($options["v"]) or isset($options["verbose"])) {
$LOGGING_VERBOSITY = LoggingVerbosity::Debug;
}
if (isset($options["fast"])) {
$FAST_FETCH_MODE = true;
}
// set timeout for file_get_contents()
ini_set('default_socket_timeout', 6); // in seconds, default is 60

@ -81,7 +81,7 @@ body {
--secondary-color-shaded: hsl(0, 0%, 91%);
--anchor-color: hsl(210, 100%, 40%);
--color-http: hsl(0, 0%, 83%);
--color-https: hsl(195, 53%, 79%)
--color-https: hsl(195, 53%, 79%);
}
a, .anchorstyle {
@ -102,6 +102,33 @@ html:not(.js) .js-only {
display: none;
}
.badge {
display: inline-block;
border-radius: 1em;
padding-block: 0.1em;
padding-inline: 0.5em;
margin-block: 0.1em;
margin-inline: 0.2em;
border: #0004 outset 1px;
/* box-shadow: 0.05em 0.05em 0.1em 0 #4444;*/
}
#tbl_communities .room-label, #details-modal .room-label {
color: black;
}
.room-label-user {
background-color: greenyellow;
}
.room-label-reserved {
background-color: yellow;
}
.room-label-warning {
background-color: pink;
}
gap {
flex-grow: 1000;
}
@ -378,12 +405,12 @@ footer nav a {
}
label[for=toggle-show-room-ids]::after {
content: " (Off)"
content: " (Off)";
}
#toggle-show-room-ids:checked
~ footer label[for=toggle-show-room-ids]::after {
content: " (On)"
content: " (On)";
}
/* --- QR code modals --- */
@ -397,9 +424,9 @@ label[for=toggle-show-room-ids]::after {
#details-modal-contents {
display: flex;
position: relative;
flex-direction: row;
flex-direction: column;
padding: 3em;
box-sizing: border-box;
}
#details-modal-close {
@ -413,6 +440,11 @@ label[for=toggle-show-room-ids]::after {
text-align: center;
}
#details-modal-pane {
display: flex;
flex-direction: row;
}
#details-modal-start {
display: flex;
flex-direction: column;
@ -436,10 +468,16 @@ label[for=toggle-show-room-ids]::after {
font-style: italic;
}
#details-modal-start #details-modal-language-flag:empty::after {
content: "Unknown";
font-style: italic;
}
#details-modal-end #details-modal-qr-code {
aspect-ratio: 1;
width: 20em;
height: 20em;
margin-bottom: 1em;
margin: 1em;
}
#details-modal-end #details-modal-qr-code-label {
@ -453,6 +491,10 @@ label[for=toggle-show-room-ids]::after {
padding: var(--cell-padding-small);
}
#details-modal-qr-code-label-name {
display: block;
}
/* <Snackbar> */
/* The snackbar - position it at the bottom and in the middle of the screen */
#copy-snackbar {
@ -478,6 +520,38 @@ label[for=toggle-show-room-ids]::after {
animation: fadein 0.5s, fadeout 0.5s 2.5s;
}
/* Responsive properties */
.td_name .room-label:nth-of-type(5) ~ .room-label {
display: none;
}
@media (max-width: 1600px) {
.td_name .room-label:nth-of-type(4) ~ .room-label {
display: none;
}
}
@media (max-width: 1500px) {
.td_name .room-label:nth-of-type(3) ~ .room-label {
display: none;
}
}
@media (max-width: 1400px) {
.td_name .room-label:nth-of-type(2) ~ .room-label {
display: none;
}
}
@media (max-width: 1200px) {
.td_name .room-label:nth-of-type(1) ~ .room-label {
display: none;
}
}
.td_name .room-label:not(.room-label-showcased) {
display: none;
}
@media (max-width: 1050px) {
/* Only current width breakpoint; */
@ -495,15 +569,44 @@ label[for=toggle-show-room-ids]::after {
}
}
@media (max-width: 950px) {
#details-modal-pane {
flex-direction: column;
}
#details-modal-end {
align-items: start;
}
#details-modal #details-modal-qr-code {
width: 12.5em;
height: 12.5em;
}
#details-modal-qr-code-label-name {
display: inline;
}
#details-modal-language, #details-modal-users {
display: inline-block;
text-align: center;
}
#details-modal-room-info > * {
margin-block: 0.5em;
}
#details-modal-language::after {
content: "•";
margin-inline: 1em;
}
}
@media (max-width: 500px) {
:root {
/* ! For when descriptions don't wrap and 100vw doesn't work. */
--dynamic-columns-width: 15rem;
}
#details-modal-contents {
flex-direction: column;
}
}
/* Animations to fade the snackbar in and out */

@ -12,20 +12,22 @@ export const dom = {
/** @type {string[]} */
return {
language_flag: row.querySelector('.td_language').textContent.trim(),
name: row.querySelector('.td_name').textContent.trim(),
name: row.querySelector('.td_name-inner').textContent.trim(),
description: row.querySelector('.td_description').textContent.trim(),
users: parseFloat(row.querySelector('.td_users').textContent.trim()),
preview_link: row.querySelector('.td_preview a[href]').getAttribute('href'),
join_link: row.querySelector('.td_join_url a[href]').getAttribute('href'),
hostname: row.getAttribute('data-hostname'),
public_key: row.getAttribute('data-pubkey'),
staff: row.getAttribute('data-staff')
hostname: row.getAttribute(ATTRIBUTES.ROW.HOSTNAME),
public_key: row.getAttribute(ATTRIBUTES.ROW.PUBLIC_KEY),
staff: row.getAttribute(ATTRIBUTES.ROW.STAFF_DATA),
tags: row.getAttribute(ATTRIBUTES.ROW.TAGS)
};
},
meta_timestamp: () => document.querySelector('meta[name=timestamp]'),
last_checked: () => document.getElementById("last_checked_value"),
/** @return {HTMLDialogElement | null} */
details_modal: () => document.getElementById('details-modal'),
details_modal_tag_container: () => document.getElementById('details-modal-room-tags'),
details_modal_qr_code: () => document.getElementById('details-modal-qr-code'),
join_urls: () => document.getElementsByClassName("join_url_container"),
servers_hidden: () => document.getElementById("servers_hidden"),
@ -56,6 +58,7 @@ export const COMPARISON = {
export const ATTRIBUTES = {
ROW: {
TAGS: 'data-tags',
IDENTIFIER: 'data-identifier',
PUBLIC_KEY: 'data-pubkey',
HOSTNAME: 'data-hostname',
@ -100,7 +103,7 @@ const TRANSFORMATION = {
export const COLUMN_TRANSFORMATION = {
[COLUMN.USERS]: TRANSFORMATION.numeric,
[COLUMN.IDENTIFIER]: TRANSFORMATION.casefold,
[COLUMN.NAME]: TRANSFORMATION.casefold,
[COLUMN.NAME]: TRANSFORMATION.getSortKey,
[COLUMN.DESCRIPTION]: TRANSFORMATION.casefold,
[COLUMN.SERVER_ICON]: TRANSFORMATION.getSortKey
}

@ -75,6 +75,13 @@ function onLoad() {
preloadQRCodes();
}
const tagBody = ({text, type, description}) => element.span({
// todo: truncate
textContent: text,
className: `room-label room-label-${type} badge`,
title: description
});
function displayQRModal(communityID) {
const modal = dom.details_modal();
@ -109,6 +116,14 @@ function displayQRModal(communityID) {
}
}
const tagContainer = dom.details_modal_tag_container();
tagContainer.innerHTML = "";
tagContainer.append(
...JSON.parse(rowInfo.tags).map(tag => tagBody(tag))
);
dom.details_modal_qr_code().src = communityQRCodeURL(communityID);
modal.showModal();
@ -123,10 +138,19 @@ function addQRModalHandlers() {
if (!rows) throw new Error("Rows not found");
for (const row of rows) {
const communityID = row.getAttribute(ATTRIBUTES.ROW.IDENTIFIER);
row.querySelector('.td_qr_code').addEventListener(
for (const cell of ['.td_qr_code', '.td_description', '.td_language', '.td_users']) {
row.querySelector(cell).addEventListener(
'click',
() => displayQRModal(communityID)
);
}
row.addEventListener(
'click',
() => displayQRModal(communityID)
);
(e) => {
if (e.target != row) { return; }
displayQRModal(communityID);
}
)
row.querySelector('.td_name').addEventListener(
'click',
(e) => {

@ -5,6 +5,7 @@
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";
@ -27,15 +28,12 @@
file_exists($CACHE_ROOT) or mkdir($CACHE_ROOT, 0700);
// Query our sources and store the resulting HTML.
// TODO: Tag information is currently discarded. Feature needs triage.
$html_pages = query_known_sources();
// Find join links in each HTML document and merge the resulting arrays.
$join_links = array_merge([], ...array_map('parse_join_links', $html_pages));
$sources = new CommunitySources();
/**
* @var CommunityServer[] $servers
*/
$servers = CommunityServer::from_join_urls($join_links);
$servers = CommunityServer::from_join_urls($sources->get_join_urls());
// Add known hosts.
$servers = [...CommunityServer::from_known_hosts($KNOWN_SERVERS, $KNOWN_PUBKEYS), ...$servers];
@ -49,6 +47,9 @@
// Merge servers with the same public key.
$servers = CommunityServer::dedupe_by_pubkey($servers);
// Fill additional information from sources.
CommunityServer::source_additional_info($servers, $sources);
// Count servers and rooms.
$servers_total = count($servers);
$rooms_total = count_rooms($servers);
@ -63,36 +64,6 @@
file_put_contents($ROOMS_FILE, json_encode($servers));
}
/**
* Fetches known sources of Session Community join links.
* @return string[] HTML pages containing join URLs.
*/
function query_known_sources(): array {
global $SOURCES;
log_info("Requesting Awesome Session Group list...");
$pages_asgl[] = file_get_contents($SOURCES['ASGL']);
log_debug($http_response_header[0]); // Supposed to be "HTTP/1.1 200 OK"
log_info("Requesting Lokilocker Mods Open Group list...");
$pages_loki[] = file_get_contents($SOURCES['LOKI']);
log_debug($http_response_header[0]); // Supposed to be "HTTP/1.1 200 OK"
log_info("Requesting session.directory list...");
$pages_sdir = [];
$json_sdir = json_decode(file_get_contents($SOURCES['SDIR-JSON']));
log_debug($http_response_header[0]); // Supposed to be "HTTP/1.1 200 OK"
foreach ($json_sdir as $room) $pages_sdir[] = $room->url;
log_info("Requesting FreeArkham.cc list...");
$pages_fark[] = file_get_contents($SOURCES['FARK']);
log_debug($http_response_header[0]); // Supposed to be "HTTP/1.1 200 OK"
log_info('Done fetching sources.');
return [...$pages_asgl, ...$pages_loki, ...$pages_sdir, ...$pages_fark];
}
/**
* Debug function to see which communities use pinned messages already
* @deprecated

@ -1,17 +1,4 @@
<?php
/**
* Community lists crawled for community links.
*/
$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/'
);
/**
* List of servers hosts queried individually.
*/
@ -35,7 +22,7 @@
// Found via Reddit:
"http://5.39.117.98",
// legacy
// "http://3.69.29.128", // former https://reccacon.com

@ -1,6 +1,9 @@
<?php
include_once "$PROJECT_ROOT/languages/language_flags.php";
include_once "$PROJECT_ROOT/php/servers/known-servers.php";
include_once 'tags.php';
$MINUTE_SECONDS = 60;
$HOUR_SECONDS = 60 * $MINUTE_SECONDS;
$DAY_SECONDS = 24 * $HOUR_SECONDS;
@ -72,7 +75,7 @@
*
* Custom attribute.
*/
public readonly array $tags;
private array $tags = [];
private function __construct(\CommunityServer $server, array $details) {
global $languages;
@ -95,11 +98,6 @@
$room_identifier = $this->get_room_identifier();
/** @var string[] */
$this->tags = isset($details['tags'])
? $details['tags']
: [];
$this->language_flag =
isset($languages[$room_identifier])
? $languages[$room_identifier]
@ -112,6 +110,7 @@
function jsonSerialize(): array {
$details = get_object_vars($this);
unset($details['server']);
$details['tags'] = CommunityTag::cacheable_room_tags($details['tags']);
return $details;
}
@ -120,7 +119,12 @@
* @param CommunityServer $server
*/
public static function from_details($server, array $details) {
return new CommunityRoom($server, $details);
$room = new CommunityRoom($server, $details);
$has_tags = isset($details['tags']);
if ($has_tags) {
$room->tags = CommunityTag::from_details_array($details['tags']);
}
return $room;
}
/**
@ -201,7 +205,6 @@
}
return floor($active_users_cutoff) . 's';
}
/**
@ -256,6 +259,108 @@
$pubkey_4 = substr($this->server->pubkey, 0, 4);
return "$token+$pubkey_4";
}
/**
* Checks whether this room belongs to a Session-owned server.
*/
function is_official_room(): bool {
return $this->server->is_official_server();
}
/**
* @param string[] $tags
*/
public function add_tags(array $tags) {
$this->tags = [...$this->tags, ...$tags];
}
private function has_nsfw_keywords(): bool {
// Description not included due to false positives.
$blob =
strtolower($this->name) . " " .
strtolower(join(" ", $this->tags));
foreach (CommunityTag::NSFW_KEYWORDS as $keyword) {
if (str_contains($blob, $keyword)) {
return true;
}
}
return false;
}
public const USERS_PER_STAFF = 50;
public const USERS_PER_STAFF_WARNING = 200;
private function has_good_staff_rating(): bool {
$recommended_staff_count = $this->active_users / CommunityRoom::USERS_PER_STAFF;
return count($this->get_staff()) >= $recommended_staff_count;
}
private function has_poor_staff_rating(): bool {
if ($this->active_users <= 3) {
return false;
}
$minimal_staff_count = $this->active_users / CommunityRoom::USERS_PER_STAFF_WARNING;
return count($this->get_staff()) < $minimal_staff_count;
}
/**
* Return the tags associated with this room.
* @return \CommunityTag[] Tags as string array.
*/
function get_room_tags(): array {
$user_tags = CommunityTag::from_user_tags($this->tags, remove_redundant: true);
/**
* @var \CommunityTag[] $derived_tags
*/
$derived_tags = [];
$CHECK_MARK = CommunityTag::CHECK_MARK;
$WARNING = CommunityTag::WARNING_ICON;
$USERS_PER_STAFF = CommunityRoom::USERS_PER_STAFF;
$USERS_PER_STAFF_WARNING = CommunityRoom::USERS_PER_STAFF_WARNING;
if ($this->is_official_room()) {
$derived_tags[] = new CommunityTag(
"official",
TagType::RESERVED_TAG,
"This Community is maintained by the Session team. $CHECK_MARK"
);
}
if ($this->has_nsfw_keywords()) {
$derived_tags[] =
new CommunityTag(
"nsfw",
TagType::WARNING_TAG,
"This Community may contain adult material. $WARNING"
);
}
if ($this->write && $this->has_good_staff_rating()) {
$derived_tags[] =
new CommunityTag(
"modded",
TagType::RESERVED_TAG,
"This Community has at least 1 staff per $USERS_PER_STAFF active users. $CHECK_MARK"
);
}
/*
if ($this->write && $this->has_poor_staff_rating()) {
$derived_tags[] =
new CommunityTag(
"not modded",
TagType::WARNING_TAG,
"This Community has less than 1 staff per $USERS_PER_STAFF_WARNING active users. $WARNING"
);
}
*/
return [...$derived_tags, ...$user_tags];
}
}
/**
@ -520,7 +625,7 @@
}
/**
Create Community server instance from array loaded server data.
* Create Community server instance from array loaded server data.
* @param array $details Decoded JSON associative arrays about server.
* @return CommunityServer[] Servers represented by given data.
*/
@ -534,6 +639,20 @@
return $servers;
}
/**
* Add to the given servers additional data extracted from our sources.
* @param \CommunityServer[] $servers
* @param \CommunitySources $source
*/
static function source_additional_info(array $servers, \CommunitySources $source): void {
foreach ($servers as $server) {
foreach ($server->rooms as $room) {
$sourced_tags = $source->get_room_tags($room->get_room_identifier());
$room->add_tags($sourced_tags);
}
}
}
/**
* Collect the rooms among the given Community servers.
* @param CommunityServer[] $servers Array of Community servers.
@ -664,8 +783,10 @@
* @return array|false Associative data about rooms if successful.
*/
private function fetch_room_list(): array|bool {
global $FAST_FETCH_MODE;
$base_url = $this->base_url;
list($rooms, $downgrade) = curl_get_contents_downgrade("$base_url/rooms?all=1");
list($rooms, $downgrade) = curl_get_contents_downgrade("$base_url/rooms?all=1", retries: $FAST_FETCH_MODE ? 2 : 4);
if (!$rooms) {
log_info("Failed fetching /rooms.");
return false;
@ -726,14 +847,15 @@
* @return bool True if successful, false otherwise.
*/
function fetch_rooms(): bool {
global $FAST_FETCH_MODE;
$this->log_details();
$base_url = $this->base_url;
// Check reachability before polling too much.
if (count($this->room_hints) >= 2) {
log_info("Checking reachability for $base_url first...");
log_value($this->room_hints);
if (!url_is_reachable($base_url)) {
if (!url_is_reachable($base_url, retries: $FAST_FETCH_MODE ? 1 : 4)) {
log_warning("Reachability test failed by $base_url.");
return false;
}
@ -757,6 +879,8 @@
* @return bool True iff no key conflict has arised and we have a pubkey.
*/
function fetch_pubkey() {
global $FAST_FETCH_MODE;
if (empty($this->rooms)) {
log_warning("Server has no rooms to poll for public key");
return false;
@ -764,10 +888,14 @@
$has_pubkey = $this->has_pubkey();
if ($has_pubkey && $FAST_FETCH_MODE) {
return true;
}
$preview_url = $this->rooms[0]->get_preview_url();
log_info("Fetching pubkey from $preview_url");
$room_view = curl_get_contents($preview_url, retries: $has_pubkey ? 1 : 5);
$room_view = curl_get_contents($preview_url, retries: $has_pubkey || $FAST_FETCH_MODE ? 1 : 5);
if (!$room_view) {
log_debug("Failed to fetch room preview from $preview_url.");
@ -797,5 +925,16 @@
return true;
}
/**
* Checks whether this server belongs to Session / OPTF.
*/
function is_official_server() {
global $KNOWN_PUBKEYS;
return (
$this->base_url == "https://open.getsession.org" &&
$this->pubkey == $KNOWN_PUBKEYS['open.getsession.org']
);
}
}
?>

@ -0,0 +1,317 @@
<?php
require_once 'utils.php';
require_once '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 .
$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,182 @@
<?php
require_once '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);
}
public function jsonSerialize(): mixed {
// Only used for passing to DOM
$details = get_object_vars($this);
$details['text'] = html_sanitize($details['text']);
$details['description'] = html_sanitize($details['description']);
$details['type'] = $this->get_tag_type();
return $details;
}
/**
* @param \CommunityTag[] $tags
* @return string[]
*/
public static function cacheable_room_tags(array $tags) {
return array_map(
'CommunityTag::__toString',
array_filter($tags, function(\CommunityTag $tag) {
return $tag->type == TagType::USER_TAG;
})
);
}
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", "modded", "not modded"];
private const SHOWCASED_TAGS = ["official"];
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);
}
}
?>

@ -122,9 +122,10 @@
* to an unreachable host.
*/
function curl_get_response(string $url, int $retries, $stop_on_codes = [404], $curlopts = []) {
global $FAST_FETCH_MODE;
// use separate timeouts to reliably get data from Chinese server with repeated tries
$connecttimeout = 2; // wait at most X seconds to connect
$timeout = 3; // can't take longer than X seconds for the whole curl process
$timeout = $FAST_FETCH_MODE ? 1.5 : 3; // can't take longer than X seconds for the whole curl process
$sleep = 2; // sleep between tries in seconds
// takes at most ($timeout + $sleep) * $retries seconds
@ -200,6 +201,18 @@
return $query_components['public_key'];
}
/**
* Computes a room's ID from a join URL.
* @param string $join_url Join URL for Session Community.
* @return string Room identifier per our format.
*/
function url_get_room_id(string $join_url) {
$room_token = url_get_token($join_url);
$pubkey = url_get_pubkey($join_url);
$pubkey_4 = substr($pubkey, 0, 4);
return "$room_token+$pubkey_4";
}
/**
* Extracts join links that match $REGEX_JOIN_LINK.
* @param ?string $html Text to find join URLs in.

@ -5,70 +5,79 @@
<dialog id="details-modal">
<div id="details-modal-contents">
<div id="details-modal-close">
&times;
</div>
<div id="details-modal-start">
<h1 id="details-modal-title">
<a
id="details-modal-community-name"
data-hydrate-with="name;preview_link:href"
title="Open preview in new tab"
></a>
</h1>
<p id="details-modal-description">
<span>Description:</span>
<span id="details-modal-description-inner" data-hydrate-with="description"></span>
</p>
<gap></gap>
<div id="details-modal-room-info">
<p>
Language: <span data-hydrate-with="language_flag"></span>
</p>
<p>
Users: <span data-hydrate-with="users"></span>
</p>
<p>
Server:
&times;
</div>
<div id="details-modal-pane">
<div id="details-modal-start">
<h1 id="details-modal-title">
<a
title="Open server in new tab"
data-hydrate-with="hostname;hostname:href"
target="_blank"
rel="noopener noreferrer"
id="details-modal-community-name"
data-hydrate-with="name;preview_link:href"
title="Open preview in new tab"
></a>
</h1>
<p id="details-modal-description">
<span id="details-modal-description-inner" data-hydrate-with="description"></span>
</p>
<gap></gap>
<div id="details-modal-room-info">
<p id="details-modal-room-tags">
<p>
<button
id="details-modal-copy-button"
data-hydrate-with="join_link:data-href"
title="Click here to copy this Community's join link"
>
Copy join link
</button>
<button
id="details-modal-copy-staff-id"
data-hydrate-with="staff:data-staff"
title="Copy the mention for a random staff member"
>
Copy mod ID
</button>
</p>
</p>
<p id="details-modal-language">
Language: <span
id="details-modal-language-flag"
data-hydrate-with="language_flag"
></span>
</p>
<p id="details-modal-users">
Users: <span data-hydrate-with="users"></span>
</p>
<p id="details-modal-host">
Server:
<a
title="Open server in new tab"
data-hydrate-with="hostname;hostname:href"
target="_blank"
rel="noopener noreferrer"
></a>
</p>
</div>
</div>
<gap></gap>
<div id="details-modal-end">
<img
src=""
id="details-modal-qr-code"
title="Community join link encoded as QR code"
>
<div id="details-modal-qr-code-label">
Scan QR code in Session to join
<span
id="details-modal-qr-code-label-name"
>'<span data-hydrate-with="name"></span>'</span>
</div>
</div>
</div>
<gap></gap>
<div id="details-modal-end">
<img
src=""
id="details-modal-qr-code"
title="Community join link encoded as QR code"
<p>
<button
id="details-modal-copy-button"
data-hydrate-with="join_link:data-href"
title="Click here to copy this Community's join link"
>
<div id="details-modal-qr-code-label">
Scan QR code in Session to join
<br>
'<span data-hydrate-with="name"></span>'
</div>
</div>
Copy join link
</button>
<button
id="details-modal-copy-staff-id"
data-hydrate-with="staff:data-staff"
title="Copy the mention for a random staff member"
>
Copy mod ID
</button>
</p>
</div>
</dialog>

@ -66,6 +66,7 @@
$hostname = html_sanitize($hostname);
$staff_json = json_encode(array_map('html_sanitize', $room->get_staff()));
$tags_json = json_encode($room->get_room_tags());
?>
<tr id="<?=$id?>" class="room-row" itemscope itemtype="https://schema.org/EntryPoint"
@ -73,14 +74,33 @@
data-pubkey="<?=$pubkey?>"
data-hostname="<?=$hostname?>"
data-staff='<?=$staff_json?>'
data-tags='<?=$tags_json?>'
>
<td class="td_identifier" itemprop="identifier"><?=$id?></td>
<td class="td_language" title="Language flag for '<?=$name?>'"><?=$language?></td>
<td class="td_name" title="Click here to preview '<?=$name?>'" itemprop="name">
<a href="<?=$preview_link?>" target="_blank" rel="noopener noreferrer">
<?=$name?>
</a>
<td class="td_name"
data-sort-by="<?=strtolower($name)?>"
>
<a
href="<?=$preview_link?>"
class="td_name-inner"
target="_blank"
title="Click here to preview '<?=$name?>'"
rel="noopener noreferrer"
itemprop="name"
><?=
$name
?></a>
<span class="room-labels-container">
<?php foreach ($room->get_room_tags() as $tag): if (CommunityTag::is_showcased_tag($tag->text)): ?>
<span
class="room-label <?=$tag->get_tag_classname()?> badge"
title="<?=$tag->description?>"
><?=
truncate($tag->text, 16)
?></span>
<?php endif; endforeach; ?>
</span>
</td>
<td
class="td_description"

@ -165,7 +165,7 @@
>Contact</a>
</nav>
</footer>
<div id="copy-snackbar"></div>
</div>
<div id="copy-snackbar"></div>
</body>
</html>

Loading…
Cancel
Save