", '€','‚','ƒ','„','…','†','‡','ˆ', '‰','Š','‹','Œ','Ž','‘','’','“', '”','•','–','—','˜','™','š','›', 'œ','ž','Ÿ' ]; $With = [ '<','>', '€','‚','ƒ','„','…','†','‡','ˆ', '‰','Š','‹','Œ','Ž','‘','’','“', '”','•','–','—','˜','™','š','›', 'œ','ž','Ÿ' ]; $Str = str_replace($Replace, $With, $Str); } return $Str; } /** * HTML-escape strings for usage in html tags */ function html_escape(mixed $str): string { return htmlspecialchars($str, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8'); } /** * reverse of html_escape */ function html_unescape(string $str): string { return html_entity_decode($str, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8'); } /** * Returns ratio */ function ratio(int $uploaded, int $downloaded, int $digits = 2): string { return match (true) { $downloaded == 0 => '∞', $uploaded <= 0 => number_format(0.0, $digits), default => number_format( (floor(($uploaded / $downloaded) * 10 ** $digits) / 10 ** $digits), $digits ), }; } /** * Gets the CSS class corresponding to a ratio */ function ratio_css(float $ratio): string { if ($ratio < 0.1) { return 'r00'; } if ($ratio < 0.2) { return 'r01'; } if ($ratio < 0.3) { return 'r02'; } if ($ratio < 0.4) { return 'r03'; } if ($ratio < 0.5) { return 'r04'; } if ($ratio < 0.6) { return 'r05'; } if ($ratio < 0.7) { return 'r06'; } if ($ratio < 0.8) { return 'r07'; } if ($ratio < 0.9) { return 'r08'; } if ($ratio < 1.0) { return 'r09'; } if ($ratio < 2.0) { return 'r10'; } if ($ratio < 5.0) { return 'r20'; } return 'r50'; } /** * Calculates and formats a ratio. */ function ratio_html(int $uploaded, int $downloaded, bool $wantColor = true): string { $ratio = ratio($uploaded, $downloaded); if ($ratio === '∞') { return $wantColor ? '∞' : '∞'; } if ($wantColor) { $ratio = sprintf('%s', ratio_css((float)$ratio), ratio($uploaded, $downloaded, 5), $ratio ); } return $ratio; } function ratio_percent(float $percent): string { return sprintf('%s', ratio_css($percent), round($percent * 100, 2) . '%', round(min(1.0, $percent) * 100, 0) . '%' ); } /** * Gets the query string of the current page, minus the parameters in $Exclude, * plus the parameters in $NewParams * * @param array $NewParams New query items to insert into the URL */ function get_url(array $Exclude = [], bool $Escape = true, bool $Sort = false, array $NewParams = []): string { $QueryItems = null; parse_str($_SERVER['QUERY_STRING'], $QueryItems); foreach ($Exclude as $Key) { unset($QueryItems[$Key]); } if ($Sort) { ksort($QueryItems); } $NewQuery = http_build_query(array_merge($QueryItems, $NewParams), ''); return $Escape ? display_str($NewQuery) : $NewQuery; } /* Format a byte value into a scaled value and its unit. * We need to have the components available separately for the * user settings page. * * Note: KiB, MiB, etc. are the IEC units, which are in base 2. * KB, MB are the SI units, which are in base 10. * * @param int $levels Number of decimal places. * Defaults to 0 if size <= 1024, 4 if size >= 1TB, otherwise 2. */ function byte_format_array(float|int|null $size, int $levels = 2): array { $size = (float)$size; $steps = 0; while (abs($size) >= 1024) { $size /= 1024; $steps++; } if (func_num_args() == 1 && $steps >= 4) { $levels++; } if ($steps == 0) { $levels = 0; } $units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; return [ 'value' => number_format($size, $levels), 'unit' => $units[$steps] ]; } /** * Format a size in bytes as a human readable string in KiB/MiB/... */ function byte_format(float|int|null $size, int $levels = 2): string { // need to maintain the number of args for the default smart formatting $format = func_num_args() == 1 ? byte_format_array($size) : byte_format_array($size, $levels); return "{$format['value']} {$format['unit']}"; } /** * Return the integer value of a formatted byte representation * E.g. 200 'MiB' => 209715200 */ function byte_unformat(float $value, string $unit): int { return (int)( $value * match ($unit) { 'KiB' => 1024, 'MiB' => 1024 ** 2, 'GiB' => 1024 ** 3, 'TiB' => 1024 ** 4, 'PiB' => 1024 ** 5, 'EiB' => 1024 ** 6, // here on out overflows 64 bit ints, so good luck with that 'ZiB' => 1024 ** 7, 'YiB' => 1024 ** 8, default => 1, } ); } /** * Format a number as a multiple of its highest power of 1000 (e.g. 10035 -> '10.04k') */ function human_format(float|int $number): string { $steps = 0; while ($number >= 1000) { $steps++; $number = $number / 1000; } return match ($steps) { 0 => (string)round($number), 1 => round($number, 2) . 'k', 2 => round($number, 2) . 'M', 3 => round($number, 2) . 'G', 4 => round($number, 2) . 'T', 5 => round($number, 2) . 'P', default => round($number, 2) . 'E + ' . $steps * 3, }; } /** * Given a formatted string of a size, get the number of bytes it represents. */ function get_bytes(string $size): int { [$value, $unit] = sscanf($size, "%f%s"); if (is_null($unit)) { return $value ? (int)round($value) : 0; } return (int)round(match (strtolower($unit[0])) { 'k' => $value * 1024, 'm' => $value * 1_048_576, 'g' => $value * 1_073_741_824, 't' => $value * 1_099_511_627_776, default => 0, }); } function byte_arithmetic(string $expression): int { if ( !preg_match( // a number with an optional SI suffix // optionally followed by plus/minus a similar number with suffix '/(-?\d+(?:\.\d*)?[kmg]?)(?:i?b)?(?:\s*([+-])\s*(\d+(?:\.\d+)?[kmg]?))?/i', $expression, $match ) ) { return 0; } $size = get_bytes($match[1]); if (count($match) === 4) { $size += get_bytes($match[3]) * ($match[2] === '-' ? -1 : 1); } return $size; } /** * Sanitize a string for use as a filename. * * @param string $name to escape * @return string contents with all OS meta-characters removed. */ function safeFilename(string $name): string { return str_replace(['"', '*', '/', ':', '<', '>', '?', '\\', '|'], '', $name); } /** * Determine the redirect header to use based on the client HTTP_REFERER or fallback * * @param string $fallback URL to use HTTP_REFERER is empty * @return string redirect URL */ function redirectUrl(string $fallback): string { return empty($_SERVER['HTTP_REFERER']) ? $fallback : $_SERVER['HTTP_REFERER']; } /** * Generate a random authentication key for a user */ function authKey(): string { return substr( urlencode_safe(signature(randomString(64), USER_AUTH_SALT)), 0, 32, ); } /** * Make sure $_GET['auth'] is the same as the user's authorization key * Should be used for any user action that relies solely on GET. */ function authorize(bool $ajax = false): void { global $Viewer; foreach (['auth', 'authkey'] as $auth) { if (isset($_REQUEST[$auth]) && $Viewer->auth() === $_REQUEST[$auth]) { return; } } Irc::sendMessage(IRC_CHAN_STATUS, "{$Viewer->username()} authorize failed on {$_SERVER['REQUEST_URI']}" . (!empty($_SERVER['HTTP_REFERER']) ? " coming from " . $_SERVER['HTTP_REFERER'] : "") ); if ($ajax) { json_die('Invalid authorization key. Go back, refresh, and try again.'); } else { Gazelle\Error400::error( 'Invalid authorization key. Go back, refresh, and try again.' ); } } function parse_user_agent(string $useragent): array { if (preg_match("/^Lidarr\/([0-9\.]+) \((.+)\)$/", $useragent, $Matches) === 1) { $OS = explode(" ", $Matches[2]); $dot = strrpos($Matches[1], '.') ?: null; $browserUserAgent = [ 'Browser' => 'Lidarr', 'BrowserVersion' => substr($Matches[1], 0, $dot), 'OperatingSystem' => $OS[0] === 'macos' ? 'macOS' : ucfirst($OS[0]), 'OperatingSystemVersion' => $OS[1] ?? null ]; } elseif (preg_match("/^VarroaMusica\/([0-9]+(?:dev)?)$/", $useragent, $Matches) === 1) { $browserUserAgent = [ 'Browser' => 'VarroaMusica', 'BrowserVersion' => str_replace('dev', '', $Matches[1]), 'OperatingSystem' => null, 'OperatingSystemVersion' => null ]; } elseif (in_array($useragent, ['Headphones/None', 'whatapi [isaaczafuta]'])) { $browserUserAgent = [ 'Browser' => $useragent, 'BrowserVersion' => null, 'OperatingSystem' => null, 'OperatingSystemVersion' => null ]; } else { $result = new WhichBrowser\Parser($useragent); $browser = $result->browser; // Unlikely as it seems, the current version of WhichBrowser\Parser can fail with // Call to a member function getName() on null // (And the PHPDoc is a lie). if (is_null($browser)) { /** @phpstan-ignore-line */ $browserUserAgent = [ 'Browser' => $useragent, 'BrowserVersion' => null, 'OperatingSystem' => 'useragent parse fail', 'OperatingSystemVersion' => null ]; } else { $browserUserAgent = [ 'Browser' => $browser->getName(), 'BrowserVersion' => explode('.', $browser->getVersion())[0], 'OperatingSystem' => $result->os->getName(), 'OperatingSystemVersion' => $result->os->getVersion() ]; } } foreach (['Browser', 'BrowserVersion', 'OperatingSystem', 'OperatingSystemVersion'] as $Key) { if ($browserUserAgent[$Key] === "") { $browserUserAgent[$Key] = null; } } return $browserUserAgent; } // phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed /** * Print JSON status result with an optional message and die. */ function json_die(array|string $status, array|string $message = "bad parameters"): never { json_print($status, $message); exit; } /** * Print JSON status result with an optional message. */ function json_print(array|string $status, array|int|string|null $message = null): void { if (is_string($status) && $status == 'success' && $message) { $response = ['status' => $status, 'response' => $message]; } elseif ($message) { $response = ['status' => $status, 'error' => $message]; } else { $response = ['status' => $status, 'response' => []]; } print(json_encode(add_json_info($response))); } function json_error(int|string $code): never { echo json_encode(add_json_info(['status' => 'failure', 'error' => $code, 'response' => []])); exit; } function json_or_error(mixed $JsonError, mixed $Error = null): never { if (defined('AJAX')) { json_error($JsonError); } else { Gazelle\Error400::error($Error ?? $JsonError); } } function add_json_info(array $info): array { if (!isset($info['info'])) { $info['info'] = [ 'source' => SITE_NAME, 'version' => 1, ]; } global $Viewer; if (!isset($info['debug']) && $Viewer instanceof \Gazelle\User && $Viewer->permitted('site_debug')) { $info['debug'] = ['queries' => \Gazelle\DB::DB()->queryList()]; if (class_exists('Sphinxql') && !empty(\Sphinxql::$Queries)) { $info['debug']['searches'] = \Sphinxql::$Queries; } } return $info; } function json_hostname(string $ip): false|string { if (isset($_SERVER['http_if_modified_since'])) { header('Status: 304 Not Modified'); return false; } header('Expires: ' . date('D, d-M-Y H:i:s \U\T\C', time() + 3600 * 24 * 120)); // 120 days header('Last-Modified: ' . date('D, d-M-Y H:i:s \U\T\C')); if (empty($ip)) { header('Status: 400 Bad Request'); return false; } $hostname = gethostbyaddr($ip); if ($hostname === false) { header('Status: 400 Bad Request'); } return json_encode(['ip' => $ip, 'hostname' => $hostname]); } function json_encode_pretty(mixed ...$data): string { return (string)json_encode( (count([...$data]) === 1) ? [...$data][0] : [...$data], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); } function dump(mixed ...$data): void { echo "
" . json_encode_pretty(...$data) . "
"; } function show(mixed ...$data): void { echo json_encode_pretty(...$data) . "\n"; } /** * Transform the values of an array by stripping off a common prefix * E.g.: * array_strip_prefix('abc_', ['abc_1', 'abc_2']) ==> ['1', '2'] */ function array_trim_prefix(string $prefix, array $list): array { return array_map(fn($v) => trim($v, $prefix), $list); } /** * Transform the keys of an array by matching those with a common prefix * and return the suffix as an int. This is especially useful for * collections of checkboxes in forms. * E.g.: you have a list of user ids from a query, [77, 1010, 6189] * These are rendered as checkboxes with values: * "user-77", "user-1010" and "user-6189". When the form is submitted, * the first checkbox is unchecked and the remaining two are checked. * In this case, array_key_extract_suffix("user-", $_POST) returns * [1010, 6189] * @param array $list */ function array_key_extract_suffix(string $prefix, array $list, bool $cast = true): array { return array_map( fn(string $v): int|string => $cast ? (int)explode('-', $v)[1] : explode('-', $v)[1], array_values(array_filter( array_keys($list), fn(string $v) => str_starts_with($v, $prefix) )) ); } /** * Similar to array_key_extract_suffix but makes the suffix the index key and retains the value. * E.g.: ["user-123" => 3, "user-456" => 99, "other" => 33] will become [123 => 3, 456 => 99] * * @param array $list */ function array_key_filter_and_map(string $prefix, array $list, bool $cast = true): array { $out = []; foreach ($list as $k => $v) { if (!str_starts_with($k, $prefix)) { continue; } $key = $cast ? (int)explode('-', $k, 2)[1] : explode('-', $k, 2)[1]; $out[$key] = $v; } return $out; } /** * Extract torrent ids from a string * "http://localhost:7001/torrents.php?id=2316&torrentid=4575#torrent4575 9317,173" * returns [4575, 9317, 173] */ function extract_torrent_id(string $text): array { if (preg_match_all('/(?:[?&]torrentid=|(? 1) { array_pop($words); $short = implode(' ', $words); } } if ($ellipsis) { $short .= "\xE2\x80\xA6"; // U+2026 HORIZONTAL ELLIPSIS } return $short; } function proxyCheck(string $IP): bool { foreach (ALLOWED_PROXY as $allowed) { //based on the wildcard principle it should never be shorter if (strlen($IP) < strlen($allowed)) { continue; } //since we're matching bit for bit iterating from the start for ($j = 0, $jl = strlen($IP); $j < $jl; ++$j) { //completed iteration and no inequality if ($j === $jl - 1 && $IP[$j] === $allowed[$j]) { return true; } //wildcard if ($allowed[$j] === '*') { return true; } //inequality found if ($IP[$j] !== $allowed[$j]) { break; } } } return false; } function sanitize_irc_nick(string $nick): string { $nick = iconv("UTF-8", "ASCII//TRANSLIT", $nick); if (!$nick) { return ''; } $nick = str_replace(['.', ':', '#', ' '], ['[dot]', '[col]', '[hash]', '_'], $nick); $nick = preg_replace('/[^a-z0-9\[\]|_-]/i', '', $nick); if (is_numeric(substr($nick, 0, 1))) { $nick = '_' . $nick; } return $nick; } /*** Time and date functions ***/ /* * Returns a by default but can optionally return the raw time * difference in text (e.g. "16 hours and 28 minutes", "1 day, 18 hours"). */ function time_diff( string|null $TimeStamp, int $Levels = 2, bool $span = true, string|false $StartTime = false, bool $hideAgo = false, ): string { return Time::diff($TimeStamp, $Levels, $span, $StartTime, $hideAgo); } /*** Paranoia functions ***/ // The following are used throughout the site: // uploaded, ratio, downloaded: stats // lastseen: approximate time the user last used the site // uploads: the full list of the user's uploads // uploads+: just how many torrents the user has uploaded // snatched, seeding, leeching: the list of the user's snatched torrents, seeding torrents, and leeching torrents respectively // snatched+, seeding+, leeching+: the length of those lists respectively // uniquegroups, perfectflacs: the list of the user's uploads satisfying a particular criterion // uniquegroups+, perfectflacs+: the length of those lists // If "uploads+" is disallowed, so is "uploads". So if "uploads" is in the array, the user is a little paranoid, "uploads+", very paranoid. // The following are almost only used in /sections/user/user.php: // requiredratio // requestsfilled_count: the number of requests the user has filled // requestsfilled_bounty: the bounty thus earned // requestsfilled_list: the actual list of requests the user has filled // requestsvoted_...: similar // artistsadded: the number of artists the user has added // torrentcomments: the list of comments the user has added to torrents // + // collages: the list of collages the user has created // + // collagecontribs: the list of collages the user has contributed to // + // invitedcount: the number of users this user has directly invited /** * Return whether currently logged in user can see $Property on a user with $Paranoia, $UserClass and (optionally) $UserID * If $Property is an array of properties, returns whether currently logged in user can see *all* $Property ... * * $Property The property to check, or an array of properties. * $Paranoia The paranoia level to check against. * $UserClass The user class to check against (Staff can see through paranoia of lower classed staff) * $UserID Optional. The user ID of the person being viewed * return mixed 1 representing the user has normal access * 2 representing that the paranoia was overridden, * false representing access denied. */ function check_paranoia(string $Property, string|array $Paranoia, int $UserClass, int|false $UserID = false): int|false { if (!is_array($Paranoia)) { $Paranoia = unserialize($Paranoia); } if (!is_array($Paranoia)) { $Paranoia = []; } global $Viewer; if (($UserID !== false) && ($Viewer->id == $UserID)) { return PARANOIA_ALLOWED; } $May = !in_array($Property, $Paranoia) && !in_array($Property . '+', $Paranoia); if ($May) { return PARANOIA_ALLOWED; } if ($Viewer->permitted('users_override_paranoia', $UserClass)) { return PARANOIA_OVERRIDDEN; } switch ($Property) { case 'downloaded': case 'ratio': case 'uploaded': case 'lastseen': if ($Viewer->permitted('users_mod', $UserClass)) { return PARANOIA_OVERRIDDEN; } break; case 'snatched': case 'snatched+': if ($Viewer->permitted('users_view_torrents_snatchlist', $UserClass)) { return PARANOIA_OVERRIDDEN; } break; case 'uploads': case 'uploads+': case 'seeding': case 'seeding+': case 'leeching': case 'leeching+': if ($Viewer->permitted('users_view_seedleech', $UserClass)) { return PARANOIA_OVERRIDDEN; } break; case 'invitedcount': if ($Viewer->permitted('users_view_invites', $UserClass)) { return PARANOIA_OVERRIDDEN; } break; } return false; } function httpProxy(): ?string { $proxy = getenv('HTTP_PROXY'); if ($proxy !== false) { return $proxy; } elseif (HTTP_PROXY != false) { return HTTP_PROXY; } return null; } function urlencode_safe(string $string): string { return rtrim(strtr(base64_encode($string), '+/', '-_'), '='); } function urldecode_safe(string $string): string { return base64_decode(str_pad(strtr($string, '-_', '+/'), strlen($string) % 4, '=', STR_PAD_RIGHT)); } function signature(string $data, string $salt): string { return urlencode_safe(hash_hmac(DIGEST_ALGO, $data, $salt, binary: true)); } function image_cache_signature(string $url, int|null $epoch = null, string $secret = IMAGE_CACHE_SECRET): string { return urlencode_safe(substr( hash_hmac(DIGEST_ALGO, $url, $secret . date('oW', $epoch ?? time()), binary: true), 0, 12) ); } /** * Transform a URL with an optional resize directive to a image cache url * E.g. https://example.com/image.jpg => /i/full/bf27c278bc5b/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWFnZS5qcGc */ function image_cache_encode( string $url, int $height = 0, int $width = 0, bool $proxy = false, int|null $epoch = null, CacheBucket $bucket = CacheBucket::standard, string $secret = IMAGE_CACHE_SECRET, bool $cache = IMAGE_CACHE_ENABLED, ): string { if (!$cache || str_starts_with($url, STATIC_SERVER) || !str_starts_with($url, 'http')) { return $url; } $encode = urlencode_safe($url) . ($proxy ? '/proxy' : ''); $sig = image_cache_signature("{$bucket->value}/$encode", $epoch, $secret); if ($proxy) { $spec = 'full'; } else { $spec = match ($height || $width) { true => ($height ?: '') . 'x' . ($width ?: ''), false => 'full', }; } return IMAGE_CACHE_HOST . "/{$bucket->value}/$spec/$sig/$encode"; } /** * Test whether an image cache url is valid (check the signature) */ function image_cache_valid( string $url, int|null $epoch = null, string $secret = IMAGE_CACHE_SECRET ): bool { // skip over slashes in http://xxx/ // if the /proxy specifier has been used, it is combined in $encode [,,, $bucket, $spec, $sig, $encode] = explode('/', $url, 7); return $sig === image_cache_signature($bucket . '/' . $encode, $epoch, $secret); } /** * Country code table * Used to make ALPHA-2 country codes to the country name. */ function ISO3166_2(): array { $data = file_get_contents(__DIR__ . '/../misc/ISO-3166-2.json'); return $data === false ? [] : json_decode($data, true); } /** * World topology details * Used in HighCharts world maps */ function worldTopology(): array { $data = file_get_contents(__DIR__ . '/../misc/world.topo.json'); return $data === false ? [] : json_decode($data, true); } function object_generator(\Gazelle\BaseManager $manager, array $idList): \Generator { foreach ($idList as $id) { $object = $manager->findById($id); if ($object instanceof \Gazelle\Base) { yield $object; } } }