@ -1,60 +1,109 @@
<?php
/**
* \file
* Represent Session Communities and Session Open Group Servers.
*/
require_once 'languages/language-flags.php';
require_once 'servers/known-servers.php';
require_once 'servers/tags.php';
require_once 'utils/fetching-coroutines.php';
require_once 'assets/room-icons.php';
require_once 'assets/room-invites.php';
$MINUTE_SECONDS = 60;
$HOUR_SECONDS = 60 * $MINUTE_SECONDS;
$DAY_SECONDS = 24 * $HOUR_SECONDS;
$WEEK_SECONDS = 7 * $DAY_SECONDS;
require_once 'utils/numeric.php';
/**
* Representation of Session Community room.
*/
class CommunityRoom implements JsonSerializable {
/** @var CommunityServer $server Server this room belongs to. */
/**
* @var CommunityServer $server
* Session Open Group Server this room belongs to.
*/
public readonly object $server;
/** @var ?int $active_users Number of active users in the defined period. */
/**
* @var int|null $active_users
* Number of active users in the defined period.
*/
public readonly ?int $active_users;
/** @var ?int $active_users_cutoff Period for `$active_users`, in seconds. */
/**
* @var int|null $active_users_cutoff
* Period for `$active_users`, in seconds.
*/
public readonly ?int $active_users_cutoff;
/** @var string $token Room name in Community API. */
/**
* @var string $token
* Unique room identifier within server.
*/
public readonly string $token;
/** @var ?string $name User-facing name given to Community. */
/**
* @var string|null $name
* User-facing name of Community.
*/
public readonly ?string $name;
/** @var ?string[] $admins The mixed Session IDs of public room admins. */
/**
* @var string[]|null $admins
* The mixed Session IDs of public room admins.
*/
public readonly ?array $admins;
/** @var ?string[] $moderators The mixed Session IDs of public room moderators. */
/**
* @var string[]|null $moderators
* The mixed Session IDs of public room moderators.
*/
public readonly ?array $moderators;
/** @var ?float $created UNIX timestamp of room creation, in seconds. */
/**
* @var float|null $created
* UNIX timestamp of room creation, in seconds.
*/
public readonly ?float $created;
/** @var ?string $description User-facing description given to Community. */
/**
* @var string|null $description
* User-facing description given to Community.
*/
public readonly ?string $description;
/** @var ?int $image_id Optional file ID for this room's icon, as served under the room files. */
/**
* @var int|null $image_id
* File number for this room's icon; optional.
*/
public readonly ?int $image_id;
/** @var ?int $info_updates Monotonic integer counter that increases
* whenever the room's metadata changes. */
/**
* @var int|null $info_updates
* Monotonic integer counter that increases whenever the room's metadata changes.
*/
public readonly ?int $info_updates;
/** @var ?int $message_sequence Monotonic room post counter that
* increases each time a message is posted, edited, or deleted in this room. */
/**
* @var int|null $message_sequence
* Monotonic room post counter that increases each time a message is posted, edited, or deleted in this room.
*/
public readonly ?int $message_sequence;
/**
* @var ?bool $read
* @var bool|nul l $read
* This boolean flag indicates whether a regular user
* has permission to read messages in this room.
*/
public readonly ?bool $read;
/**
* @var ? bool $upload
* @var bool|nul l $upload
* This boolean flag indicates whether a regular user
* has permission to upload files to this room.
*/
public readonly ?bool $upload;
/**
* @var ? bool $write
* @var bool|nul l $write
* This boolean flag indicates whether a regular user
* has permission to write messages to this room.
*/
@ -69,7 +118,7 @@
*/
private array $tags = [];
private function __construct(\ CommunityServer $server, array $details) {
private function __construct(CommunityServer $server, array $details) {
$this->server = $server;
$this->active_users = $details['active_users'];
$this->active_users_cutoff = $details['active_users_cutoff'];
@ -89,6 +138,9 @@
$this->extract_tags_from_description();
}
/**
* Regular expression matching tags specified in the Community description.
*/
private const DESCRIPTION_TAGS_SPECIFICATION = '/(#[^#()@., ]+(?:,?\s*|\s+|$))+\s*$/';
@ -109,7 +161,7 @@
/**
* Pre-processes SOGS data by treating description-trailing hashtags as room tags.
*/
function extract_tags_from_description() {
private function extract_tags_from_description() {
$matches = [];
if (!preg_match(CommunityRoom::DESCRIPTION_TAGS_SPECIFICATION, $this->description, $matches)) {
return;
@ -135,14 +187,14 @@
/**
* Returns true if room should not be reflected in listings.
*/
function room_ is_off_record(): bool {
public function is_off_record(): bool {
return !$this->read;
}
/**
* Return information for JSON serialization.
* Produce associative data for JSON serialization.
*/
function jsonSerialize(): array {
public function jsonSerialize(): array {
$details = get_object_vars($this);
unset($details['server']);
$details['tags'] = $this->get_raw_tags();
@ -152,9 +204,9 @@
}
/**
* Return information for JSON serialization in listing .
* Produce associative data for JSON serialization to Community listings .
*/
function to_listing_data(): array {
public function to_listing_data(): array {
$details = get_object_vars($this);
unset($details['server']);
unset($details['tags']);
@ -170,7 +222,7 @@
}
/**
* Create a CommunityRoom instance from loaded data.
* Create a CommunityRoom instance from associative data.
* @param CommunityServer $server
*/
public static function from_details($server, array $details) {
@ -183,7 +235,7 @@
}
/**
* Create an array of CommunityRoom instances from loaded data.
* Create an array of CommunityRoom instances from associative data.
* @param array[] $details
* @return CommunityRoom[]
*/
@ -194,12 +246,12 @@
}
/**
* Sorts Community rooms in-place by the given string property.
* @param \ CommunityRoom[] $rooms Rooms to sort by given key.
* Sort Community rooms in-place by the given string property.
* @param CommunityRoom[] $rooms Rooms to sort by given key.
* @param string $key String property of CommunityRoom to sort by.
*/
public static function sort_rooms_str(array & $rooms, string $key) {
usort($rooms, function(\ CommunityRoom $a, \ CommunityRoom $b) use ($key) {
usort($rooms, function(CommunityRoom $a, CommunityRoom $b) use ($key) {
return strcmp(
$a->$key,
$b->$key
@ -208,11 +260,11 @@
}
/**
* Sorts Community rooms in-place by their server's public key.
* @param \ CommunityRoom[] $rooms Rooms to sort by server pubkey.
* Sort Community rooms in-place by their server's public key.
* @param CommunityRoom[] $rooms Rooms to sort by server pubkey.
*/
public static function sort_rooms_by_pubkey(array & $rooms) {
usort($rooms, function(\ CommunityRoom $a, \ CommunityRoom $b) {
usort($rooms, function(CommunityRoom $a, CommunityRoom $b) {
return strcmp(
$a->server->get_pubkey(),
$b->server->get_pubkey()
@ -221,45 +273,28 @@
}
/**
* Returns array of staff Session IDs.
* Return all known Community staff Session IDs.
* @return string[]
*/
function get_staff() {
function get_staff(): array {
return array_unique(
[...$this->admins, ...$this->moderators]
);
}
/**
* Returns seconds elapsed since room was created.
* Return duration in seconds since room was created.
*/
function get_age(): float {
return time() - $this->created;
}
/**
* Formats the period over which active users are co un ted as a duration string.
* @return string Active user cutoff period for this room, expressed in days .
* Formats the active user cutoff p erio d as a duration string.
* @return string|null Period over which active users are counted in huamn-readable form .
*/
function format_user_cutoff_period(): ?string {
global $WEEK_SECONDS, $DAY_SECONDS, $HOUR_SECONDS, $MINUTE_SECONDS;
$active_users_cutoff = $this->active_users_cutoff;
if ($active_users_cutoff >= $WEEK_SECONDS) {
return floor($active_users_cutoff / $WEEK_SECONDS) . ' week(s)';
}
if ($active_users_cutoff >= $DAY_SECONDS) {
return floor($active_users_cutoff / $DAY_SECONDS) . ' day(s)';
}
if ($active_users_cutoff >= $HOUR_SECONDS) {
return floor($active_users_cutoff / $HOUR_SECONDS) . ' hour(s)';
}
if ($active_users_cutoff >= $MINUTE_SECONDS) {
return floor($active_users_cutoff / $MINUTE_SECONDS) . 'minute(s)';
}
return floor($active_users_cutoff) . 's';
return format_duration($this->active_users_cutoff);
}
/**
@ -306,7 +341,7 @@
}
/**
* Return our format of room identifier.
* Return a globally unique room identifier.
* @return string String in the form `token+pubkey[:4]`.
*/
function get_room_identifier(): string {
@ -323,12 +358,16 @@
}
/**
* Add string tags to the Community.
* @param string[] $tags
*/
public function add_tags(array $tags) {
$this->tags = [...$this->tags, ...$tags];
}
/**
* Check whether the Community's text fields contain adult keywords.
*/
private function has_nsfw_keywords(): bool {
// Description not included due to false positives.
$blob =
@ -344,12 +383,21 @@
return false;
}
/**
* Check whether the given list matches the current Community or its parent server.
* @param string[] $filter
* Array of unique room identifiers, server pubkeys and/or server hostnames.
* @return bool True if the array matches the Community, false otherwise.
*/
public function matched_by_list(array $filter): bool {
return in_array($this->get_room_identifier(), $filter) ||
in_array($this->server->get_pubkey(), $filter) ||
in_array($this->server->get_hostname(), $filter);
}
/**
* Determine whether the Community is not safe for work.
*/
public function rated_nsfw(): bool {
global $NSFW_INCLUDE, $NSFW_EXCLUDE;
@ -360,6 +408,10 @@
return $this->has_nsfw_keywords() || $this->matched_by_list($NSFW_INCLUDE);
}
/**
* Determine the safety of the Community's icon.
* @return 1 if safe, -1 if unsafe, 0 if unknown.
*/
public function icon_safety(): int {
global $ICON_ALLOWLIST, $ICON_BLOCKLIST;
@ -373,16 +425,33 @@
return 0;
}
public const USERS_PER_STAFF = 50;
public const USERS_PER_STAFF_WARNING = 200;
public const MINIMUM_STAFF = 2;
/**
* Estimate for minimum number of users covered by one member of Community staff.
*/
private const USERS_PER_STAFF = 50;
/**
* Estimate for maximum number of users covered by one member of Community staff.
*/
private const USERS_PER_STAFF_WARNING = 200;
/**
* Number of minimum staff needed to moderate a Community.
*/
private const MINIMUM_STAFF = 2;
/**
* Estimate whether the Community has enough staff.
*/
private function has_good_staff_rating(): bool {
$recommended_staff_count = $this->active_users / CommunityRoom::USERS_PER_STAFF;
$staff_count = count($this->get_staff());
return $staff_count >= $recommended_staff_count & & $staff_count >= CommunityRoom::MINIMUM_STAFF;
}
/**
* Estimate whether the Community does not have enough staff.
*/
private function has_poor_staff_rating(): bool {
if ($this->active_users < = 3) {
return false;
@ -392,20 +461,22 @@
}
/**
* @return string[]
* Return the string tags associated with this Community
* @return string[] Array of unique string tags.
*/
function get_raw_tags(): array {
private function get_raw_tags(): array {
return array_unique(array_values($this->tags));
}
/**
* Return the deroved tags associated with this room.
* Return the derived tags associated with this room.
* @return CommunityTag[] Array of tags.
*/
function get_derived_tags(): array {
private function get_derived_tags(): array {
global $ROOMS_USED_BY_PROJECT, $TESTING_INCLUDE;
/**
* @var \ CommunityTag[] $derived_tags
* @var CommunityTag[] $derived_tags
*/
$derived_tags = [];
@ -502,7 +573,7 @@
/**
* Return the tags associated with this room.
* @return \CommunityTag[] Tags as string array .
* @return CommunityTag[] Array of tags .
*/
function get_room_tags(): array {
$user_tags = CommunityTag::from_user_tags($this->tags, remove_redundant: true);
@ -510,11 +581,29 @@
}
}
/**
* Specifies criteria used to merge data in CommunityServer instances.
*/
enum CommunityServerMergeStrategy {
/**
* @var SameHostname
* Strategy considering two servers to be identical if they share a hostname.
*/
case SameHostname;
/**
* @var SamePublicKey
* Strategy considering two servers to be identical if they share a SOGS public key.
*/
case SamePublicKey;
public function should_merge_servers(\CommunityServer $a, \CommunityServer $b) {
/**
* Determine whether two CommunityServer instances are identical under the given criteria.
* @param CommunityServer $a CommunityServer to compare.
* @param CommunityServer $b CommunityServer to compare.
* @return bool True if we know that the given CommunityServer instances refer to the same server.
*/
public function should_merge_servers(CommunityServer $a, CommunityServer $b): bool {
return match ($this) {
CommunityServerMergeStrategy::SameHostname => $a->get_hostname() == $b->get_hostname(),
CommunityServerMergeStrategy::SamePublicKey => $a->get_pubkey() == $b->get_pubkey()
@ -526,12 +615,24 @@
* Class representing Session Community server hosting Community rooms.
*/
class CommunityServer implements JsonSerializable {
/** @var string $base_url The root URL of this server. */
/**
* @var string $base_url
* The root URL of this server.
*/
public string $base_url = "";
/** @var string[] $pubkey_candidates Possible SOGS protocol pubkeys for this server. */
/**
* @var string[] $pubkey_candidates
* Possible SOGS protocol pubkeys for this server.
**/
private array $pubkey_candidates = [];
/** @var ?\CommunityRoom[] Array of rooms hosted by this server. */
/**
* @var CommunityRoom[]|null $rooms
* Array of Communities hosted by this server.
*/
public ?array $rooms = null;
/**
* @var string[] $room_hints
* This array contains fallback room tokens collected from links.
@ -599,7 +700,10 @@
usort($servers, 'CommunityServer::compare_by_pubkey');
}
private function absorb_pubkeys_from($server): void {
/**
* Absorb candidates for the SOGS public key from a duplicate server instance.
*/
private function merge_pubkeys_from(CommunityServer $server): void {
$this->pubkey_candidates = [
...$this->pubkey_candidates,
...$server->pubkey_candidates
@ -612,7 +716,7 @@
*
* @return True if successful, false in case of mismatch.
*/
private function merge_from($server, \ CommunityServerMergeStrategy $strategy): bool {
private function merge_from($server, CommunityServerMergeStrategy $strategy): bool {
// Merge room hint information.
$this->room_hints = [
...$this->room_hints,
@ -624,7 +728,7 @@
log_error("SameHostname merging: Merged servers differ in hostname");
exit(1);
}
$this->absorb _pubkeys_from($server);
$this->merge _pubkeys_from($server);
} else if ($strategy == CommunityServerMergeStrategy::SamePublicKey) {
if ($this->get_pubkey() != $server->get_pubkey()) {
log_error("SamePublicKey merging: Merged servers differ in public key");
@ -652,7 +756,7 @@
*/
private static function ensure_merge_consistency(array $servers) {
// Exclude servers with merge errors.
$servers = array_filter($servers, function(\ CommunityServer $server) {
$servers = array_filter($servers, function(CommunityServer $server) {
return !$server->merge_error;
});
@ -670,7 +774,7 @@
* @param CommunityServer[] $servers Servers sorted by given attribute.
* @param string $method Method name to retrieve attribute from server.
*/
private static function merge_by(& $servers, \ CommunityServerMergeStrategy $strategy) {
private static function merge_by(& $servers, CommunityServerMergeStrategy $strategy) {
// Backwards-merging to preserve indexing for unprocessed servers.
// Merging only makes sense for pairs, so stop at $i = 1.
for ($i = count($servers) - 1; $i >= 1; $i--) {
@ -683,7 +787,7 @@
}
/**
* Write details about this server to debug log.
* Write details about this server to the debug log.
*/
private function log_details() {
$base_url = $this->base_url;
@ -694,9 +798,9 @@
}
/**
* Filters the given servers to remove URL duplicates.
* Filter the given servers to remove URL duplicates.
* @param CommunityServer[] $servers Servers to merge by URL.
* @return CommunityServer[] Merged URL-unique servers .
* @return CommunityServer[] Servers merged by URL .
*/
public static function dedupe_by_url($servers) {
CommunityServer::sort_by_url($servers);
@ -709,9 +813,10 @@
}
/**
* Filters the given servers to remove pubkey duplicates.
* Filter the given servers to remove pubkey duplicates.
* Servers must already have a determined public key.
* @param CommunityServer[] $servers Servers to merge by public key.
* @return CommunityServer[] Merged pubkey-unique servers.
* @return CommunityServer[] Servers merged by public key-
*/
public static function dedupe_by_pubkey($servers) {
CommunityServer::sort_by_pubkey($servers);
@ -818,10 +923,10 @@
/**
* Add to the given servers additional data extracted from our sources.
* @param \ CommunityServer[] $servers
* @param \ CommunitySources $source
* @param CommunityServer[] $servers
* @param CommunitySources $source
*/
static function source_additional_info(array $servers, \ CommunitySources $source): void {
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());
@ -990,8 +1095,8 @@
/**
* Returns the room of the given token, or null if one does not exist.
*/
function get_room_by_token(string $token): \ CommunityRoom | null {
$candidates = array_filter($this->rooms, function(\ CommunityRoom $room) use ($token) {
function get_room_by_token(string $token): CommunityRoom | null {
$candidates = array_filter($this->rooms, function(CommunityRoom $room) use ($token) {
return $room->token == $token;
});
@ -1004,7 +1109,8 @@
}
/**
* @return \Generator< int , CurlHandle , CurlHandle | false , array | null >
* Fetch Community data from the server and yield required network requests.
* @return Generator< string , CurlHandle , CurlHandle | false , array | null >
*/
private function fetch_room_list_coroutine(): Generator {
global $FAST_FETCH_MODE;
@ -1042,6 +1148,7 @@
}
/**
* Fetch individual rooms and yield required network requests.
* @return Generator< int , CurlHandle , CurlHandle | false , array | null >
*/
private function fetch_room_hints_coroutine(): Generator {
@ -1104,6 +1211,10 @@
return $rooms;
}
/**
* Check whether the Community server is reachable and yield required requests.
* @return Generator< int , CurlHandle , CurlHandle | false , array | null >
*/
function check_reachability_coroutine() {
global $FAST_FETCH_MODE;
$base_url = $this->base_url;
@ -1135,7 +1246,8 @@
}
/**
* @return \Generator< int , CurlHandle , CurlHandle | false , bool >
* Fetch Community data from public or observed information and yield required network requests.
* @return Generator< int , CurlHandle , CurlHandle | false , bool >
*/
function fetch_rooms_coroutine(): Generator {
$this->log_details();
@ -1165,7 +1277,8 @@
}
/**
* @return \Generator< int , CurlHandle , CurlHandle | false , bool >
* Fetch the Session Open Group Server public key and yield required network requests.
* @return Generator< int , CurlHandle , CurlHandle | false , bool >
*/
function fetch_pubkey_coroutine(): Generator {
global $FAST_FETCH_MODE;
@ -1225,7 +1338,7 @@
}
/**
* @param \ CommunityServer $servers
* @param CommunityServer $servers
*/
public static function fetch_assets(array $servers) {
// Sequential in each server, see note in fetch_room_hints_coroutine()
@ -1242,7 +1355,7 @@
}
/**
* Checks whether this server belongs to Session / OPTF.
* Checks whether this server belongs to Session / Oxen Privacy Tech Foundation .
*/
function is_official_server() {
global $KNOWN_PUBKEYS;