", '','','','',' ','','','', '','','','','','','','', '','','','','','','','', '','','' ]; $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