mirror of
https://github.com/OPSnet/Gazelle.git
synced 2026-01-16 18:04:34 -05:00
930 lines
29 KiB
PHP
930 lines
29 KiB
PHP
<?php
|
|
|
|
// This is a file of miscellaneous functions that are called so damn often
|
|
// that it'd just be annoying to stick them in namespaces.
|
|
|
|
use Gazelle\Enum\CacheBucket;
|
|
use Gazelle\Util\Irc;
|
|
use Gazelle\Util\Time;
|
|
use Gazelle\Util\Type;
|
|
|
|
/**
|
|
* Return true if the given string is an integer. The original Gazelle developers
|
|
* must have thought the only numbers out there were integers when naming this function.
|
|
*/
|
|
function is_number(mixed $integer): bool {
|
|
return filter_var($integer, FILTER_VALIDATE_INT) !== false;
|
|
}
|
|
|
|
/**
|
|
* Awful anglo-centric hack for handling plurals ;-)
|
|
*
|
|
* @param int $n the number
|
|
* @param string $plural override the default 's' with something else e.g. 'es'
|
|
* @return string '' if 1, otherwise 's'
|
|
*/
|
|
function plural(int $n, string $plural = 's'): string {
|
|
return $n == 1 ? '' : $plural;
|
|
}
|
|
|
|
/**
|
|
* Awful anglo-centric hack for handling articles
|
|
*
|
|
* @param int $n the number
|
|
* @param string $article string to use if you don't want the default 'a' e.g. 'an'
|
|
*/
|
|
function article(int $n, $article = 'a'): string {
|
|
return $n == 1 ? $article : (string)$n;
|
|
}
|
|
|
|
/**
|
|
* HTML-escape a string for output.
|
|
*/
|
|
function display_str(mixed $Str): string {
|
|
if (is_null($Str) || is_array($Str)) {
|
|
return '';
|
|
}
|
|
if ($Str != '' && !is_number($Str)) {
|
|
$Str = make_utf8($Str);
|
|
$Str = htmlspecialchars($Str, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8', false);
|
|
$Str = preg_replace("/&(?![A-Za-z]{0,4}\w{2,3};|#[0-9]{2,6};)/m", '&', $Str);
|
|
|
|
$Replace = [
|
|
"<",">",
|
|
'€','‚','ƒ','„','…','†','‡','ˆ',
|
|
'‰','Š','‹','Œ','Ž','‘','’','“',
|
|
'”','•','–','—','˜','™','š','›',
|
|
'œ','ž','Ÿ'
|
|
];
|
|
|
|
$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|false {
|
|
return match (true) {
|
|
$downloaded == 0 && $uploaded == 0 => false,
|
|
$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 === false) {
|
|
return '--';
|
|
}
|
|
if ($ratio === '∞') {
|
|
return $wantColor ? '<span class="tooltip r99" title="Infinite">∞</span>' : '∞';
|
|
}
|
|
if ($wantColor) {
|
|
$ratio = sprintf('<span class="tooltip %s" title="%s">%s</span>',
|
|
ratio_css((float)$ratio),
|
|
ratio($uploaded, $downloaded, 5),
|
|
$ratio
|
|
);
|
|
}
|
|
return $ratio;
|
|
}
|
|
|
|
function ratio_percent(float $percent): string {
|
|
return sprintf('<span class="tooltip %s" title="%s">%s</span>',
|
|
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 "<pre>" . json_encode_pretty(...$data) . "</pre>";
|
|
}
|
|
|
|
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<string, true> $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<string, int|true> $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=|(?<![:=#])\b)(\d+)/', trim($text), $match)) {
|
|
return array_map('intval', $match[1]);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Utility function that unserializes an array, and then if the unserialization fails,
|
|
* it'll then return an empty array instead of a null or false which will break downstream
|
|
* things that require an incoming array
|
|
*
|
|
* @param string $array
|
|
* @return array
|
|
*/
|
|
function unserialize_array($array) {
|
|
$array = empty($array) ? [] : unserialize($array);
|
|
return (empty($array)) ? [] : $array;
|
|
}
|
|
|
|
/**
|
|
* Helper function to return an string of N elements from an array.
|
|
*
|
|
* (e.g. [2, 4, 6] into a list of query placeholders (e.g. '?,?,?')
|
|
* By default '?' is used, but a custom placeholder may be specified,
|
|
* such as '(?)' or '(?, now(), 100)', for use in a bulk insert.
|
|
*
|
|
* @param array $list The list of elements
|
|
* @param string $placeholder ('?' by default).
|
|
*/
|
|
function placeholders(array $list, string $placeholder = '?'): string {
|
|
return implode(',', array_fill(0, count($list), $placeholder));
|
|
}
|
|
|
|
/**
|
|
* Parse a string to determine if it valid UTF-8
|
|
*/
|
|
function is_utf8(string $s): bool {
|
|
return (bool)preg_match('/
|
|
^(?:
|
|
[\x09\x0A\x0D\x20-\x7E] # ASCII
|
|
| [\xC2-\xDF][\x80-\xBF] # non-overlong 2-byte
|
|
| \xE0[\xA0-\xBF][\x80-\xBF] # excluding overlongs
|
|
| [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2} # straight 3-byte
|
|
| \xED[\x80-\x9F][\x80-\xBF] # excluding surrogates
|
|
| \xF0[\x90-\xBF][\x80-\xBF]{2} # planes 1-3
|
|
| [\xF1-\xF3][\x80-\xBF]{3} # planes 4-15
|
|
| \xF4[\x80-\x8F][\x80-\xBF]{2} # plane 16
|
|
)*$
|
|
/xs', $s
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Detect the encoding of a string and transform it to UTF-8.
|
|
*/
|
|
function make_utf8(?string $str): string {
|
|
if (is_null($str) || $str === '' || is_utf8($str)) {
|
|
return $str;
|
|
}
|
|
$encoding = mb_detect_encoding($str, 'UTF-8, ISO-8859-1', true);
|
|
return $encoding === 'ISO-8859-1'
|
|
? (string)mb_convert_encoding($str, 'UTF-8', $encoding)
|
|
: $str;
|
|
}
|
|
|
|
/**
|
|
* Generate a random string drawn from alphanumeric characters
|
|
* but omitting lowercase l, uppercase I and O (to avoid confusion).
|
|
*
|
|
* @param int $len
|
|
* @return string random alphanumeric string
|
|
*/
|
|
function randomString($len = 32) {
|
|
$alphabet = str_split('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ0123456789');
|
|
$max = count($alphabet);
|
|
$mask = 2 ** (int)ceil(log($len, 2)) - 1;
|
|
$out = '';
|
|
while (strlen($out) < $len) {
|
|
$n = ord(openssl_random_pseudo_bytes(1)) & $mask;
|
|
if ($n < $max) {
|
|
$out .= $alphabet[$n];
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Shorten a string
|
|
*
|
|
* @param string $text string to cut
|
|
* @param int $maxLength cut at length
|
|
* @param bool $force force cut at length instead of at closest word
|
|
* @param bool $ellipsis Show dots at the end
|
|
* @return string formatted string
|
|
*/
|
|
function shortenString(string $text, int $maxLength, bool $force = false, bool $ellipsis = true): string {
|
|
if (mb_strlen($text, 'UTF-8') <= $maxLength) {
|
|
return $text;
|
|
}
|
|
if ($force) {
|
|
$short = mb_substr($text, 0, $maxLength, 'UTF-8');
|
|
} else {
|
|
$short = mb_substr($text, 0, $maxLength, 'UTF-8');
|
|
$words = explode(' ', $short);
|
|
if (count($words) > 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 <span> 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', /** @phpstan-ignore-line */
|
|
};
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
}
|