diff --git a/app/Enums/Ruleset.php b/app/Enums/Ruleset.php index 1fc36baeedc..3ff92da8ee4 100644 --- a/app/Enums/Ruleset.php +++ b/app/Enums/Ruleset.php @@ -14,16 +14,31 @@ enum Ruleset: int case catch = 2; case mania = 3; - public static function fromName(string $ruleset): self + // for usage with tryFrom when the parameter may be null. + public const NULL = -1; + + public static function tryFromName(?string $ruleset): ?self { + if ($ruleset === null) { + return null; + } + static $lookupMap; if ($lookupMap === null) { $lookupMap = []; foreach (self::cases() as $r) { $lookupMap[$r->name] = $r; } + $lookupMap['fruits'] = self::catch; } - return $lookupMap[$ruleset]; + return $lookupMap[$ruleset] ?? null; + } + + public function legacyName() + { + return $this === self::catch + ? 'fruits' + : $this->name; } } diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index eb5b75ce9ee..c44353cd325 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -7,6 +7,7 @@ use App\Exceptions\ImageProcessorException; use App\Exceptions\ModelNotSavedException; +use App\Libraries\User\AvatarHelper; use App\Libraries\User\CountryChange; use App\Libraries\User\CountryChangeTarget; use App\Libraries\UserVerification; @@ -73,7 +74,7 @@ public function avatar() $user = auth()->user(); try { - $user->setAvatar(Request::file('avatar_file')); + AvatarHelper::set($user, Request::file('avatar_file')); } catch (ImageProcessorException $e) { return error_popup($e->getMessage()); } @@ -308,7 +309,7 @@ public function verify() public function verifyLink() { - $state = UserVerificationState::fromVerifyLink(request('key')); + $state = UserVerificationState::fromVerifyLink(get_string(request('key')) ?? ''); if ($state === null) { UserVerification::logAttempt('link', 'fail', 'incorrect_key'); diff --git a/app/Http/Controllers/BeatmapsController.php b/app/Http/Controllers/BeatmapsController.php index c82345bb533..97158c45737 100644 --- a/app/Http/Controllers/BeatmapsController.php +++ b/app/Http/Controllers/BeatmapsController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; +use App\Enums\Ruleset; use App\Exceptions\InvariantException; use App\Jobs\Notifications\BeatmapOwnerChange; use App\Libraries\BeatmapDifficultyAttributes; @@ -256,20 +257,24 @@ public function show($id) abort(404); } - if ($beatmap->mode === 'osu') { + $beatmapRuleset = $beatmap->mode; + if ($beatmapRuleset === 'osu') { $params = get_params(request()->all(), null, [ 'm:int', // legacy parameter - 'mode:string', + 'mode', // legacy parameter + 'ruleset', ], ['null_missing' => true]); - $mode = Beatmap::isModeValid($params['mode']) - ? $params['mode'] - : Beatmap::modeStr($params['m']); + $ruleset = ( + Ruleset::tryFromName($params['ruleset']) + ?? Ruleset::tryFromName($params['mode']) + ?? Ruleset::tryFrom($params['m'] ?? Ruleset::NULL) + )?->legacyName(); } - $mode ??= $beatmap->mode; + $ruleset ??= $beatmapRuleset; - return ujs_redirect(route('beatmapsets.show', ['beatmapset' => $beatmapset->getKey()]).'#'.$mode.'/'.$beatmap->getKey()); + return ujs_redirect(route('beatmapsets.show', ['beatmapset' => $beatmapset->getKey()]).'#'.$ruleset.'/'.$beatmap->getKey()); } /** diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index a45cf05a3df..c6062330a92 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -23,6 +23,7 @@ class Kernel extends HttpKernel Middleware\AuthApi::class, Middleware\SetLocaleApi::class, Middleware\CheckUserBanStatus::class, + Middleware\UpdateUserLastvisit::class, ], 'web' => [ Middleware\StripCookies::class, diff --git a/app/Http/Middleware/AuthApi.php b/app/Http/Middleware/AuthApi.php index 72c46653816..592c1ef5a17 100644 --- a/app/Http/Middleware/AuthApi.php +++ b/app/Http/Middleware/AuthApi.php @@ -79,6 +79,7 @@ private function validTokenFromRequest($psr) throw new AuthenticationException('invalid token'); } + $token->setRelation('client', $client); $token->validate(); $user = $token->getResourceOwner(); diff --git a/app/Http/Middleware/DatadogMetrics.php b/app/Http/Middleware/DatadogMetrics.php index 89b27a7be94..68a8f1f710f 100644 --- a/app/Http/Middleware/DatadogMetrics.php +++ b/app/Http/Middleware/DatadogMetrics.php @@ -32,7 +32,7 @@ protected static function logDuration(Request $request, Response $response, $sta $duration = microtime(true) - $startTime; $tags = [ 'action' => 'error_page', - 'api' => $request->is('api/*') ? 'true' : 'false', + 'api' => is_api_request() ? 'true' : 'false', 'controller' => 'error', 'namespace' => 'error', 'pod_name' => $hostname, diff --git a/app/Http/Middleware/StripCookies.php b/app/Http/Middleware/StripCookies.php index 1899047b2af..05168a701ab 100644 --- a/app/Http/Middleware/StripCookies.php +++ b/app/Http/Middleware/StripCookies.php @@ -15,13 +15,7 @@ public function handle($request, Closure $next) if ($request->attributes->get('strip_cookies')) { // strip all cookies from response - foreach ($result->headers->getCookies() as $cookie) { - $result->headers->removeCookie( - $cookie->getName(), - $cookie->getPath(), - $cookie->getDomain() - ); - } + $result->headers->remove('set-cookie'); } return $result; diff --git a/app/Http/Middleware/UpdateUserLastvisit.php b/app/Http/Middleware/UpdateUserLastvisit.php index 063605a2cc2..a817773224a 100644 --- a/app/Http/Middleware/UpdateUserLastvisit.php +++ b/app/Http/Middleware/UpdateUserLastvisit.php @@ -24,24 +24,30 @@ public function handle($request, Closure $next) $user = $this->auth->user(); if ($user !== null) { - $isInactive = $user->isInactive(); + $token = $user->token(); + $shouldUpdate = $token === null || $token->client->password_client; - if ($isInactive) { - $isVerified = $user->isSessionVerified(); - } + if ($shouldUpdate) { + $isInactive = $user->isInactive(); + if ($isInactive) { + $isVerified = $user->isSessionVerified(); + } - if (!$isInactive || $isVerified) { - $recordedLastVisit = $user->getRawAttribute('user_lastvisit'); - $currentLastVisit = time(); + if (!$isInactive || $isVerified) { + $recordedLastVisit = $user->getRawAttribute('user_lastvisit'); + $currentLastVisit = time(); - if ($currentLastVisit - $recordedLastVisit > 300) { - $user->update([ - 'user_lastvisit' => $currentLastVisit, - ], ['skipValidations' => true]); + if ($currentLastVisit - $recordedLastVisit > 300) { + $user->update([ + 'user_lastvisit' => $currentLastVisit, + ], ['skipValidations' => true]); + } } - } - $this->recordSession($request); + if ($token === null) { + $this->recordSession($request); + } + } } return $next($request); diff --git a/app/Libraries/BBCodeForDB.php b/app/Libraries/BBCodeForDB.php index 8d3fb94e44d..88d7a8445ed 100644 --- a/app/Libraries/BBCodeForDB.php +++ b/app/Libraries/BBCodeForDB.php @@ -5,7 +5,6 @@ namespace App\Libraries; -use App\Models\Smiley; use App\Models\User; class BBCodeForDB @@ -325,22 +324,13 @@ public function parseSize($text) ); } - // copied from www/forum/includes/message_parser.php#L1196 - public function parseSmiley($text) { - $smilies = Smiley::getAll(); - - $match = []; - $replace = []; + $replacer = app('smilies')->replacer(); - foreach ($smilies as $smiley) { - $match[] = '(?<=^|[\n .])'.preg_quote($smiley['code'], '#').'(?![^<>]*>)'; - $replace[] = ''.$smiley['code'].''; - } - if (count($match)) { + if (count($replacer['patterns']) > 0) { // Make sure the delimiter # is added in front and at the end of every element within $match - $text = trim(preg_replace(explode(chr(0), '#'.implode('#'.chr(0).'#', $match).'#'), $replace, $text)); + $text = trim(preg_replace($replacer['patterns'], $replacer['replacements'], $text)); } return $text; diff --git a/app/Libraries/Base64Url.php b/app/Libraries/Base64Url.php new file mode 100644 index 00000000000..9e2a5252b42 --- /dev/null +++ b/app/Libraries/Base64Url.php @@ -0,0 +1,23 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Libraries; + +class Base64Url +{ + public static function decode(string $value): ?string + { + return null_if_false(base64_decode(strtr($value, '-_', '+/'), true)); + } + + public static function encode(string $value): string + { + // url safe base64 + // reference: https://datatracker.ietf.org/doc/html/rfc4648#section-5 + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } +} diff --git a/app/Libraries/SignedRandomString.php b/app/Libraries/SignedRandomString.php new file mode 100644 index 00000000000..7e5cc35002f --- /dev/null +++ b/app/Libraries/SignedRandomString.php @@ -0,0 +1,39 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Libraries; + +class SignedRandomString +{ + public static function create(int $randomSize): string + { + $key = random_bytes($randomSize); + $hmac = static::hmac($key); + + return Base64Url::encode($hmac.$key); + } + + public static function isValid(string $input): bool + { + $bin = Base64Url::decode($input); + if ($bin === null) { + return false; + } + + // hmac size for sha1 is 20 + $hmac = substr($bin, 0, 20); + $key = substr($bin, 20); + $expectedHmac = static::hmac($key); + + return hash_equals($expectedHmac, $hmac); + } + + private static function hmac(string $key): string + { + return hash_hmac('sha1', $key, \Crypt::getKey(), true); + } +} diff --git a/app/Libraries/Smilies.php b/app/Libraries/Smilies.php new file mode 100644 index 00000000000..136381ada4d --- /dev/null +++ b/app/Libraries/Smilies.php @@ -0,0 +1,43 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Libraries; + +use App\Models\Smiley; +use App\Traits\Memoizes; + +class Smilies +{ + use Memoizes; + + public function all(): array + { + return $this->memoize(__FUNCTION__, fn () => $this->fetch()); + } + + public function replacer(): array + { + return $this->memoize(__FUNCTION__, function () { + $smilies = $this->all(); + + $patterns = []; + $replacements = []; + + foreach ($smilies as $smiley) { + $patterns[] = '#(?<=^|[\n .])'.preg_quote($smiley['code'], '#').'(?![^<>]*>)#'; + $replacements[] = ''.$smiley['code'].''; + } + + return compact('patterns', 'replacements'); + }); + } + + private function fetch(): array + { + return Smiley::orderBy(\DB::raw('LENGTH(code)'), 'desc')->get()->toArray(); + } +} diff --git a/app/Libraries/User/AvatarHelper.php b/app/Libraries/User/AvatarHelper.php new file mode 100644 index 00000000000..6b4919dc507 --- /dev/null +++ b/app/Libraries/User/AvatarHelper.php @@ -0,0 +1,79 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Libraries\User; + +use App\Libraries\ImageProcessor; +use App\Libraries\StorageUrl; +use App\Models\User; + +class AvatarHelper +{ + public static function set(User $user, ?\SplFileInfo $src): bool + { + $id = $user->getKey(); + $storage = \Storage::disk(static::disk()); + + if ($src === null) { + $storage->delete($id); + } else { + $srcPath = $src->getRealPath(); + $processor = new ImageProcessor($srcPath, [256, 256], 100000); + $processor->process(); + + $storage->putFileAs('/', $src, $id, 'public'); + $entry = $id.'_'.time().'.'.$processor->ext(); + } + + static::purgeCache($id); + + return $user->update(['user_avatar' => $entry ?? '']); + } + + public static function url(User $user): string + { + $value = $user->getRawAttribute('user_avatar'); + + return present($value) + ? StorageUrl::make(static::disk(), strtr($value, '_', '?')) + : \Config::get('osu.avatar.default'); + } + + private static function disk(): string + { + return \Config::get('osu.avatar.storage'); + } + + private static function purgeCache(int $id): void + { + $prefix = presence(\Config::get('osu.avatar.cache_purge_prefix')); + + if ($prefix === null) { + return; + } + + $method = \Config::get('osu.avatar.cache_purge_method') ?? 'GET'; + $auth = \Config::get('osu.avatar.cache_purge_authorization_key'); + $ctx = [ + 'http' => [ + 'method' => $method, + 'header' => present($auth) ? "Authorization: {$auth}" : null, + ], + ]; + $suffix = $method === 'GET' ? '?'.time() : ''; // Bypass CloudFlare cache if using GET + $url = "{$prefix}{$id}{$suffix}"; + + try { + file_get_contents($url, false, stream_context_create($ctx)); + } catch (\ErrorException $e) { + // ignores 404 errors, throws everything else + if (!ends_with($e->getMessage(), "404 Not Found\r\n")) { + throw $e; + } + } + } +} diff --git a/app/Libraries/UserVerificationState.php b/app/Libraries/UserVerificationState.php index 266ff6abe11..61c336ee09a 100644 --- a/app/Libraries/UserVerificationState.php +++ b/app/Libraries/UserVerificationState.php @@ -23,6 +23,10 @@ public static function fromCurrentRequest() public static function fromVerifyLink($linkKey) { + if (!SignedRandomString::isValid($linkKey)) { + return null; + } + $params = cache()->get("verification:{$linkKey}"); if ($params !== null) { @@ -76,7 +80,7 @@ public function issue() // 1 byte = 2^8 bits = 16^2 bits = 2 hex characters $key = bin2hex(random_bytes(config('osu.user.verification_key_length_hex') / 2)); - $linkKey = bin2hex(random_bytes(32)); + $linkKey = SignedRandomString::create(32); $expires = now()->addHours(5); $this->session->put('verification_key', $key); diff --git a/app/Models/Smiley.php b/app/Models/Smiley.php index e5d54ff431d..ae298595245 100644 --- a/app/Models/Smiley.php +++ b/app/Models/Smiley.php @@ -5,9 +5,6 @@ namespace App\Models; -use Cache; -use DB; - /** * @property string $code * @property int $display_on_posting @@ -21,11 +18,4 @@ class Smiley extends Model { protected $table = 'phpbb_smilies'; - - public static function getAll() - { - return Cache::rememberForever('smilies', function () { - return self::orderBy(DB::raw('LENGTH(code)'), 'desc')->get()->toArray(); - }); - } } diff --git a/app/Models/Traits/UserAvatar.php b/app/Models/Traits/UserAvatar.php deleted file mode 100644 index 252d6411665..00000000000 --- a/app/Models/Traits/UserAvatar.php +++ /dev/null @@ -1,67 +0,0 @@ -. Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -namespace App\Models\Traits; - -use App\Libraries\ImageProcessor; -use App\Libraries\StorageUrl; -use ErrorException; - -trait UserAvatar -{ - private static function avatarDisk(): string - { - return \Config::get('osu.avatar.storage'); - } - - public function setAvatar($file) - { - $storage = \Storage::disk(static::avatarDisk()); - if ($file === null) { - $storage->delete($this->user_id); - } else { - $filePath = $file->getRealPath(); - $processor = new ImageProcessor($filePath, [256, 256], 100000); - $processor->process(); - - $storage->put($this->user_id, file_get_contents($filePath), 'public'); - - $entry = $this->user_id.'_'.time().'.'.$processor->ext(); - } - - if (present(\Config::get('osu.avatar.cache_purge_prefix'))) { - try { - $ctx = [ - 'http' => [ - 'method' => \Config::get('osu.avatar.cache_purge_method') ?? 'GET', - 'header' => present(\Config::get('osu.avatar.cache_purge_authorization_key')) - ? 'Authorization: '.\Config::get('osu.avatar.cache_purge_authorization_key') - : null, - ], - ]; - $prefix = \Config::get('osu.avatar.cache_purge_prefix'); - $suffix = $ctx['http']['method'] === 'GET' ? '?'.time() : ''; // Bypass CloudFlare cache if using GET - $url = $prefix.$this->user_id.$suffix; - file_get_contents($url, false, stream_context_create($ctx)); - } catch (ErrorException $e) { - // ignores 404 errors, throws everything else - if (!ends_with($e->getMessage(), "404 Not Found\r\n")) { - throw $e; - } - } - } - - return $this->update(['user_avatar' => $entry ?? '']); - } - - protected function getUserAvatar() - { - $value = $this->getRawAttribute('user_avatar'); - - return present($value) - ? StorageUrl::make(static::avatarDisk(), strtr($value, '_', '?')) - : \Config::get('osu.avatar.default'); - } -} diff --git a/app/Models/User.php b/app/Models/User.php index 013951fe5a7..04a34a07aa9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -14,6 +14,7 @@ use App\Libraries\Elasticsearch\Indexable; use App\Libraries\Session\Store as SessionStore; use App\Libraries\Transactions\AfterCommit; +use App\Libraries\User\AvatarHelper; use App\Libraries\User\DatadogLoginAttempt; use App\Libraries\User\ProfileBeatmapset; use App\Libraries\User\UsernamesForDbLookup; @@ -212,7 +213,7 @@ */ class User extends Model implements AfterCommit, AuthenticatableContract, HasLocalePreference, Indexable, Traits\ReportableInterface { - use Authenticatable, HasApiTokens, Memoizes, Traits\Es\UserSearch, Traits\Reportable, Traits\UserAvatar, Traits\UserScoreable, Traits\UserStore, Validatable; + use Authenticatable, HasApiTokens, Memoizes, Traits\Es\UserSearch, Traits\Reportable, Traits\UserScoreable, Traits\UserStore, Validatable; const PLAYSTYLES = [ 'mouse' => 1, @@ -837,7 +838,7 @@ public function getAttribute($key) 'displayed_last_visit' => $this->getDisplayedLastVisit(), 'osu_playstyle' => $this->getOsuPlaystyle(), 'playmode' => $this->getPlaymode(), - 'user_avatar' => $this->getUserAvatar(), + 'user_avatar' => AvatarHelper::url($this), 'user_colour' => $this->getUserColour(), 'user_rank' => $this->getUserRank(), 'user_website' => $this->getUserWebsite(), diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 52ec5525f68..8dc6a20424d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -22,6 +22,7 @@ use App\Libraries\OsuMessageSelector; use App\Libraries\RateLimiter; use App\Libraries\RouteSection; +use App\Libraries\Smilies; use App\Libraries\User\ScorePins; use Datadog; use Illuminate\Database\Eloquent\Relations\Relation; @@ -43,6 +44,7 @@ class AppServiceProvider extends ServiceProvider 'groups' => Groups::class, 'layout-cache' => LayoutCache::class, 'medals' => Medals::class, + 'smilies' => Smilies::class, ]; const SINGLETONS = [ diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index bcb8f9ea713..719bcb38c75 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -12,6 +12,7 @@ use Carbon\Carbon; use Illuminate\Contracts\Auth\StatefulGuard; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; +use Laravel\Passport\Http\Controllers\AccessTokenController; use Laravel\Passport\Http\Controllers\ApproveAuthorizationController; use Laravel\Passport\Http\Controllers\DenyAuthorizationController; use Laravel\Passport\Passport; @@ -52,7 +53,7 @@ public function boot() // RouteServiceProvider current runs before our provider, so Passport's default routes will override // those set in routes/web.php. Route::group(['prefix' => 'oauth', 'as' => 'oauth.'], function () { - Route::post('token', '\Laravel\Passport\Http\Controllers\AccessTokenController@issueToken')->middleware('throttle')->name('passport.token'); + Route::post('token', AccessTokenController::class.'@issueToken')->middleware('throttle')->name('passport.token'); Route::get('authorize', AuthorizationController::class.'@authorize') ->middleware(['web', 'verify-user']) ->name('authorizations.authorize'); diff --git a/app/helpers.php b/app/helpers.php index 6e22360737c..dbfe75eacf5 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -3,6 +3,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. +use App\Libraries\Base64Url; use App\Libraries\LocaleMeta; use App\Models\LoginAttempt; use Egulias\EmailValidator\EmailValidator; @@ -298,7 +299,7 @@ function current_locale_meta(): LocaleMeta function cursor_decode($cursorString): ?array { if (is_string($cursorString) && present($cursorString)) { - $cursor = json_decode(base64_decode(strtr($cursorString, '-_', '+/'), true), true); + $cursor = json_decode(Base64Url::decode($cursorString) ?? '', true); if (is_array($cursor)) { return $cursor; @@ -310,13 +311,9 @@ function cursor_decode($cursorString): ?array function cursor_encode(?array $cursor): ?string { - if ($cursor === null) { - return null; - } - - // url safe base64 - // reference: https://datatracker.ietf.org/doc/html/rfc4648#section-5 - return rtrim(strtr(base64_encode(json_encode($cursor)), '+/', '-_'), '='); + return $cursor === null + ? null + : Base64Url::encode(json_encode($cursor)); } function cursor_for_response(?array $cursor): array @@ -791,14 +788,14 @@ function forum_user_link(int $id, string $username, string|null $colour, int|nul return "{$icon} {$link}"; } -function is_api_request() +function is_api_request(): bool { - return request()->is('api/*'); + return str_starts_with(rawurldecode(Request::getPathInfo()), '/api/'); } -function is_json_request() +function is_json_request(): bool { - return is_api_request() || request()->expectsJson(); + return is_api_request() || Request::expectsJson(); } function is_valid_email_format(?string $email): bool diff --git a/composer.json b/composer.json index fdad702a921..a77c864bf5a 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,8 @@ } ], "require": { + "ext-ds": "*", + "ext-redis": "*", "anhskohbo/no-captcha": "^3.2", "chaseconey/laravel-datadog-helper": ">=1.2.0", "egulias/email-validator": "*", @@ -40,7 +42,6 @@ "maennchen/zipstream-php": "^2.1", "mariuzzo/laravel-js-localization": "*", "paypal/paypal-checkout-sdk": "*", - "php-ds/php-ds": "^1.3", "sentry/sentry-laravel": "*", "symfony/yaml": "*", "tightenco/ziggy": ">=0.8.1", diff --git a/composer.lock b/composer.lock index 91ddc6d9a0e..45ae87b2800 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cc91b3026bfa983837950f0e96fb7c8a", + "content-hash": "c748b63fdc90f32f3850b615cb80eb80", "packages": [ { "name": "anhskohbo/no-captcha", @@ -5807,61 +5807,6 @@ }, "time": "2021-09-14T21:35:26+00:00" }, - { - "name": "php-ds/php-ds", - "version": "v1.4.1", - "source": { - "type": "git", - "url": "https://github.com/php-ds/polyfill.git", - "reference": "43d2df301a9e2017f67b8c11d94a5222f9c00fd1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-ds/polyfill/zipball/43d2df301a9e2017f67b8c11d94a5222f9c00fd1", - "reference": "43d2df301a9e2017f67b8c11d94a5222f9c00fd1", - "shasum": "" - }, - "require": { - "ext-json": "*", - "php": ">=7.0" - }, - "provide": { - "ext-ds": "1.3.0" - }, - "require-dev": { - "php-ds/tests": "^1.3" - }, - "suggest": { - "ext-ds": "to improve performance and reduce memory usage" - }, - "type": "library", - "autoload": { - "psr-4": { - "Ds\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Rudi Theunissen", - "email": "rudolf.theunissen@gmail.com" - } - ], - "keywords": [ - "data structures", - "ds", - "php", - "polyfill" - ], - "support": { - "issues": "https://github.com/php-ds/polyfill/issues", - "source": "https://github.com/php-ds/polyfill/tree/v1.4.1" - }, - "time": "2022-03-09T20:39:30+00:00" - }, { "name": "php-http/cache-plugin", "version": "1.8.0", @@ -6434,16 +6379,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.20", + "version": "3.0.34", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "543a1da81111a0bfd6ae7bbc2865c5e89ed3fc67" + "reference": "56c79f16a6ae17e42089c06a2144467acc35348a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/543a1da81111a0bfd6ae7bbc2865c5e89ed3fc67", - "reference": "543a1da81111a0bfd6ae7bbc2865c5e89ed3fc67", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/56c79f16a6ae17e42089c06a2144467acc35348a", + "reference": "56c79f16a6ae17e42089c06a2144467acc35348a", "shasum": "" }, "require": { @@ -6524,7 +6469,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.20" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.34" }, "funding": [ { @@ -6540,7 +6485,7 @@ "type": "tidelift" } ], - "time": "2023-06-13T06:30:34+00:00" + "time": "2023-11-27T11:13:31+00:00" }, { "name": "psr/cache", @@ -13882,7 +13827,10 @@ }, "prefer-stable": false, "prefer-lowest": false, - "platform": [], + "platform": { + "ext-ds": "*", + "ext-redis": "*" + }, "platform-dev": [], "platform-overrides": { "php": "8.2.0" diff --git a/config/app.php b/config/app.php index 1c483773537..d39297d1714 100644 --- a/config/app.php +++ b/config/app.php @@ -1,6 +1,7 @@ [ - - /* - * Laravel Framework Service Providers... - */ - Illuminate\Auth\AuthServiceProvider::class, - Illuminate\Broadcasting\BroadcastServiceProvider::class, - Illuminate\Bus\BusServiceProvider::class, - Illuminate\Cache\CacheServiceProvider::class, - Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, - // Illuminate\Cookie\CookieServiceProvider::class, - Illuminate\Database\DatabaseServiceProvider::class, - Illuminate\Encryption\EncryptionServiceProvider::class, - Illuminate\Filesystem\FilesystemServiceProvider::class, - Illuminate\Foundation\Providers\FoundationServiceProvider::class, - // Illuminate\Hashing\HashServiceProvider::class, - Illuminate\Mail\MailServiceProvider::class, - Illuminate\Notifications\NotificationServiceProvider::class, - Illuminate\Pagination\PaginationServiceProvider::class, - Illuminate\Pipeline\PipelineServiceProvider::class, - Illuminate\Queue\QueueServiceProvider::class, - Illuminate\Redis\RedisServiceProvider::class, - Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, - // We're using our own SessionServiceProvider so we can override the session id naming (for redis key namespacing) - App\Providers\SessionServiceProvider::class, - Illuminate\Translation\TranslationServiceProvider::class, - Illuminate\Validation\ValidationServiceProvider::class, - Illuminate\View\ViewServiceProvider::class, - - /* - * Package Service Providers... - */ - GrahamCampbell\GitHub\GitHubServiceProvider::class, - Mariuzzo\LaravelJsLocalization\LaravelJsLocalizationServiceProvider::class, - Laravel\Tinker\TinkerServiceProvider::class, - - /* - * Application Service Providers... - */ + 'providers' => ServiceProvider::defaultProviders()->except([ + Illuminate\Cookie\CookieServiceProvider::class, + Illuminate\Hashing\HashServiceProvider::class, + Illuminate\Session\SessionServiceProvider::class, + ])->merge([ App\Providers\AppServiceProvider::class, + App\Providers\AuthServiceProvider::class, App\Providers\EventServiceProvider::class, + // Override default migrate:fresh + App\Providers\MigrationServiceProvider::class, App\Providers\RouteServiceProvider::class, - - /* - * After DB transaction commit support - */ + // Override the session id naming (for redis key namespacing) + App\Providers\SessionServiceProvider::class, + // After DB transaction commit support App\Providers\TransactionStateServiceProvider::class, - /* - * OAuth2 Setup - */ - - App\Providers\AuthServiceProvider::class, - Laravel\Passport\PassportServiceProvider::class, - - /* Datadog Metrics */ - ChaseConey\LaravelDatadogHelper\LaravelDatadogHelperServiceProvider::class, - - /* Override default migrate:fresh */ - App\Providers\MigrationServiceProvider::class, - ], + Mariuzzo\LaravelJsLocalization\LaravelJsLocalizationServiceProvider::class, + ])->toArray(), /* |-------------------------------------------------------------------------- diff --git a/database/migrations/2023_11_22_055714_change_multiplayer_scores_high_attempts_to_int.php b/database/migrations/2023_11_22_055714_change_multiplayer_scores_high_attempts_to_int.php new file mode 100644 index 00000000000..fc174c84ae7 --- /dev/null +++ b/database/migrations/2023_11_22_055714_change_multiplayer_scores_high_attempts_to_int.php @@ -0,0 +1,27 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::table('multiplayer_scores_high', function (Blueprint $table) { + $table->unsignedInteger('attempts')->default(0)->change(); + }); + } + + public function down(): void + { + Schema::table('multiplayer_scores_high', function (Blueprint $table) { + $table->unsignedTinyInteger('attempts')->default(0)->change(); + }); + } +}; diff --git a/phpunit.dusk.xml b/phpunit.dusk.xml index 0fe4666ae8f..ef830df7a43 100644 --- a/phpunit.dusk.xml +++ b/phpunit.dusk.xml @@ -1,24 +1,22 @@ - + - + ./tests/Browser - - - ./app - - diff --git a/resources/css/bem/chat-conversation-panel.less b/resources/css/bem/chat-conversation-panel.less index de9dabc370e..27d373d547f 100644 --- a/resources/css/bem/chat-conversation-panel.less +++ b/resources/css/bem/chat-conversation-panel.less @@ -5,9 +5,15 @@ display: flex; flex-direction: column; flex: 1; - overflow-y: auto; // without this, firefox scroll breaks position: relative; + @media @mobile { + overflow-y: hidden; // Needed to limit the height of the panel so chat-conversation is scrollable and + // chat-conversation-list doesn't become 0 height on mobile. + // height: 100% alone doesn't work. + // The panel itself should not be scrollable. + } + &__instructions { margin-top: 10px; } diff --git a/resources/js/components/textarea-autosize.tsx b/resources/js/components/textarea-autosize.tsx index b47d3da401a..10deca89f37 100644 --- a/resources/js/components/textarea-autosize.tsx +++ b/resources/js/components/textarea-autosize.tsx @@ -3,9 +3,9 @@ import autosize from 'autosize'; import React from 'react'; +import { present } from 'utils/string'; interface Props extends React.TextareaHTMLAttributes { - async: boolean; innerRef?: React.RefObject; maxRows?: number; } @@ -14,13 +14,14 @@ interface State { lineHeight?: number; } -export default class TextareaAutosize extends React.Component { +export default class TextareaAutosize extends React.PureComponent { static readonly defaultProps = { async: false, rows: 1, }; private readonly ref = this.props.innerRef ?? React.createRef(); + private shouldUpdate = true; private get maxHeight() { return this.props.maxRows != null && this.state.lineHeight != null @@ -36,7 +37,7 @@ export default class TextareaAutosize extends React.Component { componentDidMount() { if (this.ref.current == null) return; - if (this.props.maxRows != null || this.props.async) { + if (this.props.maxRows != null || present(this.props.value?.toString())) { window.setTimeout(() => { if (this.ref.current != null) { if (this.props.maxRows != null) { @@ -62,7 +63,12 @@ export default class TextareaAutosize extends React.Component { this.ref.current.style.overflowX = 'hidden'; } - autosize.update(this.ref.current); + // Avoid double updating since autosize automatically triggers update on input. + if (this.shouldUpdate) { + autosize.update(this.ref.current); + } else { + this.shouldUpdate = true; + } } componentWillUnmount() { @@ -71,16 +77,22 @@ export default class TextareaAutosize extends React.Component { } render() { - const { async, innerRef, maxRows, style, ...otherProps } = this.props; + const { innerRef, onInput, maxRows, style, ...otherProps } = this.props; const maxHeight = this.maxHeight; return (