the browser-facing portion of osu!
at master 1904 lines 51 kB view raw
1<?php 2 3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4// See the LICENCE file in the repository root for full licence text. 5 6use App\Exceptions\FastImagesizeFetchException; 7use App\Exceptions\HasExtraExceptionData; 8use App\Http\Controllers\RankingController; 9use App\Libraries\Base64Url; 10use App\Libraries\LocaleMeta; 11use App\Models\LoginAttempt; 12use Egulias\EmailValidator\EmailValidator; 13use Egulias\EmailValidator\Validation\NoRFCWarningsValidation; 14use Illuminate\Database\Migrations\Migration; 15use Illuminate\Http\Request as HttpRequest; 16use Illuminate\Support\Arr; 17use Illuminate\Support\HtmlString; 18use Sentry\State\Scope; 19 20function api_version(): int 21{ 22 $request = request(); 23 $version = $request->attributes->get('api_version'); 24 if ($version === null) { 25 $version = get_int($request->header('x-api-version')) ?? 0; 26 $request->attributes->set('api_version', $version); 27 } 28 29 return $version; 30} 31 32function array_reject_null(iterable $array): array 33{ 34 $ret = []; 35 foreach ($array as $item) { 36 if ($item !== null) { 37 $ret[] = $item; 38 } 39 } 40 41 return $ret; 42} 43 44/* 45 * Like array_search but returns null if not found instead of false. 46 * Strict mode only. 47 */ 48function array_search_null($value, $array) 49{ 50 return null_if_false(array_search($value, $array, true)); 51} 52 53function atom_id(string $namespace, $id = null): string 54{ 55 return 'tag:'.request()->getHttpHost().',2019:'.$namespace.($id === null ? '' : "/{$id}"); 56} 57 58function background_image($url): string 59{ 60 return present($url) 61 ? sprintf(' style="background-image:url(\'%s\');" ', e($url)) 62 : ''; 63} 64 65function beatmap_timestamp_format($ms) 66{ 67 $s = $ms / 1000; 68 $ms = $ms % 1000; 69 $m = $s / 60; 70 $s = $s % 60; 71 72 return sprintf('%02d:%02d.%03d', $m, $s, $ms); 73} 74 75/** 76 * Allows using both html-safe and non-safe text inside `{{ }}` directive. 77 */ 78function blade_safe($html): HtmlString 79{ 80 return new HtmlString($html); 81} 82 83/** 84 * Like cache_remember_with_fallback but with a mutex that only allows a single process to run the callback. 85 */ 86function cache_remember_mutexed(string $key, $seconds, $default, callable $callback, ?callable $exceptionHandler = null) 87{ 88 static $oneMonthInSeconds = 30 * 24 * 60 * 60; 89 $fullKey = "{$key}:with_fallback_v2"; 90 $data = Cache::get($fullKey); 91 92 $now = time(); 93 if ($data === null || $data['expires_at'] < $now) { 94 $lockKey = "{$key}:lock"; 95 // this is arbitrary, but you've got other problems if it takes more than 5 minutes. 96 // the max is because cache()->add() doesn't work so well with funny values. 97 $lockTime = min(max($seconds, 60), 300); 98 99 // only the first caller that manages to setnx runs this. 100 if (Cache::add($lockKey, 1, $lockTime)) { 101 try { 102 $data = [ 103 'expires_at' => $now + $seconds, 104 'value' => $callback(), 105 ]; 106 107 Cache::put($fullKey, $data, max($oneMonthInSeconds, $seconds * 10)); 108 } catch (Exception $e) { 109 $handled = $exceptionHandler !== null && $exceptionHandler($e); 110 111 if (!$handled) { 112 // Log and continue with data from the first ::get. 113 log_error($e); 114 } 115 } finally { 116 Cache::forget($lockKey); 117 } 118 } 119 } 120 121 return $data['value'] ?? $default; 122} 123 124/** 125 * Like Cache::remember but always save for one month or 10 * $seconds (whichever is longer) 126 * and return old value if failed getting the value after it expires. 127 */ 128function cache_remember_with_fallback($key, $seconds, $callback) 129{ 130 static $oneMonthInSeconds = 30 * 24 * 60 * 60; 131 132 $fullKey = "{$key}:with_fallback_v2"; 133 134 $data = Cache::get($fullKey); 135 136 $now = time(); 137 if ($data === null || $data['expires_at'] < $now) { 138 try { 139 $data = [ 140 'expires_at' => $now + $seconds, 141 'value' => $callback(), 142 ]; 143 144 Cache::put($fullKey, $data, max($oneMonthInSeconds, $seconds * 10)); 145 } catch (Exception $e) { 146 // Log and continue with data from the first ::get. 147 log_error($e); 148 } 149 } 150 151 return $data['value'] ?? null; 152} 153 154/** 155 * Marks the content in the key as expired but leaves the fallback set amount of time. 156 * Use with cache_remember_mutexed when the previous value needs to be shown while a key is being updated. 157 * 158 * @param string $key The key of the item to expire. 159 * @param int $duration The duration the fallback should still remain available for, in seconds. Default: 1 month. 160 * @return void 161 */ 162function cache_expire_with_fallback(string $key, int $duration = 2592000) 163{ 164 $fullKey = "{$key}:with_fallback_v2"; 165 166 $data = Cache::get($fullKey); 167 168 if ($data === null) { 169 return; 170 } 171 172 $now = time(); 173 if ($data['expires_at'] < $now) { 174 return; 175 } 176 177 $data['expires_at'] = $now - 3600; 178 Cache::put($fullKey, $data, $duration); 179} 180 181// Just normal Cache::forget but with the suffix. 182function cache_forget_with_fallback($key) 183{ 184 return Cache::forget("{$key}:with_fallback_v2"); 185} 186 187function captcha_enabled() 188{ 189 return $GLOBALS['cfg']['turnstile']['site_key'] !== '' && $GLOBALS['cfg']['turnstile']['secret_key'] !== ''; 190} 191 192function captcha_login_triggered() 193{ 194 if (!captcha_enabled()) { 195 return false; 196 } 197 198 if ($GLOBALS['cfg']['osu']['captcha']['threshold'] === 0) { 199 $triggered = true; 200 } else { 201 $loginAttempts = LoginAttempt::find(request()->getClientIp()); 202 $triggered = $loginAttempts && $loginAttempts->failed_attempts >= $GLOBALS['cfg']['osu']['captcha']['threshold']; 203 } 204 205 return $triggered; 206} 207 208function class_modifiers_flat(array $modifiersArray): array 209{ 210 $ret = []; 211 212 foreach ($modifiersArray as $modifiers) { 213 if (is_array($modifiers)) { 214 // either "$modifier => boolean" or "$i => $modifier|null" 215 foreach ($modifiers as $k => $v) { 216 if (is_bool($v)) { 217 if ($v) { 218 $ret[] = $k; 219 } 220 } elseif ($v !== null) { 221 $ret[] = $v; 222 } 223 } 224 } elseif (is_string($modifiers)) { 225 $ret[] = $modifiers; 226 } 227 } 228 229 return $ret; 230} 231 232function class_with_modifiers(string $className, ...$modifiersArray): string 233{ 234 $class = $className; 235 236 foreach (class_modifiers_flat($modifiersArray) as $m) { 237 $class .= " {$className}--{$m}"; 238 } 239 240 return $class; 241} 242 243function cleanup_cookies() 244{ 245 $host = request()->getHost(); 246 247 // don't do anything for ip address access 248 if (filter_var($host, FILTER_VALIDATE_IP) !== false) { 249 return; 250 } 251 252 $hostParts = explode('.', $host); 253 254 // don't do anything for single word domain 255 if (count($hostParts) === 1) { 256 return; 257 } 258 259 $domains = [$host, '']; 260 261 // phpcs:ignore 262 while (count($hostParts) > 1) { 263 array_shift($hostParts); 264 $domains[] = implode('.', $hostParts); 265 } 266 267 // remove duplicates and current session domain 268 $sessionDomain = presence(ltrim($GLOBALS['cfg']['session']['domain'], '.')) ?? ''; 269 $domains = array_diff(array_unique($domains), [$sessionDomain]); 270 271 foreach (['locale', 'osu_session', 'XSRF-TOKEN'] as $key) { 272 foreach ($domains as $domain) { 273 cookie()->queueForget($key, null, $domain); 274 } 275 } 276} 277 278function config_set(string $key, $value): void 279{ 280 Config::set($key, $value); 281 $GLOBALS['cfg'] = Config::all(); 282} 283 284function css_group_colour($group) 285{ 286 return '--group-colour: '.(optional($group)->colour ?? 'initial'); 287} 288 289function css_var_2x(string $key, ?string $url): ?HtmlString 290{ 291 if (!present($url)) { 292 return null; 293 } 294 295 $url = e($url); 296 $url2x = retinaify($url); 297 298 return blade_safe("{$key}: url('{$url}'); {$key}-2x: url('{$url2x}');"); 299} 300 301function current_locale_meta(): LocaleMeta 302{ 303 return locale_meta(app()->getLocale()); 304} 305 306function cursor_decode($cursorString): ?array 307{ 308 if (is_string($cursorString) && present($cursorString)) { 309 $cursor = json_decode(Base64Url::decode($cursorString) ?? '', true); 310 311 if (is_array($cursor)) { 312 return $cursor; 313 } 314 } 315 316 return null; 317} 318 319function cursor_encode(?array $cursor): ?string 320{ 321 return $cursor === null 322 ? null 323 : Base64Url::encode(json_encode($cursor)); 324} 325 326function cursor_for_response(?array $cursor): array 327{ 328 return [ 329 'cursor' => $cursor, 330 'cursor_string' => cursor_encode($cursor), 331 ]; 332} 333 334function cursor_from_params($params): ?array 335{ 336 if (is_array($params)) { 337 $cursor = cursor_decode($params['cursor_string'] ?? null) ?? $params['cursor'] ?? null; 338 339 if (is_array($cursor)) { 340 return $cursor; 341 } 342 } 343 344 return null; 345} 346 347function datadog_increment(string $stat, array|string $tags = null, int $value = 1) 348{ 349 Datadog::increment( 350 stats: $GLOBALS['cfg']['datadog-helper']['prefix_web'].'.'.$stat, 351 tags: $tags, 352 value: $value 353 ); 354} 355 356function datadog_timing(callable $callable, $stat, array $tag = null) 357{ 358 $startTime = microtime(true); 359 360 $result = $callable(); 361 362 $endTime = microtime(true); 363 364 if (app('clockwork.support')->isEnabled()) { 365 // spaces used so clockwork doesn't run across the whole screen. 366 $description = $stat 367 .' '.($tag['type'] ?? null) 368 .' '.($tag['index'] ?? null); 369 370 $clockworkEvent = clock()->event($description); 371 $clockworkEvent->start = $startTime; 372 $clockworkEvent->end = $endTime; 373 } 374 375 Datadog::microtiming($stat, $endTime - $startTime, 1, $tag); 376 377 return $result; 378} 379 380function db_unsigned_increment($column, $count) 381{ 382 if ($count >= 0) { 383 $value = "`{$column}` + {$count}"; 384 } else { 385 $change = -$count; 386 $value = "IF(`{$column}` < {$change}, 0, `{$column}` - {$change})"; 387 } 388 389 return DB::raw($value); 390} 391 392function default_mode() 393{ 394 return optional(auth()->user())->playmode ?? 'osu'; 395} 396 397function flag_url($countryCode) 398{ 399 $chars = str_split($countryCode); 400 $hexEmojiChars = array_map(fn ($chr) => dechex(mb_ord($chr) + 127397), $chars); 401 $baseFileName = implode('-', $hexEmojiChars); 402 403 return "/assets/images/flags/{$baseFileName}.svg"; 404} 405 406function format_month_column(\DateTimeInterface $date): string 407{ 408 return $date->format('ym'); 409} 410 411function format_rank(?int $rank): string 412{ 413 return $rank !== null ? '#'.i18n_number_format($rank) : '-'; 414} 415 416function get_valid_locale($requestedLocale) 417{ 418 if (in_array($requestedLocale, $GLOBALS['cfg']['app']['available_locales'], true)) { 419 return $requestedLocale; 420 } 421} 422 423function hsl_to_hex($h, $s, $l) 424{ 425 $c = (1 - abs(2 * $l - 1)) * $s; 426 $x = $c * (1 - abs(fmod($h / 60, 2) - 1)); 427 $m = $l - ($c / 2); 428 429 [$r, $g, $b] = match (true) { 430 $h < 60 => [$c, $x, 0], 431 $h < 120 => [$x, $c, 0], 432 $h < 180 => [0, $c, $x], 433 $h < 240 => [0, $x, $c], 434 $h < 300 => [$x, 0, $c], 435 default => [$c, 0, $x] 436 }; 437 438 $r = round(($r + $m) * 255); 439 $g = round(($g + $m) * 255); 440 $b = round(($b + $m) * 255); 441 442 return sprintf('#%02x%02x%02x', $r, $g, $b); 443} 444 445function html_entity_decode_better($string) 446{ 447 // ENT_HTML5 to handle more named entities (&apos;, etc?). 448 return html_entity_decode($string, ENT_QUOTES | ENT_HTML5, 'UTF-8'); 449} 450 451function html_excerpt($body, $limit = 300) 452{ 453 $body = html_entity_decode_better(replace_tags_with_spaces($body)); 454 455 return e(truncate($body, $limit)); 456} 457 458function img2x(array $attributes) 459{ 460 if (!present($attributes['src'] ?? null)) { 461 return; 462 } 463 464 $src2x = retinaify($attributes['src']); 465 $attributes['srcset'] = "{$attributes['src']} 1x, {$src2x} 1.5x"; 466 467 return tag('img', $attributes); 468} 469 470function locale_meta(string $locale): LocaleMeta 471{ 472 return LocaleMeta::find($locale); 473} 474 475function trim_unicode(?string $value) 476{ 477 return preg_replace('/(^\s+|\s+$)/u', '', $value); 478} 479 480function truncate(string $text, $limit = 100, $ellipsis = '...') 481{ 482 if (mb_strlen($text) > $limit) { 483 return mb_substr($text, 0, $limit - mb_strlen($ellipsis)).$ellipsis; 484 } 485 486 return $text; 487} 488 489function truncate_inclusive(string $text, int $limit): string 490{ 491 if (mb_strlen($text) > $limit) { 492 return mb_substr($text, 0, $limit).'...'; 493 } 494 495 return $text; 496} 497 498function json_date(?DateTime $date): ?string 499{ 500 return $date === null ? null : $date->format('Y-m-d'); 501} 502 503function json_time(?DateTime $time): ?string 504{ 505 return $time === null ? null : $time->format(DateTime::ATOM); 506} 507 508function log_error($exception, ?array $sentryTags = null): void 509{ 510 Log::error($exception); 511 log_error_sentry($exception, $sentryTags); 512} 513 514function log_error_sentry(Throwable $exception, ?array $tags = null): ?string 515{ 516 // Fallback in case error happening before config is initialised 517 if (!($GLOBALS['cfg']['sentry']['dsn'] ?? false)) { 518 return null; 519 } 520 521 return Sentry\withScope(function (Scope $scope) use ($exception, $tags) { 522 $currentUser = Auth::user(); 523 $userContext = $currentUser === null 524 ? ['id' => null] 525 : [ 526 'id' => $currentUser->getKey(), 527 'username' => $currentUser->username, 528 ]; 529 530 $scope->setUser($userContext); 531 foreach ($tags ?? [] as $key => $value) { 532 $scope->setTag($key, $value); 533 } 534 535 if ($exception instanceof HasExtraExceptionData) { 536 $scope->setExtras($exception->getExtras()); 537 $contexts = $exception->getContexts(); 538 539 foreach ($contexts as $name => $value) { 540 $scope->setContext($name, $value ?? []); 541 } 542 } 543 544 return Sentry\captureException($exception); 545 }); 546} 547 548function logout() 549{ 550 \Session::delete(); 551 Auth::logout(); 552 cleanup_cookies(); 553} 554 555function markdown($input, $preset = 'default') 556{ 557 static $converter; 558 559 App\Libraries\Markdown\OsuMarkdown::PRESETS[$preset] ?? $preset = 'default'; 560 561 if (!isset($converter[$preset])) { 562 $converter[$preset] = new App\Libraries\Markdown\OsuMarkdown($preset); 563 } 564 565 return $converter[$preset]->load($input)->html(); 566} 567 568function markdown_plain(?string $input): string 569{ 570 if ($input === null) { 571 return ''; 572 } 573 574 static $converter; 575 576 if (!isset($converter)) { 577 $converter = new League\CommonMark\CommonMarkConverter([ 578 'allow_unsafe_links' => false, 579 'html_input' => 'escape', 580 ]); 581 } 582 583 return $converter->convert($input)->getContent(); 584} 585 586function max_offset($page, $limit) 587{ 588 $offset = ($page - 1) * $limit; 589 590 return max(0, min($offset, $GLOBALS['cfg']['osu']['pagination']['max_count'] - $limit)); 591} 592 593function oauth_token(): ?App\Models\OAuth\Token 594{ 595 return Request::instance()->attributes->get(App\Http\Middleware\AuthApi::REQUEST_OAUTH_TOKEN_KEY); 596} 597 598function osu_trans($key = null, $replace = [], $locale = null) 599{ 600 $translator = app('translator'); 601 602 if (is_null($key)) { 603 return $translator; 604 } 605 606 if (!trans_exists($key, $locale)) { 607 $locale = $GLOBALS['cfg']['app']['fallback_locale']; 608 } 609 610 return $translator->get($key, $replace, $locale, false); 611} 612 613function osu_trans_choice($key, $number, array $replace = [], $locale = null) 614{ 615 if (!trans_exists($key, $locale)) { 616 $locale = $GLOBALS['cfg']['app']['fallback_locale']; 617 } 618 619 if (is_array($number) || $number instanceof Countable) { 620 $number = count($number); 621 } 622 623 if (!isset($replace['count_delimited'])) { 624 $replace['count_delimited'] = i18n_number_format($number, null, null, null, $locale); 625 } 626 627 return app('translator')->choice($key, $number, $replace, $locale); 628} 629 630function osu_url(string $key): ?string 631{ 632 $url = $GLOBALS['cfg']['osu']['urls'][$key] ?? null; 633 634 if (($url[0] ?? null) === '/') { 635 $url = $GLOBALS['cfg']['osu']['urls']['base'].$url; 636 } 637 638 return $url; 639} 640 641function pack_str($str) 642{ 643 return pack('ccH*', 0x0b, strlen($str), bin2hex($str)); 644} 645 646function product_quantity_options($product, $selected = null) 647{ 648 if ($product->stock === null) { 649 $max = $product->max_quantity; 650 } else { 651 $max = min($product->max_quantity, $product->stock); 652 } 653 654 $opts = []; 655 for ($i = 1; $i <= $max; $i++) { 656 $opts[] = [ 657 'label' => osu_trans_choice('common.count.item', $i), 658 'selected' => $i === $selected, 659 'value' => $i, 660 ]; 661 } 662 663 // include selected value separately if it's out of range. 664 if ($selected !== null && $selected > $max) { 665 $opts[] = [ 666 'label' => osu_trans_choice('common.count.item', $selected), 667 'selected' => true, 668 'value' => $selected, 669 ]; 670 } 671 672 return $opts; 673} 674 675function read_image_properties($path) 676{ 677 try { 678 return null_if_false(getimagesize($path)); 679 } catch (Exception $_e) { 680 return null; 681 } 682} 683 684function read_image_properties_from_string($string) 685{ 686 try { 687 return null_if_false(getimagesizefromstring($string)); 688 } catch (Exception $_e) { 689 return null; 690 } 691} 692 693// use this instead of strip_tags when <br> and <p> need to be converted to space 694function replace_tags_with_spaces($body) 695{ 696 return preg_replace('#<[^>]+>#', ' ', $body); 697} 698 699function request_country($request = null) 700{ 701 return $request === null 702 ? Request::header('CF_IPCOUNTRY') 703 : $request->header('CF_IPCOUNTRY'); 704} 705 706function require_login($textKey, $linkTextKey) 707{ 708 return osu_trans($textKey, ['link' => link_to( 709 '#', 710 osu_trans($linkTextKey), 711 [ 712 'class' => 'js-user-link', 713 'title' => osu_trans('users.anonymous.login_link'), 714 ], 715 )]); 716} 717 718function spinner(?array $modifiers = null) 719{ 720 return tag('div', [ 721 'class' => class_with_modifiers('la-ball-clip-rotate', $modifiers), 722 ]); 723} 724 725function strip_utf8_bom($input) 726{ 727 if (substr($input, 0, 3) === "\xEF\xBB\xBF") { 728 return substr($input, 3); 729 } 730 731 return $input; 732} 733 734function tag($element, $attributes = [], $content = null) 735{ 736 $attributeString = ''; 737 738 foreach ($attributes ?? [] as $key => $value) { 739 $attributeString .= ' '.$key.'="'.e($value).'"'; 740 } 741 742 return '<'.$element.$attributeString.'>'.($content ?? '').'</'.$element.'>'; 743} 744 745// Handles case where crowdin fills in untranslated key with empty string. 746function trans_exists($key, $locale) 747{ 748 $translated = app('translator')->get($key, [], $locale, false); 749 750 return present($translated) && $translated !== $key; 751} 752 753function obscure_email($email) 754{ 755 $email = explode('@', $email); 756 757 if (!present($email[0]) || !present($email[1] ?? null)) { 758 return '<unknown>'; 759 } 760 761 return mb_substr($email[0], 0, 1).'***'.'@'.$email[1]; 762} 763 764function currency($price, $precision = 2, $zeroShowFree = true) 765{ 766 $price = round($price, $precision); 767 if ($price === 0.00 && $zeroShowFree) { 768 return osu_trans('store.free'); 769 } 770 771 return 'US$'.i18n_number_format($price, null, null, $precision); 772} 773 774/** 775 * Compares 2 money values from payment processor in a sane manner. 776 * i.e. not a float. 777 * 778 * @param float $a money value A 779 * @param float $b money value B 780 * @return int 0 if equal, 1 if $a > $b, -1 if $a < $b 781 */ 782function compare_currency(float $a, float $b): int 783{ 784 return (int) ($a * 100) <=> (int) ($b * 100); 785} 786 787function error_popup($message, $statusCode = 422) 788{ 789 return response(['error' => $message], $statusCode); 790} 791 792function ext_view($view, $data = null, $type = null, $status = null) 793{ 794 static $types = [ 795 'atom' => 'application/atom+xml', 796 'html' => 'text/html', 797 'js' => 'application/javascript', 798 'json' => 'application/json', 799 'opensearch' => 'application/opensearchdescription+xml', 800 'rss' => 'application/rss+xml', 801 ]; 802 803 return response()->view( 804 $view, 805 $data ?? [], 806 $status ?? 200, 807 ['Content-Type' => $types[$type ?? 'html']] 808 ); 809} 810 811function from_app_url(?HttpRequest $request = null) 812{ 813 $headers = ($request ?? Request::instance())->headers; 814 $appUrl = $GLOBALS['cfg']['app']['url']; 815 // Add trailing slash for referer check to avoid matching 816 // https://osu.web.domain.com. 817 // This assumes app.url doesn't contain trailing slash. 818 return $headers->get('origin') === $appUrl 819 || str_starts_with($headers->get('referer'), "{$appUrl}/"); 820} 821 822function forum_user_link(int $id, string $username, string|null $colour, int|null $currentUserId): string 823{ 824 $icon = tag('span', [ 825 'class' => 'forum-user-icon', 826 'style' => user_color_style($colour, 'background-color'), 827 ]); 828 829 $link = link_to_user($id, blade_safe($icon.e($username)), null, []); 830 if ($currentUserId === $id) { 831 $link = tag('strong', null, $link); 832 } 833 834 return $link; 835} 836 837function is_api_request(): bool 838{ 839 $url = rawurldecode(Request::getPathInfo()); 840 return str_starts_with($url, '/api/') 841 || str_starts_with($url, '/_lio/'); 842} 843 844function is_http(string $url): bool 845{ 846 return str_starts_with($url, 'http://') 847 || str_starts_with($url, 'https://'); 848} 849 850function is_json_request(): bool 851{ 852 return is_api_request() || Request::expectsJson(); 853} 854 855function is_valid_email_format(?string $email): bool 856{ 857 if ($email === null) { 858 return false; 859 } 860 861 static $validator; 862 $validator ??= new EmailValidator(); 863 static $lexer; 864 $lexer ??= new NoRFCWarningsValidation(); 865 866 return $validator->isValid($email, $lexer); 867} 868 869function is_sql_unique_exception(\Throwable $ex): bool 870{ 871 return $ex instanceof Illuminate\Database\UniqueConstraintViolationException; 872} 873 874function js_localtime($date) 875{ 876 $formatted = json_time($date); 877 878 return "<time class='js-localtime' datetime='{$formatted}'>{$formatted}</time>"; 879} 880 881function page_description($extra) 882{ 883 $parts = ['osu!', page_title()]; 884 885 if (present($extra)) { 886 $parts[] = $extra; 887 } 888 889 return blade_safe(implode(' » ', array_map('e', $parts))); 890} 891 892// sync with pageTitleMap in header-v4.tsx 893function page_title() 894{ 895 $currentRoute = app('route-section')->getCurrent(); 896 $checkLocale = $GLOBALS['cfg']['app']['fallback_locale']; 897 898 $actionKey = "{$currentRoute['namespace']}.{$currentRoute['controller']}.{$currentRoute['action']}"; 899 $actionKey = match ($actionKey) { 900 'forum.topic_watches_controller.index' => 'main.home_controller.index', 901 'main.account_controller.edit' => 'main.home_controller.index', 902 'main.beatmapset_watches_controller.index' => 'main.home_controller.index', 903 'main.follows_controller.index' => 'main.home_controller.index', 904 'main.friends_controller.index' => 'main.home_controller.index', 905 default => $actionKey, 906 }; 907 $controllerKey = "{$currentRoute['namespace']}.{$currentRoute['controller']}._"; 908 $controllerKey = match ($controllerKey) { 909 'main.artist_tracks_controller._' => 'main.artists_controller._', 910 'main.store_controller._' => 'store._', 911 'multiplayer.rooms_controller._' => 'main.ranking_controller._', 912 'ranking.daily_challenge_controller._' => 'main.ranking_controller._', 913 default => $controllerKey, 914 }; 915 $namespaceKey = "{$currentRoute['namespace']}._"; 916 $namespaceKey = match ($namespaceKey) { 917 'admin_forum._' => 'admin._', 918 'teams._' => 'main.teams_controller._', 919 default => $namespaceKey, 920 }; 921 $keys = [ 922 "page_title.{$actionKey}", 923 "page_title.{$controllerKey}", 924 "page_title.{$namespaceKey}", 925 ]; 926 927 foreach ($keys as $key) { 928 if (trans_exists($key, $checkLocale)) { 929 return osu_trans($key); 930 } 931 } 932 933 return 'unknown'; 934} 935 936function ujs_redirect($url, $status = 200) 937{ 938 $request = Request::instance(); 939 // This is done mainly to work around fetch ignoring/removing anchor from page redirect. 940 // Reference: https://github.com/hotwired/turbo/issues/211 941 if ($request->headers->get('x-turbo-request-id') !== null) { 942 if ($status === 200 && $request->getMethod() !== 'GET') { 943 // Turbo doesn't like 200 response on non-GET requests. 944 // Reference: https://github.com/hotwired/turbo/issues/22 945 $status = 201; 946 } 947 948 return response($url, $status, ['content-type' => 'text/osu-turbo-redirect']); 949 } elseif ($request->ajax() && $request->getMethod() !== 'GET') { 950 return ext_view('layout.ujs-redirect', compact('url'), 'js', $status); 951 } else { 952 // because non-3xx redirects make no sense. 953 if ($status < 300 || $status > 399) { 954 $status = 302; 955 } 956 957 return redirect($url, $status); 958 } 959} 960 961// strips combining characters after x levels deep 962function unzalgo(?string $text, int $level = 2) 963{ 964 return preg_replace("/(\pM{{$level}})\pM+/u", '\1', $text); 965} 966 967function route_redirect($path, $target, string $method = 'get') 968{ 969 return Route::$method($path, '\App\Http\Controllers\RedirectController')->name("redirect:{$target}"); 970} 971 972function timeago($date) 973{ 974 $formatted = json_time($date); 975 976 return "<time class='js-timeago' datetime='{$formatted}'>{$formatted}</time>"; 977} 978 979function link_to(string $url, HtmlString|string $text, array $attributes = []): HtmlString 980{ 981 return blade_safe(tag('a', [...$attributes, 'href' => $url], make_blade_safe($text))); 982} 983 984function link_to_user($id, $username = null, $color = null, $classNames = null) 985{ 986 if ($id instanceof App\Models\User) { 987 $username ?? ($username = $id->username); 988 $color ?? ($color = $id->user_colour); 989 $id = $id->getKey(); 990 } 991 $id = presence(e($id)); 992 $username = e($username); 993 $style = user_color_style($color, 'color'); 994 995 if ($classNames === null) { 996 $classNames = ['user-name']; 997 } 998 999 $class = implode(' ', $classNames); 1000 1001 if ($id === null) { 1002 return "<span class='{$class}'>{$username}</span>"; 1003 } else { 1004 $class .= ' js-usercard'; 1005 // FIXME: remove `rawurlencode` workaround when fixed upstream. 1006 // Reference: https://github.com/laravel/framework/issues/26715 1007 $url = e(route('users.show', rawurlencode($id))); 1008 1009 return "<a class='{$class}' data-user-id='{$id}' href='{$url}' style='{$style}'>{$username}</a>"; 1010 } 1011} 1012 1013function make_blade_safe(HtmlString|string $text): HtmlString 1014{ 1015 return $text instanceof HtmlString ? $text : blade_safe(e($text)); 1016} 1017 1018function issue_icon($issue) 1019{ 1020 switch ($issue) { 1021 case 'added': 1022 return 'fas fa-cogs'; 1023 case 'assigned': 1024 return 'fas fa-user'; 1025 case 'confirmed': 1026 return 'fas fa-exclamation-triangle'; 1027 case 'resolved': 1028 return 'far fa-check-circle'; 1029 case 'duplicate': 1030 return 'fas fa-copy'; 1031 case 'invalid': 1032 return 'far fa-times-circle'; 1033 } 1034} 1035 1036function build_url($build) 1037{ 1038 return route('changelog.build', [optional($build->updateStream)->name ?? 'unknown', $build->version]); 1039} 1040 1041function post_url($topicId, $postId, $jumpHash = true, $tail = false) 1042{ 1043 if ($topicId === null) { 1044 return null; 1045 } 1046 1047 $postIdParamKey = 'start'; 1048 if ($tail === true) { 1049 $postIdParamKey = 'end'; 1050 } 1051 1052 return route('forum.topics.show', ['topic' => $topicId, $postIdParamKey => $postId]); 1053} 1054 1055function wiki_image_url(string $path, bool $fullUrl = true) 1056{ 1057 static $placeholder = '_WIKI_IMAGE_'; 1058 1059 return str_replace($placeholder, $path, route('wiki.image', ['path' => $placeholder], $fullUrl)); 1060} 1061 1062function wiki_url($path = null, $locale = null, $api = null, $fullUrl = true) 1063{ 1064 $path = $path === null ? 'Main_page' : str_replace(['%2F', '%23'], ['/', '#'], rawurlencode($path)); 1065 1066 $params = [ 1067 'path' => 'WIKI_PATH', 1068 'locale' => $locale ?? App::getLocale(), 1069 ]; 1070 1071 if ($api ?? is_api_request()) { 1072 $route = 'api.wiki.show'; 1073 } else { 1074 if ($path === 'Sitemap') { 1075 return route('wiki.sitemap', $params['locale'], $fullUrl); 1076 } 1077 1078 if (starts_with("{$path}/", 'Legal/')) { 1079 $path = ltrim(substr($path, strlen('Legal')), '/'); 1080 $route = 'legal'; 1081 } else { 1082 $route = 'wiki.show'; 1083 } 1084 } 1085 1086 return rtrim(str_replace($params['path'], $path, route($route, $params, $fullUrl)), '/'); 1087} 1088 1089function bbcode($text, $uid = null, $options = []) 1090{ 1091 return (new App\Libraries\BBCodeFromDB($text, $uid, $options))->toHTML(); 1092} 1093 1094function bbcode_for_editor($text, $uid = null) 1095{ 1096 return (new App\Libraries\BBCodeFromDB($text, $uid))->toEditor(); 1097} 1098 1099function concat_path($paths) 1100{ 1101 return implode('/', array_filter($paths, 'present')); 1102} 1103 1104function proxy_media($url) 1105{ 1106 if (!present($url)) { 1107 return ''; 1108 } 1109 1110 if ($GLOBALS['cfg']['osu']['camo']['key'] === null) { 1111 return $url; 1112 } 1113 1114 $isProxied = str_starts_with($url, $GLOBALS['cfg']['osu']['camo']['prefix']); 1115 1116 if ($isProxied) { 1117 return $url; 1118 } 1119 1120 // turn relative urls into absolute urls 1121 if (!is_http($url)) { 1122 // ensure url is relative to the site root 1123 if ($url[0] !== '/') { 1124 $url = "/{$url}"; 1125 } 1126 $url = $GLOBALS['cfg']['app']['url'].$url; 1127 } 1128 1129 1130 $hexUrl = bin2hex($url); 1131 $secret = hash_hmac('sha1', $url, $GLOBALS['cfg']['osu']['camo']['key']); 1132 1133 return $GLOBALS['cfg']['osu']['camo']['prefix']."{$secret}/{$hexUrl}"; 1134} 1135 1136function proxy_media_original_url(?string $url): ?string 1137{ 1138 if ($url === null) { 1139 return null; 1140 } 1141 1142 return str_starts_with($url, $GLOBALS['cfg']['osu']['camo']['prefix']) 1143 ? hex2bin(substr($url, strrpos($url, '/') + 1)) 1144 : $url; 1145} 1146 1147function lazy_load_image($url, $class = '', $alt = '') 1148{ 1149 return "<img class='{$class}' src='{$url}' alt='{$alt}' loading='lazy' />"; 1150} 1151 1152function nav_links() 1153{ 1154 $defaultMode = default_mode(); 1155 $links = []; 1156 1157 $links['home'] = [ 1158 '_' => route('home'), 1159 'page_title.main.news_controller._' => route('news.index'), 1160 'layout.menu.home.team' => wiki_url('People/osu!_team'), 1161 'page_title.main.changelog_controller._' => route('changelog.index'), 1162 'page_title.main.home_controller.get_download' => route('download'), 1163 'page_title.main.home_controller.search' => route('search'), 1164 ]; 1165 $links['beatmaps'] = [ 1166 'page_title.main.beatmapsets_controller.index' => route('beatmapsets.index'), 1167 'page_title.main.artists_controller._' => route('artists.index'), 1168 'page_title.main.beatmap_packs_controller._' => route('packs.index'), 1169 ]; 1170 foreach (RankingController::TYPES as $rankingType) { 1171 $links['rankings']["rankings.type.{$rankingType}"] = RankingController::url($rankingType, $defaultMode); 1172 } 1173 $links['community'] = [ 1174 'page_title.forum._' => route('forum.forums.index'), 1175 'page_title.main.chat_controller._' => route('chat.index'), 1176 'page_title.main.contests_controller._' => route('contests.index'), 1177 'page_title.main.tournaments_controller._' => route('tournaments.index'), 1178 'page_title.main.livestreams_controller._' => route('livestreams.index'), 1179 'layout.menu.community.dev' => osu_url('dev'), 1180 ]; 1181 $links['store'] = [ 1182 'layout.header.store.products' => route('store.products.index'), 1183 'layout.header.store.cart' => route('store.cart.show'), 1184 'layout.header.store.orders' => route('store.orders.index'), 1185 ]; 1186 $links['help'] = [ 1187 'page_title.main.wiki_controller._' => wiki_url('Main_page'), 1188 'layout.menu.help.getFaq' => wiki_url('FAQ'), 1189 'layout.menu.help.getRules' => wiki_url('Rules'), 1190 'layout.menu.help.getAbuse' => wiki_url('Reporting_bad_behaviour/Abuse'), 1191 'layout.menu.help.getSupport' => wiki_url('Help_centre'), 1192 ]; 1193 1194 return $links; 1195} 1196 1197function footer_landing_links() 1198{ 1199 return [ 1200 'general' => [ 1201 'home' => route('home'), 1202 'changelog-index' => route('changelog.index'), 1203 'beatmaps' => action('BeatmapsetsController@index'), 1204 'download' => route('download'), 1205 ], 1206 'help' => [ 1207 'faq' => wiki_url('FAQ'), 1208 'forum' => route('forum.forums.index'), 1209 'livestreams' => route('livestreams.index'), 1210 'wiki' => wiki_url('Main_page'), 1211 ], 1212 'legal' => footer_legal_links(), 1213 ]; 1214} 1215 1216function footer_legal_links(): array 1217{ 1218 $locale = app()->getLocale(); 1219 1220 $ret = []; 1221 $ret['terms'] = route('legal', ['locale' => $locale, 'path' => 'Terms']); 1222 if ($locale === 'ja') { 1223 $ret['jp_sctl'] = route('legal', ['locale' => $locale, 'path' => 'SCTL']); 1224 } 1225 $ret['privacy'] = route('legal', ['locale' => $locale, 'path' => 'Privacy']); 1226 $ret['copyright'] = route('legal', ['locale' => $locale, 'path' => 'Copyright']); 1227 $ret['server_status'] = osu_url('server_status'); 1228 $ret['source_code'] = osu_url('source_code'); 1229 1230 return $ret; 1231} 1232 1233function presence($string, $valueIfBlank = null) 1234{ 1235 return present($string) ? $string : $valueIfBlank; 1236} 1237 1238function present($string) 1239{ 1240 return $string !== null && $string !== ''; 1241} 1242 1243function user_color_style($color, $style) 1244{ 1245 if (!present($color)) { 1246 return ''; 1247 } 1248 1249 return sprintf('%s: %s', $style, e($color)); 1250} 1251 1252function display_regdate($user) 1253{ 1254 if ($user->user_regdate === null) { 1255 return; 1256 } 1257 1258 $tooltipDate = i18n_date($user->user_regdate); 1259 1260 $formattedDate = i18n_date($user->user_regdate, pattern: 'year_month'); 1261 1262 if ($user->user_regdate < Carbon\Carbon::createFromDate(2008, 1, 1)) { 1263 return '<div title="'.$tooltipDate.'">'.osu_trans('users.show.first_members').'</div>'; 1264 } 1265 1266 return osu_trans('users.show.joined_at', [ 1267 'date' => "<strong title='{$tooltipDate}'>{$formattedDate}</strong>", 1268 ]); 1269} 1270 1271function i18n_date($datetime, int $format = IntlDateFormatter::LONG, $pattern = null) 1272{ 1273 $formatter = IntlDateFormatter::create( 1274 App::getLocale(), 1275 $format, 1276 IntlDateFormatter::NONE 1277 ); 1278 1279 if ($pattern !== null) { 1280 $formatter->setPattern(osu_trans("common.datetime.{$pattern}.php")); 1281 } 1282 1283 return $formatter->format($datetime); 1284} 1285 1286function i18n_date_auto(DateTimeInterface $date, string $skeleton): string 1287{ 1288 $locale = App::getLocale(); 1289 $generator = new IntlDatePatternGenerator($locale); 1290 $pattern = $generator->getBestPattern($skeleton); 1291 1292 return IntlDateFormatter::formatObject($date, $pattern, $locale); 1293} 1294 1295function i18n_number_format($number, $style = null, $pattern = null, $precision = null, $locale = null) 1296{ 1297 if ($style === null && $pattern === null && $precision === null) { 1298 static $formatters = []; 1299 $locale ??= App::getLocale(); 1300 $formatter = $formatters[$locale] ??= new NumberFormatter($locale, NumberFormatter::DEFAULT_STYLE); 1301 } else { 1302 $formatter = new NumberFormatter( 1303 $locale ?? App::getLocale(), 1304 $style ?? NumberFormatter::DEFAULT_STYLE, 1305 $pattern 1306 ); 1307 1308 if ($precision !== null) { 1309 $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, $precision); 1310 } 1311 } 1312 1313 return $formatter->format($number); 1314} 1315 1316function open_image($path, $dimensions = null) 1317{ 1318 if ($dimensions === null) { 1319 $dimensions = read_image_properties($path); 1320 } 1321 1322 if (!isset($dimensions[2]) || !is_int($dimensions[2])) { 1323 return; 1324 } 1325 1326 try { 1327 $image = null; 1328 1329 switch ($dimensions[2]) { 1330 case IMAGETYPE_GIF: 1331 $image = imagecreatefromgif($path); 1332 break; 1333 case IMAGETYPE_JPEG: 1334 $image = imagecreatefromjpeg($path); 1335 break; 1336 case IMAGETYPE_PNG: 1337 $image = imagecreatefrompng($path); 1338 break; 1339 } 1340 1341 return null_if_false($image); 1342 } catch (ErrorException $_e) { 1343 // do nothing 1344 } 1345} 1346 1347function json_collection($model, $transformer, $includes = null) 1348{ 1349 $manager = new League\Fractal\Manager(new App\Libraries\Transformers\ScopeFactory()); 1350 if ($includes !== null) { 1351 $manager->parseIncludes($includes); 1352 } 1353 $manager->setSerializer(new App\Serializers\ApiSerializer()); 1354 1355 // da bess 1356 if (is_string($transformer)) { 1357 $transformer = 'App\Transformers\\'.str_replace('/', '\\', $transformer).'Transformer'; 1358 $transformer = new $transformer(); 1359 } 1360 1361 // we're using collection instead of item here, so we can peek at the items beforehand 1362 $collection = new League\Fractal\Resource\Collection($model, $transformer); 1363 1364 return $manager->createData($collection)->toArray(); 1365} 1366 1367function json_item($model, $transformer, $includes = null) 1368{ 1369 return json_collection([$model], $transformer, $includes)[0] ?? null; 1370} 1371 1372function fast_imagesize($url, ?string $logErrorId = null) 1373{ 1374 static $oneMonthInSeconds = 30 * 24 * 60 * 60; 1375 1376 return null_if_false(Cache::remember( 1377 "imageSize:{$url}", 1378 $oneMonthInSeconds, 1379 function () use ($logErrorId, $url) { 1380 $curl = curl_init($url); 1381 curl_setopt_array($curl, [ 1382 CURLOPT_HTTPHEADER => [ 1383 'Range: bytes=0-32768', 1384 ], 1385 CURLOPT_RETURNTRANSFER => true, 1386 CURLOPT_FOLLOWLOCATION => true, 1387 CURLOPT_MAXREDIRS => 5, 1388 CURLOPT_TIMEOUT => 10, 1389 ]); 1390 $data = curl_exec($curl); 1391 1392 $ret = read_image_properties_from_string($data); 1393 1394 if ($ret === null && $logErrorId !== null) { 1395 log_error(new FastImagesizeFetchException(), [ 1396 'curl_error_code' => curl_errno($curl), 1397 'curl_error_message' => presence(curl_error($curl)) ?? 'ok', 1398 'curl_status_code' => curl_getinfo($curl, CURLINFO_HTTP_CODE), 1399 'error_id' => $logErrorId, 1400 'url' => $url, 1401 ]); 1402 } 1403 1404 // null isn't cached 1405 return $ret ?? false; 1406 }, 1407 )); 1408} 1409 1410function get_arr($input, $callback = null) 1411{ 1412 if (is_array($input)) { 1413 if ($callback === null) { 1414 return $input; 1415 } 1416 1417 $result = []; 1418 foreach ($input as $value) { 1419 $casted = call_user_func($callback, $value); 1420 1421 if ($casted !== null) { 1422 $result[] = $casted; 1423 } 1424 } 1425 1426 return $result; 1427 } 1428} 1429 1430function get_bool($string) 1431{ 1432 if (is_bool($string)) { 1433 return $string; 1434 } elseif ($string === 1 || $string === '1' || $string === 'on' || $string === 'true') { 1435 return true; 1436 } elseif ($string === 0 || $string === '0' || $string === 'false') { 1437 return false; 1438 } 1439} 1440 1441/* 1442 * Parses a string. If it's not an empty string or null, 1443 * return parsed float value of it, otherwise return null. 1444 */ 1445function get_float($string) 1446{ 1447 if (present($string) && is_scalar($string)) { 1448 return (float) $string; 1449 } 1450} 1451 1452/* 1453 * Parses a string. If it's not an empty string or null, 1454 * return parsed integer value of it, otherwise return null. 1455 */ 1456function get_int($string) 1457{ 1458 if (present($string) && is_scalar($string)) { 1459 return (int) $string; 1460 } 1461} 1462 1463function get_length_seconds($string): ?array 1464{ 1465 static $scales = [ 1466 'ms' => 0.001, 1467 's' => 1, 1468 'm' => 60, 1469 'h' => 3600, 1470 ]; 1471 1472 static $patterns = [ 1473 '/^((?<hours>\d+):)?(?<minutes>\d+):(?<seconds>\d+)$/', 1474 '/^((?<hours>\d+(\.\d+)?)h)?((?<minutes>\d+(\.\d+)?)m)?((?<seconds>\d+(\.\d+)?)s)?((?<milliseconds>\d+(\.\d+)?)ms)?$/', 1475 '/^(?<seconds>\d+(\.\d+)?)$/', 1476 ]; 1477 1478 $string = get_string($string); 1479 1480 if ($string === null) { 1481 return null; 1482 } 1483 1484 $time = null; 1485 $minScale = 3600000; 1486 1487 foreach ($patterns as $pattern) { 1488 $match = preg_match($pattern, $string, $matches); 1489 if ($match !== 1) { 1490 continue; 1491 } 1492 1493 $time ??= 0; 1494 1495 if (isset($matches['milliseconds'])) { 1496 $scale = $scales['ms']; 1497 $minScale = min($minScale, $scale); 1498 $time += get_float($matches['milliseconds']) * $scale; 1499 } 1500 1501 if (isset($matches['seconds'])) { 1502 $scale = $scales['s']; 1503 $minScale = min($minScale, $scale); 1504 $time += get_float($matches['seconds']) * $scale; 1505 } 1506 1507 if (isset($matches['minutes'])) { 1508 $scale = $scales['m']; 1509 $minScale = min($minScale, $scale); 1510 $time += get_float($matches['minutes']) * $scale; 1511 } 1512 1513 if (isset($matches['hours'])) { 1514 $scale = $scales['h']; 1515 $minScale = min($minScale, $scale); 1516 $time += get_float($matches['hours']) * $scale; 1517 } 1518 1519 break; 1520 } 1521 1522 return ['value' => $time, 'min_scale' => $minScale]; 1523} 1524 1525function get_file($input) 1526{ 1527 if ($input instanceof Symfony\Component\HttpFoundation\File\UploadedFile) { 1528 return $input->getRealPath(); 1529 } 1530} 1531 1532function get_string($input) 1533{ 1534 if (is_scalar($input)) { 1535 return (string) $input; 1536 } 1537} 1538 1539function get_string_split($input) 1540{ 1541 return get_arr( 1542 explode("\n", strtr(get_string($input), ["\r\n" => "\n", "\r" => "\n"])), 1543 fn ($item) => presence(trim_unicode($item)), 1544 ); 1545} 1546 1547function get_class_basename($className) 1548{ 1549 return substr($className, strrpos($className, '\\') + 1); 1550} 1551 1552function get_class_namespace($className) 1553{ 1554 return substr($className, 0, strrpos($className, '\\')); 1555} 1556 1557function sanitize_filename($file) 1558{ 1559 $file = mb_ereg_replace('[^\w\s\d\-_~,;\[\]\(\).]', '', $file); 1560 $file = mb_ereg_replace('[\.]{2,}', '.', $file); 1561 1562 return $file; 1563} 1564 1565function deltree($dir) 1566{ 1567 $files = array_diff(scandir($dir), ['.', '..']); 1568 foreach ($files as $file) { 1569 is_dir("$dir/$file") ? deltree("$dir/$file") : unlink("$dir/$file"); 1570 } 1571 1572 return rmdir($dir); 1573} 1574 1575function get_param_value($input, $type) 1576{ 1577 switch ($type) { 1578 case 'any': 1579 return $input; 1580 case 'array': 1581 return get_arr($input); 1582 case 'bool': 1583 case 'boolean': 1584 return get_bool($input); 1585 case 'int': 1586 return get_int($input); 1587 case 'file': 1588 return get_file($input); 1589 case 'number': 1590 case 'float': 1591 return get_float($input); 1592 case 'length': 1593 return get_length_seconds($input); 1594 case 'string': 1595 return get_string($input); 1596 case 'string_split': 1597 return get_string_split($input); 1598 case 'string[]': 1599 return get_arr($input, 'get_string'); 1600 case 'int[]': 1601 return get_arr($input, 'get_int'); 1602 case 'time': 1603 return parse_time_to_carbon($input); 1604 default: 1605 return presence(get_string($input)); 1606 } 1607} 1608 1609function get_params($input, $namespace, $keys, $options = []) 1610{ 1611 if ($namespace !== null) { 1612 $input = array_get($input, $namespace); 1613 } 1614 1615 $params = []; 1616 1617 $options['null_missing'] ??= false; 1618 1619 if (!Arr::accessible($input) && $options['null_missing']) { 1620 $input = []; 1621 } 1622 1623 if (Arr::accessible($input)) { 1624 foreach ($keys as $keyAndType) { 1625 $keyAndType = explode(':', $keyAndType); 1626 1627 $key = $keyAndType[0]; 1628 $type = $keyAndType[1] ?? null; 1629 1630 if (array_has($input, $key)) { 1631 $value = get_param_value(array_get($input, $key), $type); 1632 array_set($params, $key, $value); 1633 } else { 1634 if ($options['null_missing']) { 1635 array_set($params, $key, null); 1636 } 1637 } 1638 } 1639 } 1640 1641 return $params; 1642} 1643 1644/** 1645 * @template T 1646 * @param T[]|Illuminate\Support\Collection<T> $array 1647 * @return T|null 1648 */ 1649function array_rand_val($array) 1650{ 1651 if ($array instanceof Illuminate\Support\Collection) { 1652 $array = $array->all(); 1653 } 1654 1655 if (count($array) === 0) { 1656 return null; 1657 } 1658 1659 return $array[array_rand($array)]; 1660} 1661 1662/** 1663 * Just like original builder's "pluck" but with actual casting. 1664 * I mean "lists" in 5.1 which then replaced by replaced "pluck" 1665 * function. I mean, they deprecated the "pluck" function in 5.1 1666 * and then goes on changing what the function does. 1667 * 1668 * If need to pluck for all rows, just call `select()` on the class. 1669 */ 1670function model_pluck($builder, $key, $class = null) 1671{ 1672 if ($class) { 1673 $selectKey = (new $class())->qualifyColumn($key); 1674 } 1675 1676 $result = []; 1677 1678 foreach ($builder->select($selectKey ?? $key)->get() as $el) { 1679 $result[] = $el->$key; 1680 } 1681 1682 return $result; 1683} 1684 1685function null_if_false($value) 1686{ 1687 return $value === false ? null : $value; 1688} 1689 1690function parse_time_to_carbon($value) 1691{ 1692 if (!present($value)) { 1693 return; 1694 } 1695 1696 if (is_numeric($value)) { 1697 try { 1698 return Carbon\Carbon::createFromTimestamp($value); 1699 } catch (Carbon\Exceptions\InvalidFormatException $_e) { 1700 return; 1701 } 1702 } 1703 1704 if (is_string($value)) { 1705 try { 1706 return Carbon\Carbon::parse($value); 1707 } catch (Exception $_e) { 1708 return; 1709 } 1710 } 1711 1712 if ($value instanceof Carbon\Carbon) { 1713 return $value; 1714 } 1715 1716 if ($value instanceof DateTime) { 1717 return Carbon\Carbon::instance($value); 1718 } 1719 1720 if ($value instanceof Carbon\CarbonImmutable) { 1721 return $value->toMutable(); 1722 } 1723} 1724 1725function format_duration_for_display(int $seconds) 1726{ 1727 return floor($seconds / 60).':'.str_pad((string) ($seconds % 60), 2, '0', STR_PAD_LEFT); 1728} 1729 1730// Converts a standard image url to a retina one 1731// e.g. https://local.host/test.jpg -> https://local.host/test@2x.jpg 1732function retinaify($url) 1733{ 1734 return preg_replace('/(\.[^.]+)$/', '@2x\1', $url); 1735} 1736 1737function priv_check($ability, $object = null) 1738{ 1739 return priv_check_user(Auth::user(), $ability, $object); 1740} 1741 1742function priv_check_user($user, $ability, $object = null) 1743{ 1744 return app()->make('OsuAuthorize')->doCheckUser($user, $ability, $object); 1745} 1746 1747// Used to generate x,y pairs for fancy-chart.coffee 1748function array_to_graph_json(array $array, string $fieldName): array 1749{ 1750 $ret = []; 1751 1752 foreach ($array as $index => $item) { 1753 $ret[] = [ 1754 'x' => $index, 1755 'y' => $item[$fieldName], 1756 ]; 1757 } 1758 1759 return $ret; 1760} 1761 1762// Fisher-Yates 1763function seeded_shuffle(array &$items, int $seed = 0) 1764{ 1765 mt_srand($seed); 1766 for ($i = count($items) - 1; $i > 0; $i--) { 1767 $j = mt_rand(0, $i); 1768 $tmp = $items[$i]; 1769 $items[$i] = $items[$j]; 1770 $items[$j] = $tmp; 1771 } 1772 mt_srand(); 1773} 1774 1775function set_opengraph($model, ...$options) 1776{ 1777 $className = str_replace('App\Models', 'App\Libraries\Opengraph', $model::class).'Opengraph'; 1778 1779 Request::instance()->attributes->set('opengraph', (new $className($model, ...$options))->get()); 1780} 1781 1782function first_paragraph($html, $split_on = "\n") 1783{ 1784 $text = strip_tags($html); 1785 $match_pos = strpos($text, $split_on); 1786 1787 return $match_pos === false ? $text : substr($text, 0, $match_pos); 1788} 1789 1790// e.g. 100634983048665 -> 100.63 trillion 1791function suffixed_number_format(float|int $number, ?string $locale = null): string 1792{ 1793 $locale ??= App::getLocale(); 1794 1795 static $formatters = []; 1796 1797 if (!isset($formatters[$locale])) { 1798 $formatters[$locale] = new NumberFormatter($locale, NumberFormatter::PADDING_POSITION); 1799 $formatters[$locale]->setAttribute(NumberFormatter::FRACTION_DIGITS, 2); 1800 } 1801 1802 return $formatters[$locale]->format($number); 1803} 1804 1805function suffixed_number_format_tag($number) 1806{ 1807 return "<span title='".i18n_number_format($number)."'>".suffixed_number_format($number).'</span>'; 1808} 1809 1810// formats a number as a percentage with a fixed number of precision 1811// e.g.: 0.983 -> 98.30% 1812function format_percentage($number, $precision = 2) 1813{ 1814 // the formatter assumes decimal number while the function receives percentage number. 1815 return i18n_number_format($number, NumberFormatter::PERCENT, null, $precision); 1816} 1817 1818// shorthand to return the filename of an open stream/handle 1819function get_stream_filename($handle) 1820{ 1821 $meta = stream_get_meta_data($handle); 1822 1823 return $meta['uri']; 1824} 1825 1826// Performs a HEAD request to the given url and checks the http status code. 1827// Returns true on status 200, otherwise false (note: doesn't support redirects/etc) 1828function check_url(string $url): bool 1829{ 1830 $ch = curl_init($url); 1831 curl_setopt_array($ch, [ 1832 CURLOPT_HEADER => true, 1833 CURLOPT_NOBODY => true, 1834 CURLOPT_RETURNTRANSFER => true, 1835 CURLOPT_TIMEOUT => 10, 1836 ]); 1837 curl_exec($ch); 1838 1839 return curl_errno($ch) === 0 && curl_getinfo($ch, CURLINFO_HTTP_CODE) === 200; 1840} 1841 1842function mini_asset(string $url): string 1843{ 1844 return str_replace($GLOBALS['cfg']['filesystems']['disks']['s3']['base_url'], $GLOBALS['cfg']['filesystems']['disks']['s3']['mini_url'], $url); 1845} 1846 1847function section_to_hue_map($section): int 1848{ 1849 static $colourToHue = [ 1850 'blue' => 200, 1851 'darkorange' => 20, 1852 'green' => 115, 1853 'orange' => 45, 1854 'pink' => 333, 1855 'purple' => 255, 1856 'red' => 0, 1857 ]; 1858 1859 static $sectionMapping = [ 1860 'admin' => 'red', 1861 'beatmaps' => 'blue', 1862 'community' => 'pink', 1863 'error' => 'pink', 1864 'help' => 'orange', 1865 'home' => 'purple', 1866 'multiplayer' => 'pink', 1867 'rankings' => 'green', 1868 'store' => 'darkorange', 1869 'user' => 'pink', 1870 ]; 1871 1872 return isset($sectionMapping[$section]) ? $colourToHue[$sectionMapping[$section]] : $colourToHue['pink']; 1873} 1874 1875function search_error_message(?Exception $e): ?string 1876{ 1877 if ($e === null) { 1878 return null; 1879 } 1880 1881 $basename = snake_case(get_class_basename(get_class($e))); 1882 $key = "errors.search.{$basename}"; 1883 $text = osu_trans($key); 1884 1885 return $text === $key ? osu_trans('errors.search.default') : $text; 1886} 1887 1888/** 1889 * Gets the path to a versioned resource. 1890 * 1891 * @throws Exception 1892 */ 1893function unmix(string $resource): HtmlString 1894{ 1895 return app('assets-manifest')->src($resource); 1896} 1897 1898/** 1899 * Get an instance of the named migration. 1900 */ 1901function migration(string $name): Migration 1902{ 1903 return require database_path("migrations/{$name}.php"); 1904}