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 (', 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}