. Licensed under the GNU Affero General Public License v3.0.
// See the LICENCE file in the repository root for full licence text.
use App\Exceptions\FastImagesizeFetchException;
use App\Exceptions\HasExtraExceptionData;
use App\Http\Controllers\RankingController;
use App\Libraries\Base64Url;
use App\Libraries\LocaleMeta;
use App\Models\LoginAttempt;
use Egulias\EmailValidator\EmailValidator;
use Egulias\EmailValidator\Validation\NoRFCWarningsValidation;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Http\Request as HttpRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\HtmlString;
use Sentry\State\Scope;
function api_version(): int
{
$request = request();
$version = $request->attributes->get('api_version');
if ($version === null) {
$version = get_int($request->header('x-api-version')) ?? 0;
$request->attributes->set('api_version', $version);
}
return $version;
}
function array_reject_null(iterable $array): array
{
$ret = [];
foreach ($array as $item) {
if ($item !== null) {
$ret[] = $item;
}
}
return $ret;
}
/*
* Like array_search but returns null if not found instead of false.
* Strict mode only.
*/
function array_search_null($value, $array)
{
return null_if_false(array_search($value, $array, true));
}
function atom_id(string $namespace, $id = null): string
{
return 'tag:'.request()->getHttpHost().',2019:'.$namespace.($id === null ? '' : "/{$id}");
}
function background_image($url): string
{
return present($url)
? sprintf(' style="background-image:url(\'%s\');" ', e($url))
: '';
}
function beatmap_timestamp_format($ms)
{
$s = $ms / 1000;
$ms = $ms % 1000;
$m = $s / 60;
$s = $s % 60;
return sprintf('%02d:%02d.%03d', $m, $s, $ms);
}
/**
* Allows using both html-safe and non-safe text inside `{{ }}` directive.
*/
function blade_safe($html): HtmlString
{
return new HtmlString($html);
}
/**
* Like cache_remember_with_fallback but with a mutex that only allows a single process to run the callback.
*/
function cache_remember_mutexed(string $key, $seconds, $default, callable $callback, ?callable $exceptionHandler = null)
{
static $oneMonthInSeconds = 30 * 24 * 60 * 60;
$fullKey = "{$key}:with_fallback_v2";
$data = Cache::get($fullKey);
$now = time();
if ($data === null || $data['expires_at'] < $now) {
$lockKey = "{$key}:lock";
// this is arbitrary, but you've got other problems if it takes more than 5 minutes.
// the max is because cache()->add() doesn't work so well with funny values.
$lockTime = min(max($seconds, 60), 300);
// only the first caller that manages to setnx runs this.
if (Cache::add($lockKey, 1, $lockTime)) {
try {
$data = [
'expires_at' => $now + $seconds,
'value' => $callback(),
];
Cache::put($fullKey, $data, max($oneMonthInSeconds, $seconds * 10));
} catch (Exception $e) {
$handled = $exceptionHandler !== null && $exceptionHandler($e);
if (!$handled) {
// Log and continue with data from the first ::get.
log_error($e);
}
} finally {
Cache::forget($lockKey);
}
}
}
return $data['value'] ?? $default;
}
/**
* Like Cache::remember but always save for one month or 10 * $seconds (whichever is longer)
* and return old value if failed getting the value after it expires.
*/
function cache_remember_with_fallback($key, $seconds, $callback)
{
static $oneMonthInSeconds = 30 * 24 * 60 * 60;
$fullKey = "{$key}:with_fallback_v2";
$data = Cache::get($fullKey);
$now = time();
if ($data === null || $data['expires_at'] < $now) {
try {
$data = [
'expires_at' => $now + $seconds,
'value' => $callback(),
];
Cache::put($fullKey, $data, max($oneMonthInSeconds, $seconds * 10));
} catch (Exception $e) {
// Log and continue with data from the first ::get.
log_error($e);
}
}
return $data['value'] ?? null;
}
/**
* Marks the content in the key as expired but leaves the fallback set amount of time.
* Use with cache_remember_mutexed when the previous value needs to be shown while a key is being updated.
*
* @param string $key The key of the item to expire.
* @param int $duration The duration the fallback should still remain available for, in seconds. Default: 1 month.
* @return void
*/
function cache_expire_with_fallback(string $key, int $duration = 2592000)
{
$fullKey = "{$key}:with_fallback_v2";
$data = Cache::get($fullKey);
if ($data === null) {
return;
}
$now = time();
if ($data['expires_at'] < $now) {
return;
}
$data['expires_at'] = $now - 3600;
Cache::put($fullKey, $data, $duration);
}
// Just normal Cache::forget but with the suffix.
function cache_forget_with_fallback($key)
{
return Cache::forget("{$key}:with_fallback_v2");
}
function captcha_enabled()
{
return $GLOBALS['cfg']['turnstile']['site_key'] !== '' && $GLOBALS['cfg']['turnstile']['secret_key'] !== '';
}
function captcha_login_triggered()
{
if (!captcha_enabled()) {
return false;
}
if ($GLOBALS['cfg']['osu']['captcha']['threshold'] === 0) {
$triggered = true;
} else {
$loginAttempts = LoginAttempt::find(request()->getClientIp());
$triggered = $loginAttempts && $loginAttempts->failed_attempts >= $GLOBALS['cfg']['osu']['captcha']['threshold'];
}
return $triggered;
}
function class_modifiers_flat(array $modifiersArray): array
{
$ret = [];
foreach ($modifiersArray as $modifiers) {
if (is_array($modifiers)) {
// either "$modifier => boolean" or "$i => $modifier|null"
foreach ($modifiers as $k => $v) {
if (is_bool($v)) {
if ($v) {
$ret[] = $k;
}
} elseif ($v !== null) {
$ret[] = $v;
}
}
} elseif (is_string($modifiers)) {
$ret[] = $modifiers;
}
}
return $ret;
}
function class_with_modifiers(string $className, ...$modifiersArray): string
{
$class = $className;
foreach (class_modifiers_flat($modifiersArray) as $m) {
$class .= " {$className}--{$m}";
}
return $class;
}
function cleanup_cookies()
{
$host = request()->getHost();
// don't do anything for ip address access
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
return;
}
$hostParts = explode('.', $host);
// don't do anything for single word domain
if (count($hostParts) === 1) {
return;
}
$domains = [$host, ''];
// phpcs:ignore
while (count($hostParts) > 1) {
array_shift($hostParts);
$domains[] = implode('.', $hostParts);
}
// remove duplicates and current session domain
$sessionDomain = presence(ltrim($GLOBALS['cfg']['session']['domain'], '.')) ?? '';
$domains = array_diff(array_unique($domains), [$sessionDomain]);
foreach (['locale', 'osu_session', 'XSRF-TOKEN'] as $key) {
foreach ($domains as $domain) {
cookie()->queueForget($key, null, $domain);
}
}
}
function config_set(string $key, $value): void
{
Config::set($key, $value);
$GLOBALS['cfg'] = Config::all();
}
function css_group_colour($group)
{
return '--group-colour: '.(optional($group)->colour ?? 'initial');
}
function css_var_2x(string $key, ?string $url): ?HtmlString
{
if (!present($url)) {
return null;
}
$url = e($url);
$url2x = retinaify($url);
return blade_safe("{$key}: url('{$url}'); {$key}-2x: url('{$url2x}');");
}
function current_locale_meta(): LocaleMeta
{
return locale_meta(app()->getLocale());
}
function cursor_decode($cursorString): ?array
{
if (is_string($cursorString) && present($cursorString)) {
$cursor = json_decode(Base64Url::decode($cursorString) ?? '', true);
if (is_array($cursor)) {
return $cursor;
}
}
return null;
}
function cursor_encode(?array $cursor): ?string
{
return $cursor === null
? null
: Base64Url::encode(json_encode($cursor));
}
function cursor_for_response(?array $cursor): array
{
return [
'cursor' => $cursor,
'cursor_string' => cursor_encode($cursor),
];
}
function cursor_from_params($params): ?array
{
if (is_array($params)) {
$cursor = cursor_decode($params['cursor_string'] ?? null) ?? $params['cursor'] ?? null;
if (is_array($cursor)) {
return $cursor;
}
}
return null;
}
function datadog_increment(string $stat, array|string $tags = null, int $value = 1)
{
Datadog::increment(
stats: $GLOBALS['cfg']['datadog-helper']['prefix_web'].'.'.$stat,
tags: $tags,
value: $value
);
}
function datadog_timing(callable $callable, $stat, array $tag = null)
{
$startTime = microtime(true);
$result = $callable();
$endTime = microtime(true);
if (app('clockwork.support')->isEnabled()) {
// spaces used so clockwork doesn't run across the whole screen.
$description = $stat
.' '.($tag['type'] ?? null)
.' '.($tag['index'] ?? null);
$clockworkEvent = clock()->event($description);
$clockworkEvent->start = $startTime;
$clockworkEvent->end = $endTime;
}
Datadog::microtiming($stat, $endTime - $startTime, 1, $tag);
return $result;
}
function db_unsigned_increment($column, $count)
{
if ($count >= 0) {
$value = "`{$column}` + {$count}";
} else {
$change = -$count;
$value = "IF(`{$column}` < {$change}, 0, `{$column}` - {$change})";
}
return DB::raw($value);
}
function default_mode()
{
return optional(auth()->user())->playmode ?? 'osu';
}
function flag_url($countryCode)
{
$chars = str_split($countryCode);
$hexEmojiChars = array_map(fn ($chr) => dechex(mb_ord($chr) + 127397), $chars);
$baseFileName = implode('-', $hexEmojiChars);
return "/assets/images/flags/{$baseFileName}.svg";
}
function format_month_column(\DateTimeInterface $date): string
{
return $date->format('ym');
}
function format_rank(?int $rank): string
{
return $rank !== null ? '#'.i18n_number_format($rank) : '-';
}
function get_valid_locale($requestedLocale)
{
if (in_array($requestedLocale, $GLOBALS['cfg']['app']['available_locales'], true)) {
return $requestedLocale;
}
}
function hsl_to_hex($h, $s, $l)
{
$c = (1 - abs(2 * $l - 1)) * $s;
$x = $c * (1 - abs(fmod($h / 60, 2) - 1));
$m = $l - ($c / 2);
[$r, $g, $b] = match (true) {
$h < 60 => [$c, $x, 0],
$h < 120 => [$x, $c, 0],
$h < 180 => [0, $c, $x],
$h < 240 => [0, $x, $c],
$h < 300 => [$x, 0, $c],
default => [$c, 0, $x]
};
$r = round(($r + $m) * 255);
$g = round(($g + $m) * 255);
$b = round(($b + $m) * 255);
return sprintf('#%02x%02x%02x', $r, $g, $b);
}
function html_entity_decode_better($string)
{
// ENT_HTML5 to handle more named entities (', etc?).
return html_entity_decode($string, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
function html_excerpt($body, $limit = 300)
{
$body = html_entity_decode_better(replace_tags_with_spaces($body));
return e(truncate($body, $limit));
}
function img2x(array $attributes)
{
if (!present($attributes['src'] ?? null)) {
return;
}
$src2x = retinaify($attributes['src']);
$attributes['srcset'] = "{$attributes['src']} 1x, {$src2x} 1.5x";
return tag('img', $attributes);
}
function locale_meta(string $locale): LocaleMeta
{
return LocaleMeta::find($locale);
}
function trim_unicode(?string $value)
{
return preg_replace('/(^\s+|\s+$)/u', '', $value);
}
function truncate(string $text, $limit = 100, $ellipsis = '...')
{
if (mb_strlen($text) > $limit) {
return mb_substr($text, 0, $limit - mb_strlen($ellipsis)).$ellipsis;
}
return $text;
}
function truncate_inclusive(string $text, int $limit): string
{
if (mb_strlen($text) > $limit) {
return mb_substr($text, 0, $limit).'...';
}
return $text;
}
function json_date(?DateTime $date): ?string
{
return $date === null ? null : $date->format('Y-m-d');
}
function json_time(?DateTime $time): ?string
{
return $time === null ? null : $time->format(DateTime::ATOM);
}
function log_error($exception, ?array $sentryTags = null): void
{
Log::error($exception);
log_error_sentry($exception, $sentryTags);
}
function log_error_sentry(Throwable $exception, ?array $tags = null): ?string
{
// Fallback in case error happening before config is initialised
if (!($GLOBALS['cfg']['sentry']['dsn'] ?? false)) {
return null;
}
return Sentry\withScope(function (Scope $scope) use ($exception, $tags) {
$currentUser = Auth::user();
$userContext = $currentUser === null
? ['id' => null]
: [
'id' => $currentUser->getKey(),
'username' => $currentUser->username,
];
$scope->setUser($userContext);
foreach ($tags ?? [] as $key => $value) {
$scope->setTag($key, $value);
}
if ($exception instanceof HasExtraExceptionData) {
$scope->setExtras($exception->getExtras());
$contexts = $exception->getContexts();
foreach ($contexts as $name => $value) {
$scope->setContext($name, $value ?? []);
}
}
return Sentry\captureException($exception);
});
}
function logout()
{
\Session::delete();
Auth::logout();
cleanup_cookies();
}
function markdown($input, $preset = 'default')
{
static $converter;
App\Libraries\Markdown\OsuMarkdown::PRESETS[$preset] ?? $preset = 'default';
if (!isset($converter[$preset])) {
$converter[$preset] = new App\Libraries\Markdown\OsuMarkdown($preset);
}
return $converter[$preset]->load($input)->html();
}
function markdown_plain(?string $input): string
{
if ($input === null) {
return '';
}
static $converter;
if (!isset($converter)) {
$converter = new League\CommonMark\CommonMarkConverter([
'allow_unsafe_links' => false,
'html_input' => 'escape',
]);
}
return $converter->convert($input)->getContent();
}
function max_offset($page, $limit)
{
$offset = ($page - 1) * $limit;
return max(0, min($offset, $GLOBALS['cfg']['osu']['pagination']['max_count'] - $limit));
}
function oauth_token(): ?App\Models\OAuth\Token
{
return Request::instance()->attributes->get(App\Http\Middleware\AuthApi::REQUEST_OAUTH_TOKEN_KEY);
}
function osu_trans($key = null, $replace = [], $locale = null)
{
$translator = app('translator');
if (is_null($key)) {
return $translator;
}
if (!trans_exists($key, $locale)) {
$locale = $GLOBALS['cfg']['app']['fallback_locale'];
}
return $translator->get($key, $replace, $locale, false);
}
function osu_trans_choice($key, $number, array $replace = [], $locale = null)
{
if (!trans_exists($key, $locale)) {
$locale = $GLOBALS['cfg']['app']['fallback_locale'];
}
if (is_array($number) || $number instanceof Countable) {
$number = count($number);
}
if (!isset($replace['count_delimited'])) {
$replace['count_delimited'] = i18n_number_format($number, null, null, null, $locale);
}
return app('translator')->choice($key, $number, $replace, $locale);
}
function osu_url(string $key): ?string
{
$url = $GLOBALS['cfg']['osu']['urls'][$key] ?? null;
if (($url[0] ?? null) === '/') {
$url = $GLOBALS['cfg']['osu']['urls']['base'].$url;
}
return $url;
}
function pack_str($str)
{
return pack('ccH*', 0x0b, strlen($str), bin2hex($str));
}
function product_quantity_options($product, $selected = null)
{
if ($product->stock === null) {
$max = $product->max_quantity;
} else {
$max = min($product->max_quantity, $product->stock);
}
$opts = [];
for ($i = 1; $i <= $max; $i++) {
$opts[] = [
'label' => osu_trans_choice('common.count.item', $i),
'selected' => $i === $selected,
'value' => $i,
];
}
// include selected value separately if it's out of range.
if ($selected !== null && $selected > $max) {
$opts[] = [
'label' => osu_trans_choice('common.count.item', $selected),
'selected' => true,
'value' => $selected,
];
}
return $opts;
}
function read_image_properties($path)
{
try {
return null_if_false(getimagesize($path));
} catch (Exception $_e) {
return null;
}
}
function read_image_properties_from_string($string)
{
try {
return null_if_false(getimagesizefromstring($string));
} catch (Exception $_e) {
return null;
}
}
// use this instead of strip_tags when
and
need to be converted to space
function replace_tags_with_spaces($body)
{
return preg_replace('#<[^>]+>#', ' ', $body);
}
function request_country($request = null)
{
return $request === null
? Request::header('CF_IPCOUNTRY')
: $request->header('CF_IPCOUNTRY');
}
function require_login($textKey, $linkTextKey)
{
return osu_trans($textKey, ['link' => link_to(
'#',
osu_trans($linkTextKey),
[
'class' => 'js-user-link',
'title' => osu_trans('users.anonymous.login_link'),
],
)]);
}
function spinner(?array $modifiers = null)
{
return tag('div', [
'class' => class_with_modifiers('la-ball-clip-rotate', $modifiers),
]);
}
function strip_utf8_bom($input)
{
if (substr($input, 0, 3) === "\xEF\xBB\xBF") {
return substr($input, 3);
}
return $input;
}
function tag($element, $attributes = [], $content = null)
{
$attributeString = '';
foreach ($attributes ?? [] as $key => $value) {
$attributeString .= ' '.$key.'="'.e($value).'"';
}
return '<'.$element.$attributeString.'>'.($content ?? '').''.$element.'>';
}
// Handles case where crowdin fills in untranslated key with empty string.
function trans_exists($key, $locale)
{
$translated = app('translator')->get($key, [], $locale, false);
return present($translated) && $translated !== $key;
}
function obscure_email($email)
{
$email = explode('@', $email);
if (!present($email[0]) || !present($email[1] ?? null)) {
return '";
}
function nav_links()
{
$defaultMode = default_mode();
$links = [];
$links['home'] = [
'_' => route('home'),
'page_title.main.news_controller._' => route('news.index'),
'layout.menu.home.team' => wiki_url('People/osu!_team'),
'page_title.main.changelog_controller._' => route('changelog.index'),
'page_title.main.home_controller.get_download' => route('download'),
'page_title.main.home_controller.search' => route('search'),
];
$links['beatmaps'] = [
'page_title.main.beatmapsets_controller.index' => route('beatmapsets.index'),
'page_title.main.artists_controller._' => route('artists.index'),
'page_title.main.beatmap_packs_controller._' => route('packs.index'),
];
foreach (RankingController::TYPES as $rankingType) {
$links['rankings']["rankings.type.{$rankingType}"] = RankingController::url($rankingType, $defaultMode);
}
$links['community'] = [
'page_title.forum._' => route('forum.forums.index'),
'page_title.main.chat_controller._' => route('chat.index'),
'page_title.main.contests_controller._' => route('contests.index'),
'page_title.main.tournaments_controller._' => route('tournaments.index'),
'page_title.main.livestreams_controller._' => route('livestreams.index'),
'layout.menu.community.dev' => osu_url('dev'),
];
$links['store'] = [
'layout.header.store.products' => route('store.products.index'),
'layout.header.store.cart' => route('store.cart.show'),
'layout.header.store.orders' => route('store.orders.index'),
];
$links['help'] = [
'page_title.main.wiki_controller._' => wiki_url('Main_page'),
'layout.menu.help.getFaq' => wiki_url('FAQ'),
'layout.menu.help.getRules' => wiki_url('Rules'),
'layout.menu.help.getAbuse' => wiki_url('Reporting_bad_behaviour/Abuse'),
'layout.menu.help.getSupport' => wiki_url('Help_centre'),
];
return $links;
}
function footer_landing_links()
{
return [
'general' => [
'home' => route('home'),
'changelog-index' => route('changelog.index'),
'beatmaps' => action('BeatmapsetsController@index'),
'download' => route('download'),
],
'help' => [
'faq' => wiki_url('FAQ'),
'forum' => route('forum.forums.index'),
'livestreams' => route('livestreams.index'),
'wiki' => wiki_url('Main_page'),
],
'legal' => footer_legal_links(),
];
}
function footer_legal_links(): array
{
$locale = app()->getLocale();
$ret = [];
$ret['terms'] = route('legal', ['locale' => $locale, 'path' => 'Terms']);
if ($locale === 'ja') {
$ret['jp_sctl'] = route('legal', ['locale' => $locale, 'path' => 'SCTL']);
}
$ret['privacy'] = route('legal', ['locale' => $locale, 'path' => 'Privacy']);
$ret['copyright'] = route('legal', ['locale' => $locale, 'path' => 'Copyright']);
$ret['server_status'] = osu_url('server_status');
$ret['source_code'] = osu_url('source_code');
return $ret;
}
function presence($string, $valueIfBlank = null)
{
return present($string) ? $string : $valueIfBlank;
}
function present($string)
{
return $string !== null && $string !== '';
}
function user_color_style($color, $style)
{
if (!present($color)) {
return '';
}
return sprintf('%s: %s', $style, e($color));
}
function display_regdate($user)
{
if ($user->user_regdate === null) {
return;
}
$tooltipDate = i18n_date($user->user_regdate);
$formattedDate = i18n_date($user->user_regdate, pattern: 'year_month');
if ($user->user_regdate < Carbon\Carbon::createFromDate(2008, 1, 1)) {
return '