diff --git a/.env.dusk.local.example b/.env.dusk.local.example index 1d6cfcf6ad5..e73eadd68fb 100644 --- a/.env.dusk.local.example +++ b/.env.dusk.local.example @@ -1,8 +1,8 @@ APP_KEY= APP_ENV=testing -APP_URL=http://nginx -NOTIFICATION_ENDPOINT=/home/notifications/feed-dusk +APP_URL=http://localhost:8000 +NOTIFICATION_ENDPOINT=ws://notification-server-dusk:2345 DB_DATABASE=osu_test DB_DATABASE_CHAT=osu_chat_test diff --git a/.env.example b/.env.example index 501c35121a1..7a25a12235b 100644 --- a/.env.example +++ b/.env.example @@ -251,8 +251,16 @@ CLIENT_CHECK_VERSION=false # OAUTH_MAX_USER_CLIENTS=1 -# USER_REPORT_NOTIFICATION_ENDPOINT_MODERATION= # USER_REPORT_NOTIFICATION_ENDPOINT_CHEATING= +# default if nothing specified for specific type +# USER_REPORT_NOTIFICATION_ENDPOINT_MODERATION= + +# USER_REPORT_NOTIFICATION_ENDPOINT_BEATMAPSET= +# USER_REPORT_NOTIFICATION_ENDPOINT_BEATMAPSET_DISCUSSION= +# USER_REPORT_NOTIFICATION_ENDPOINT_CHAT= +# USER_REPORT_NOTIFICATION_ENDPOINT_COMMENT= +# USER_REPORT_NOTIFICATION_ENDPOINT_FORUM= +# USER_REPORT_NOTIFICATION_ENDPOINT_USER= # LOG_CHANNEL=single diff --git a/.env.testing.example b/.env.testing.example index 2dbcf456d24..1e15950708d 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -1,10 +1,12 @@ -DB_DATABASE=osu_test DB_DATABASE_CHAT=osu_chat_test DB_DATABASE_MP=osu_mp_test DB_DATABASE_STORE=osu_store_test DB_DATABASE_UPDATES=osu_updates_test DB_DATABASE_CHARTS=osu_charts_test +# match with docker-compose.yml +DB_DATABASE=osu_test ES_INDEX_PREFIX=test_ +SCHEMA=test PAYMENT_SANDBOX=true diff --git a/.eslintrc.js b/.eslintrc.js index 2362d5840dc..7bc99cf655f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,7 +17,7 @@ module.exports = { 'plugin:react-hooks/recommended', 'plugin:react/recommended', ], - files: ['resources/js/**/*.{ts,tsx}', 'tests/karma/**/*.ts'], + files: ['resources/js/**/*.{ts,tsx}', 'tests/karma/**/*.{ts,tsx}'], parser: '@typescript-eslint/parser', plugins: [ '@typescript-eslint', @@ -181,7 +181,7 @@ module.exports = { browser: false, node: true, }, - files: ['tests/karma/**/*.ts'], + files: ['tests/karma/**/*.{ts,tsx}'], parserOptions: { project: 'tests/karma/tsconfig.json', sourceType: 'module', @@ -198,6 +198,7 @@ module.exports = { rules: { 'arrow-body-style': 'error', 'arrow-parens': 'error', + 'arrow-spacing': 'error', 'brace-style': 'error', 'comma-dangle': ['error', 'always-multiline'], complexity: 'off', diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f6828ec152a..986efc213d2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -48,7 +48,7 @@ jobs: - name: Install js dependencies run: yarn --frozen-lockfile - - run: 'yarn lint --max-warnings 130 > /dev/null' + - run: 'yarn lint --max-warnings 123 > /dev/null' - run: ./bin/update_licence.sh -nf diff --git a/.github/workflows/pack.yml b/.github/workflows/pack.yml index 11183dfab34..baf5116a20d 100644 --- a/.github/workflows/pack.yml +++ b/.github/workflows/pack.yml @@ -57,6 +57,41 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + notify_pending_production_deploy: + if: ${{ github.ref_type == 'tag' }} + runs-on: ubuntu-latest + needs: + - push_to_registry + steps: + - + name: Submit pending deployment notification + run: | + export TITLE="Pending osu-web Production Deployment: $GITHUB_REF_NAME" + export URL="https://github.com/ppy/osu-web/actions/runs/$GITHUB_RUN_ID" + export DESCRIPTION="Docker image was built for tag $GITHUB_REF_NAME and awaiting approval for production deployment: + [View Workflow Run]($URL)" + export ACTOR_ICON="https://avatars.githubusercontent.com/u/$GITHUB_ACTOR_ID" + + BODY="$(jq --null-input '{ + "embeds": [ + { + "title": env.TITLE, + "color": 15098112, + "description": env.DESCRIPTION, + "url": env.URL, + "author": { + "name": env.GITHUB_ACTOR, + "icon_url": env.ACTOR_ICON + } + } + ] + }')" + + curl \ + -H "Content-Type: application/json" \ + -d "$BODY" \ + "${{ secrets.DISCORD_INFRA_WEBHOOK_URL }}" + push_to_production: if: ${{ github.ref_type == 'tag' }} runs-on: ubuntu-latest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3c0aaf29428..a0b57880fe4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -151,6 +151,14 @@ jobs: - name: Run PHPUnit run: ./bin/phpunit.sh + # TODO: workaround things (beatmaps) being indexed during test above and not cleaned up. + # This used to cause beatmap listing returning cursor with Long.MIN_VALUE for null timetamp + # and errors out when trying to get the next page (es can't parse such value for timestamp) + # but has since been fixed. + # Something should still be done regarding es index between tests though. + - name: Clean indexes + run: php artisan es:index-documents --yes + - name: Run Dusk run: ./bin/run_dusk.sh diff --git a/README.md b/README.md index 3d32cdfac67..339f897f82c 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Please see [CONTRIBUTING.md](CONTRIBUTING.md) for information about the code sta While we have standards in place, nothing is set in stone. If you have an issue with the way code is structured; with any libraries we are using; with any processes involved with contributing, *please* bring it up. We welcome all feedback so we can make contributing to this project as pain-free as possible. -For those interested, we love to reward quality contributions via [bounties](https://docs.google.com/spreadsheets/d/1jNXfj_S3Pb5PErA-czDdC9DUu4IgUbe1Lt8E7CYUJuE/view?&rm=minimal#gid=523803337), paid out via paypal or osu! supporter tags. Don't hesitate to [request a bounty](https://docs.google.com/forms/d/e/1FAIpQLSet_8iFAgPMG526pBZ2Kic6HSh7XPM3fE8xPcnWNkMzINDdYg/viewform) for your work on this project. +We love to reward quality contributions. If you have made a large contribution or are a regular contributor, you are welcome to [submit an expense via opencollective](https://opencollective.com/ppy/expenses/new). If you have any questions, feel free to [reach out to peppy](mailto:pe@ppy.sh) before doing so. ## Seeking Help diff --git a/SETUP.md b/SETUP.md index 1addd7c0d65..7b3812bed68 100644 --- a/SETUP.md +++ b/SETUP.md @@ -238,7 +238,7 @@ Note that if you use the bundled docker compose setup, yarn/webpack will be alre $ php artisan migrate:fresh --seed ``` -Run the above command to rebuild the database and seed with sample data. In order for the seeder to seed beatmaps, you must enter a valid osu! API key as the value of the `OSU_API_KEY` property in the `.env` configuration file, as the seeder obtains beatmap data from the osu! API. The key can be obtained at [the "osu! API Access" page](https://old.ppy.sh/p/api), which is currently only available on the old site. +Run the above command to rebuild the database and seed with sample data. In order for the seeder to seed beatmaps, you must enter a valid osu! API key as the value of the `OSU_API_KEY` property in the `.env` configuration file, as the seeder obtains beatmap data from the osu! API. The key can be obtained from [the "Legacy API" section of your account settings page](https://osu.ppy.sh/home/account/edit#legacy-api). ## Continuous asset generation while developing @@ -319,6 +319,7 @@ bin/run_dusk.sh or if using Docker: ``` +# `compose exec` doesn't work here due to port conflict with dev instance docker compose run --rm php test browser ``` diff --git a/app/Console/Commands/NotificationsSendMail.php b/app/Console/Commands/NotificationsSendMail.php index 3a34e90851a..b1ef6dab8a2 100644 --- a/app/Console/Commands/NotificationsSendMail.php +++ b/app/Console/Commands/NotificationsSendMail.php @@ -61,7 +61,14 @@ public function handle() foreach ($userIds->chunk($chunkSize) as $chunk) { $users = User::whereIn('user_id', $chunk)->get(); foreach ($users as $user) { - dispatch(new UserNotificationDigest($user, $fromId, $toId)); + $job = new UserNotificationDigest($user, $fromId, $toId); + try { + $job->handle(); + } catch (\Exception $e) { + // catch exception and queue job to be rerun to avoid job exploding and preventing other notifications from being processed. + log_error($e); + dispatch($job); + } } } diff --git a/app/Console/Commands/RouteConvert.php b/app/Console/Commands/RouteConvert.php index 2d3a214e8c8..c1cd563aaa8 100644 --- a/app/Console/Commands/RouteConvert.php +++ b/app/Console/Commands/RouteConvert.php @@ -78,7 +78,7 @@ protected function write() } if (!$written) { - $this->line(json_encode($this->routeScopesHelper->toArray(), JSON_PRETTY_PRINT)); + $this->line(json_encode($this->routeScopesHelper->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); } } } diff --git a/app/Events/NewPrivateNotificationEvent.php b/app/Events/NewPrivateNotificationEvent.php index 39a4321f996..5374622220a 100644 --- a/app/Events/NewPrivateNotificationEvent.php +++ b/app/Events/NewPrivateNotificationEvent.php @@ -13,17 +13,14 @@ class NewPrivateNotificationEvent extends NotificationEventBase { use SerializesModels; - public $notification; - private $receiverIds; - /** * Create a new event instance. * * @return void */ - public function __construct(Notification $notification, array $receiverIds) + public function __construct(public Notification $notification, private array $receiverIds) { - parent::__construct($notification); + parent::__construct(); $this->notification = $notification; $this->receiverIds = $receiverIds; diff --git a/app/Exceptions/ModelNotSavedException.php b/app/Exceptions/ModelNotSavedException.php index 5f56dd4a160..81db87830d2 100644 --- a/app/Exceptions/ModelNotSavedException.php +++ b/app/Exceptions/ModelNotSavedException.php @@ -5,9 +5,26 @@ namespace App\Exceptions; +use Exception; +use Illuminate\Http\Response; + // This is used for model's saveOrExplode class ModelNotSavedException extends SilencedException { + public static function makeResponse(?Exception $e, array $modelFields): Response + { + $json = [ + 'error' => $e?->getMessage(), + 'form_error' => [], + ]; + + foreach ($modelFields as $field => $model) { + $json['form_error'][$field] = $model->validationErrors()->all(); + } + + return response($json, 422); + } + public function getStatusCode() { return 422; diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index 27635db31e0..2f795a46152 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -15,6 +15,8 @@ use App\Models\UserAccountHistory; use App\Models\UserNotificationOption; use App\Transformers\CurrentUserTransformer; +use App\Transformers\LegacyApiKeyTransformer; +use App\Transformers\LegacyIrcKeyTransformer; use Auth; use DB; use Mail; @@ -111,12 +113,20 @@ public function edit() $authorizedClients = json_collection(Client::forUser($user), 'OAuth\Client', 'user'); $ownClients = json_collection($user->oauthClients()->where('revoked', false)->get(), 'OAuth\Client', ['redirect', 'secret']); + $legacyApiKey = $user->apiKeys()->available()->first(); + $legacyApiKeyJson = $legacyApiKey === null ? null : json_item($legacyApiKey, new LegacyApiKeyTransformer()); + + $legacyIrcKey = $user->legacyIrcKey; + $legacyIrcKeyJson = $legacyIrcKey === null ? null : json_item($legacyIrcKey, new LegacyIrcKeyTransformer()); + $notificationOptions = $user->notificationOptions->keyBy('name'); return ext_view('accounts.edit', compact( 'authorizedClients', 'blocks', 'currentSessionId', + 'legacyApiKeyJson', + 'legacyIrcKeyJson', 'notificationOptions', 'ownClients', 'sessions' @@ -140,7 +150,7 @@ public function update() try { $user->fill($params)->saveOrExplode(); } catch (ModelNotSavedException $e) { - return $this->errorResponse($user, $e); + return ModelNotSavedException::makeResponse($e, compact('user')); } return json_item($user, new CurrentUserTransformer()); @@ -153,19 +163,17 @@ public function updateEmail() $previousEmail = $user->user_email; if ($user->update($params) === true) { - $addresses = [$user->user_email]; - if (present($previousEmail)) { - $addresses[] = $previousEmail; - } - foreach ($addresses as $address) { - Mail::to($address)->locale($user->preferredLocale())->send(new UserEmailUpdated($user)); + foreach ([$previousEmail, $user->user_email] as $address) { + if (is_valid_email_format($address)) { + Mail::to($address)->locale($user->preferredLocale())->send(new UserEmailUpdated($user)); + } } UserAccountHistory::logUserUpdateEmail($user, $previousEmail); return response([], 204); } else { - return $this->errorResponse($user); + return ModelNotSavedException::makeResponse(null, compact('user')); } } @@ -240,7 +248,10 @@ public function updateOptions() $user->profileCustomization()->fill($profileParams)->saveOrExplode(); } } catch (ModelNotSavedException $e) { - return $this->errorResponse($user, $e); + return ModelNotSavedException::makeResponse($e, [ + 'user' => $user, + 'user_profile_customization' => $user->profileCustomization(), + ]); } return json_item($user, new CurrentUserTransformer()); @@ -252,7 +263,7 @@ public function updatePassword() $user = Auth::user()->validateCurrentPassword()->validatePasswordConfirmation(); if ($user->update($params) === true) { - if (present($user->user_email)) { + if (is_valid_email_format($user->user_email)) { Mail::to($user)->send(new UserPasswordUpdated($user)); } @@ -262,7 +273,7 @@ public function updatePassword() return response([], 204); } else { - return $this->errorResponse($user); + return ModelNotSavedException::makeResponse(null, compact('user')); } } @@ -291,12 +302,4 @@ public function reissueCode() { return UserVerification::fromCurrentRequest()->reissue(); } - - private function errorResponse($user, $exception = null) - { - return response([ - 'form_error' => ['user' => $user->validationErrors()->all()], - 'error' => optional($exception)->getMessage(), - ], 422); - } } diff --git a/app/Http/Controllers/ArtistTracksController.php b/app/Http/Controllers/ArtistTracksController.php index 6a68eca4c44..4c2fc80d8bd 100644 --- a/app/Http/Controllers/ArtistTracksController.php +++ b/app/Http/Controllers/ArtistTracksController.php @@ -18,13 +18,14 @@ public function index() $search = new ArtistTrackSearch($params); $tracks = $search->records(); - $data = array_merge([ + $index = [ 'artist_tracks' => json_collection($tracks, new ArtistTrackTransformer(), ['artist', 'album']), 'search' => ArtistTrackSearchParamsFromRequest::toArray($params), - ], cursor_for_response($search->getSortCursor())); + ...cursor_for_response($search->getSortCursor()), + ]; if (is_json_request()) { - return $data; + return $index; } $availableGenres = cache_remember_mutexed( @@ -34,7 +35,7 @@ public function index() fn () => ArtistTrack::distinct()->pluck('genre')->sort()->values(), ); - return ext_view('artist_tracks.index', compact('availableGenres', 'data')); + return ext_view('artist_tracks.index', compact('availableGenres', 'index')); } public function show($id) diff --git a/app/Http/Controllers/BeatmapDiscussionsController.php b/app/Http/Controllers/BeatmapDiscussionsController.php index 40cef2707e0..71adb61614c 100644 --- a/app/Http/Controllers/BeatmapDiscussionsController.php +++ b/app/Http/Controllers/BeatmapDiscussionsController.php @@ -20,7 +20,7 @@ class BeatmapDiscussionsController extends Controller { public function __construct() { - $this->middleware('auth', ['except' => ['index', 'show']]); + $this->middleware('auth', ['except' => ['index', 'mediaUrl', 'show']]); $this->middleware('require-scopes:public', ['only' => ['index']]); parent::__construct(); @@ -117,6 +117,14 @@ public function index() return ext_view('beatmap_discussions.index', compact('json', 'search', 'paginator')); } + public function mediaUrl() + { + $url = get_string(request('url')); + + // Tell browser not to request url for a while. + return redirect(proxy_media($url))->header('Cache-Control', 'max-age=600'); + } + public function restore($id) { $discussion = BeatmapDiscussion::whereNotNull('deleted_at')->findOrFail($id); diff --git a/app/Http/Controllers/BeatmapsetsController.php b/app/Http/Controllers/BeatmapsetsController.php index 299d344fc28..a9b5d5d603b 100644 --- a/app/Http/Controllers/BeatmapsetsController.php +++ b/app/Http/Controllers/BeatmapsetsController.php @@ -279,26 +279,33 @@ public function update($id) 'nsfw:bool', ]); - $offsetParams = get_params($params, 'beatmapset', [ - 'offset:int', - ]); - - $updateParams = array_merge($metadataParams, $offsetParams); - if (count($metadataParams) > 0) { priv_check('BeatmapsetMetadataEdit', $beatmapset)->ensureCan(); } - if (count($offsetParams) > 0) { + $updateParams = [ + ...$metadataParams, + ...get_params($params, 'beatmapset', [ + 'offset:int', + 'tags:string', + ]), + ]; + + if (array_key_exists('offset', $updateParams)) { priv_check('BeatmapsetOffsetEdit')->ensureCan(); } + if (array_key_exists('tags', $updateParams)) { + priv_check('BeatmapsetTagsEdit')->ensureCan(); + } + if (count($updateParams) > 0) { DB::transaction(function () use ($beatmapset, $updateParams) { $oldGenreId = $beatmapset->genre_id; $oldLanguageId = $beatmapset->language_id; $oldNsfw = $beatmapset->nsfw; $oldOffset = $beatmapset->offset; + $oldTags = $beatmapset->tags; $user = auth()->user(); $beatmapset->fill($updateParams)->saveOrExplode(); @@ -330,6 +337,13 @@ public function update($id) 'new' => $beatmapset->offset, ])->saveOrExplode(); } + + if ($oldTags !== $beatmapset->tags) { + BeatmapsetEvent::log(BeatmapsetEvent::TAGS_EDIT, $user, $beatmapset, [ + 'old' => $oldTags, + 'new' => $beatmapset->tags, + ])->saveOrExplode(); + } }); } diff --git a/app/Http/Controllers/CommentsController.php b/app/Http/Controllers/CommentsController.php index ced24bded86..c17d77b199e 100644 --- a/app/Http/Controllers/CommentsController.php +++ b/app/Http/Controllers/CommentsController.php @@ -194,6 +194,10 @@ public function store() $comment = new Comment($params); $comment->setCommentable(); + if ($comment->commentable === null) { + abort(422, 'invalid commentable specified'); + } + priv_check('CommentStore', $comment->commentable)->ensureCan(); try { diff --git a/app/Http/Controllers/Forum/PostsController.php b/app/Http/Controllers/Forum/PostsController.php index 6d0f2299932..c7747e95b75 100644 --- a/app/Http/Controllers/Forum/PostsController.php +++ b/app/Http/Controllers/Forum/PostsController.php @@ -156,7 +156,7 @@ public function raw($id) $text = $post->bodyRaw; if (Request::input('quote') === '1') { - $text = sprintf('[quote="%s"]%s[/quote]', $post->userNormalized()->username, $text); + $text = sprintf("[quote=\"%s\"]\n%s\n[/quote]\n", $post->userNormalized()->username, $text); } return $text; diff --git a/app/Http/Controllers/InterOp/UsersController.php b/app/Http/Controllers/InterOp/UsersController.php index 93cc6aa6147..a5678945d7c 100644 --- a/app/Http/Controllers/InterOp/UsersController.php +++ b/app/Http/Controllers/InterOp/UsersController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\InterOp; +use App\Exceptions\ModelNotSavedException; use App\Exceptions\ValidationException; use App\Http\Controllers\Controller; use App\Libraries\UserRegistration; @@ -59,9 +60,9 @@ public function store() return json_item($registration->user()->fresh(), new CurrentUserTransformer()); } catch (ValidationException $ex) { - return response(['form_error' => [ - 'user' => $registration->user()->validationErrors()->all(), - ]], 422); + return ModelNotSavedException::makeResponse($ex, [ + 'user' => $registration->user(), + ]); } } } diff --git a/app/Http/Controllers/LegacyApiKeyController.php b/app/Http/Controllers/LegacyApiKeyController.php new file mode 100644 index 00000000000..4fb7e8905e3 --- /dev/null +++ b/app/Http/Controllers/LegacyApiKeyController.php @@ -0,0 +1,51 @@ +. 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\Http\Controllers; + +use App\Exceptions\ModelNotSavedException; +use App\Transformers\LegacyApiKeyTransformer; +use Auth; +use Request; + +class LegacyApiKeyController extends Controller +{ + public function __construct() + { + $this->middleware('auth'); + $this->middleware('verify-user'); + } + + public function destroy() + { + Auth::user()->apiKeys()->available()->update(['revoked' => true]); + + return response(null, 204); + } + + public function store() + { + priv_check('LegacyApiKeyStore')->ensureCan(); + + $params = get_params(Request::all(), 'legacy_api_key', [ + 'app_name', + 'app_url', + ]); + $apiKey = Auth::user()->apiKeys()->make([ + ...$params, + 'api_key' => bin2hex(random_bytes(20)), + ]); + + try { + $apiKey->saveOrExplode(); + } catch (ModelNotSavedException $e) { + return ModelNotSavedException::makeResponse($e, ['legacy_api_key' => $apiKey]); + } + + return json_item($apiKey, new LegacyApiKeyTransformer()); + } +} diff --git a/app/Http/Controllers/LegacyIrcKeyController.php b/app/Http/Controllers/LegacyIrcKeyController.php new file mode 100644 index 00000000000..cf66e810a98 --- /dev/null +++ b/app/Http/Controllers/LegacyIrcKeyController.php @@ -0,0 +1,55 @@ +. 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\Http\Controllers; + +use App\Transformers\LegacyIrcKeyTransformer; +use Auth; +use Exception; + +class LegacyIrcKeyController extends Controller +{ + public function __construct() + { + $this->middleware('auth'); + $this->middleware('verify-user'); + } + + public function destroy() + { + Auth::user()->legacyIrcKey?->delete(); + + return response(null, 204); + } + + public function store() + { + $user = Auth::user(); + + priv_check('LegacyIrcKeyStore')->ensureCan(); + + $key = $user->legacyIrcKey; + + if ($key === null) { + for ($i = 0; $i < 10; $i++) { + try { + $key = $user->legacyIrcKey()->make([ + 'token' => bin2hex(random_bytes(4)), + ]); + $key->saveOrExplode(); + break; + } catch (Exception $e) { + if (!is_sql_unique_exception($e)) { + throw $e; + } + } + } + } + + return json_item($key, new LegacyIrcKeyTransformer()); + } +} diff --git a/app/Http/Controllers/PasswordResetController.php b/app/Http/Controllers/PasswordResetController.php index 0ccf79e479f..9e862235aaf 100644 --- a/app/Http/Controllers/PasswordResetController.php +++ b/app/Http/Controllers/PasswordResetController.php @@ -127,7 +127,7 @@ private function issue($username) return osu_trans('password_reset.error.user_not_found'); } - if (!present($user->user_email)) { + if (!is_valid_email_format($user->user_email)) { return osu_trans('password_reset.error.contact_support'); } diff --git a/app/Http/Controllers/RankingController.php b/app/Http/Controllers/RankingController.php index 59bedef80fa..8712278d3ea 100644 --- a/app/Http/Controllers/RankingController.php +++ b/app/Http/Controllers/RankingController.php @@ -91,7 +91,7 @@ public function __construct() $this->defaultViewVars['country'] = $this->country; if ($type === 'performance') { - $this->defaultViewVars['countries'] = json_collection($this->getCountries($mode), 'Country', ['display']); + $this->defaultViewVars['countries'] = json_collection($this->getCountries($mode), new SelectOptionTransformer()); } return $next($request); diff --git a/app/Http/Controllers/ReportsController.php b/app/Http/Controllers/ReportsController.php index 2667a4136f2..0c78f495385 100644 --- a/app/Http/Controllers/ReportsController.php +++ b/app/Http/Controllers/ReportsController.php @@ -25,7 +25,7 @@ public function store() 'reason', 'reportable_id:int', 'reportable_type', - ]); + ], ['null_missing' => true]); $class = MorphMap::getClass($params['reportable_type']); if ($class === null) { diff --git a/app/Http/Controllers/UsersController.php b/app/Http/Controllers/UsersController.php index 06a00065b71..b085b990be9 100644 --- a/app/Http/Controllers/UsersController.php +++ b/app/Http/Controllers/UsersController.php @@ -685,7 +685,7 @@ private function parsePaginationParams() abort(404); } - $this->offset = get_int(Request::input('offset')) ?? 0; + $this->offset = max(0, get_int(Request::input('offset')) ?? 0); if ($this->offset >= $this->maxResults) { $this->perPage = 0; @@ -982,9 +982,9 @@ private function storeUser(array $rawParams) return json_item($user->fresh(), new CurrentUserTransformer()); } } catch (ValidationException $e) { - return response(['form_error' => [ - 'user' => $registration->user()->validationErrors()->all(), - ]], 422); + return ModelNotSavedException::makeResponse($e, [ + 'user' => $registration->user(), + ]); } } } diff --git a/app/Http/Middleware/SetLocale.php b/app/Http/Middleware/SetLocale.php index ab5f26f29e6..2fc3ed7bded 100644 --- a/app/Http/Middleware/SetLocale.php +++ b/app/Http/Middleware/SetLocale.php @@ -41,7 +41,7 @@ protected function setLocale(?string $locale, Request $request): void App::setLocale($locale); // Carbon setLocale normalizes the locale - Carbon::setLocale($locale); + Carbon::setLocale($locale === 'sr' ? 'sr_Cyrl' : $locale); } private function localeFromHeader(Request $request): string diff --git a/app/Jobs/NotifyForumUpdateMail.php b/app/Jobs/NotifyForumUpdateMail.php deleted file mode 100644 index 73089ccaea8..00000000000 --- a/app/Jobs/NotifyForumUpdateMail.php +++ /dev/null @@ -1,78 +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\Jobs; - -use App\Mail\ForumNewReply; -use App\Models\UserNotificationOption; -use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Queue\SerializesModels; -use Mail; - -class NotifyForumUpdateMail implements ShouldQueue -{ - use Queueable, SerializesModels; - - public $topic; - public $user; - - public function __construct($data) - { - $this->topic = $data['topic']; - $this->user = $data['user']; - } - - public function handle() - { - if ($this->topic === null) { - return; - } - - $watches = $this->topic->watches() - ->where('mail', '=', true) - ->where('notify_status', '=', false) - ->has('user') - ->with('user', 'topic') - ->get(); - - $options = UserNotificationOption - ::whereIn('user_id', $watches->pluck('user_id')) - ->where(['name' => UserNotificationOption::FORUM_TOPIC_REPLY]) - ->get() - ->keyBy('user_id'); - - foreach ($watches as $watch) { - $user = $watch->user; - - if (!present($user->user_email)) { - continue; - } - - if (($options[$user->getKey()]->details['mail'] ?? true) !== true) { - continue; - } - - if ($this->user !== null && $this->user->getKey() === $user->getKey()) { - continue; - } - - if ($user->getKey() === $this->topic->topic_last_poster_id) { - continue; - } - - if (!priv_check_user($user, 'ForumTopicWatch', $this->topic)->can()) { - continue; - } - - Mail::to($user)->queue(new ForumNewReply([ - 'topic' => $this->topic, - 'user' => $user, - ])); - - $watch->update(['notify_status' => true]); - } - } -} diff --git a/app/Jobs/UserNotificationDigest.php b/app/Jobs/UserNotificationDigest.php index b21e00a1b29..57606b56d2c 100644 --- a/app/Jobs/UserNotificationDigest.php +++ b/app/Jobs/UserNotificationDigest.php @@ -11,6 +11,7 @@ use App\Models\Notification; use App\Models\User; use App\Models\UserNotification; +use Datadog; use DB; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -38,7 +39,7 @@ public function __construct(User $user, int $fromId, int $toId) public function handle() { - if (!present($this->user->email)) { + if (!is_valid_email_format($this->user->user_email)) { return; } @@ -50,6 +51,8 @@ public function handle() // TODO: catch and log errors? Mail::to($this->user)->send(new UserNotificationDigestMail($notifications, $this->user)); + + Datadog::increment(config('datadog-helper.prefix_web').'.user_notification_digest.mail', 1); } private function filterNotifications(Collection $notifications) diff --git a/app/Libraries/BBCodeForDB.php b/app/Libraries/BBCodeForDB.php index 094588eccb6..a20c675307f 100644 --- a/app/Libraries/BBCodeForDB.php +++ b/app/Libraries/BBCodeForDB.php @@ -10,6 +10,15 @@ class BBCodeForDB { + const EXTRA_ESCAPES = [ + '[' => '[', + ']' => ']', + '.' => '.', + ':' => ':', + "\n" => ' ', + '@' => '@', + ]; + public $text; public $uid; @@ -20,17 +29,15 @@ class BBCodeForDB public function extraEscapes($text) { - return strtr( - $text, - [ - '[' => '[', - ']' => ']', - '.' => '.', - ':' => ':', - "\n" => ' ', - '@' => '@', - ], - ); + return strtr($text, static::EXTRA_ESCAPES); + } + + public static function extraUnescape(string $text): string + { + static $mapping; + $mapping ??= array_flip(static::EXTRA_ESCAPES); + + return strtr($text, $mapping); } public function __construct($text = '') @@ -72,12 +79,13 @@ public function parseBlockSimple($text) public function parseBox($text) { - $text = preg_replace('#\[box=([^]]*?)\]#s', "[box=\\1:{$this->uid}]", $text); - $text = str_replace('[/box]', "[/box:{$this->uid}]", $text); - $text = str_replace('[spoilerbox]', "[spoilerbox:{$this->uid}]", $text); - $text = str_replace('[/spoilerbox]', "[/spoilerbox:{$this->uid}]", $text); + $text = preg_replace('#\[box=((\\\[\[\]]|[^][]|\[(\\\[\[\]]|[^][]|(?R))*\])*?)\]#s', "[box=\\1:{$this->uid}]", $text); - return $text; + return strtr($text, [ + '[/box]' => "[/box:{$this->uid}]", + '[spoilerbox]' => "[spoilerbox:{$this->uid}]", + '[/spoilerbox]' => "[/spoilerbox:{$this->uid}]", + ]); } public function parseCode($text) @@ -134,6 +142,19 @@ public function parseImage($text) return $text; } + public function parseImagemap($text) + { + return preg_replace_callback( + '#\[imagemap\](.+?)\[/imagemap\]#s', + function ($m) { + $escapedMap = $this->extraEscapes($m[1]); + + return "[imagemap]{$escapedMap}[/imagemap]"; + }, + $text + ); + } + /** * Handles: * - Code (c) @@ -159,29 +180,29 @@ public function parseLinks($text) // internal url $text = preg_replace( - "#{$spaces[0]}({$internalUrl}/([^\s]+?)){$spaces[1]}#", - "\\1\\3\\4", + "#{$spaces[0]}({$internalUrl}/([^\s]+?))(?={$spaces[1]})#", + "\\1\\3", $text ); // plain http/https/ftp $text = preg_replace( - "#{$spaces[0]}((?:https?|ftp)://[^\s]+?){$spaces[1]}#", - "\\1\\2\\3", + "#{$spaces[0]}((?:https?|ftp)://[^\s]+?)(?={$spaces[1]})#", + "\\1\\2", $text ); // www $text = preg_replace( - "#{$spaces[0]}(www\.[^\s]+){$spaces[1]}#", - "\\1\\2\\3", + "#{$spaces[0]}(www\.[^\s]+)(?={$spaces[1]})#", + "\\1\\2", $text ); // emails $text = preg_replace( - "#{$spaces[0]}([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z-]+){$spaces[1]}#", - "\\1\\2\\3", + "#{$spaces[0]}([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z-]+)(?={$spaces[1]})#", + "\\1\\2", $text ); @@ -369,6 +390,7 @@ public function generate() $text = htmlentities($this->text, ENT_QUOTES, 'UTF-8', true); $text = $this->unifyNewline($text); + $text = $this->parseImagemap($text); $text = $this->parseCode($text); $text = $this->parseNotice($text); $text = $this->parseBox($text); diff --git a/app/Libraries/BBCodeFromDB.php b/app/Libraries/BBCodeFromDB.php index 93b2c78a5b7..074810f22a9 100644 --- a/app/Libraries/BBCodeFromDB.php +++ b/app/Libraries/BBCodeFromDB.php @@ -38,11 +38,6 @@ public function __construct($text, $uid = '', $options = []) } } - public function clearSpacesBetweenTags($text) - { - return preg_replace('/([^-][^-]>)\s*uid}\](?[^[]+)\[/audio:{$this->uid}\]#", $text, $matches, PREG_SET_ORDER); @@ -67,7 +62,7 @@ public function parseBold($text) public function parseBox($text) { - $text = preg_replace("#\[box=([^]]*?):{$this->uid}\]\n*#s", $this->parseBoxHelperPrefix('\\1'), $text); + $text = preg_replace("#\[box=((\\\[\[\]]|[^][]|\[(\\\[\[\]]|[^][]|(?R))*\])*?):{$this->uid}\]\n*#s", $this->parseBoxHelperPrefix('\\1'), $text); $text = preg_replace("#\n*\[/box:{$this->uid}]\n?#s", $this->parseBoxHelperSuffix(), $text); $text = preg_replace("#\[spoilerbox:{$this->uid}\]\n*#s", $this->parseBoxHelperPrefix(), $text); @@ -129,11 +124,62 @@ public function parseEmail($text) public function parseHeading($text) { $text = str_replace("[heading:{$this->uid}]", '

', $text); - $text = str_replace("[/heading:{$this->uid}]", '

', $text); + $text = preg_replace("#\[/heading:{$this->uid}\]\n?#", '', $text); return $text; } + public function parseImagemap($text) + { + return preg_replace_callback( + '#(\[imagemap\].+?\[/imagemap\]\n?)#', + function ($m) { + return preg_replace_callback( + '#\[imagemap\]\n(?https?://.+)\n(?(?:(?:[0-9.]+ ){4}(?:\#|https?://[^\s]+|mailto:[^\s]+)(?: .*)?\n)+)\[/imagemap\]\n?#', + function ($map) { + $links = array_map( + fn ($rawLink) => explode(' ', $rawLink, 6), + explode("\n", $map['links']), + ); + array_pop($links); // remove the empty string from last newline + + $linksHtml = implode('', array_map( + fn ($link) => tag($link[4] === '#' ? 'span' : 'a', [ + 'class' => 'imagemap__link', + 'href' => $link[4], + 'style' => implode(';', [ + "left: {$link[0]}%", + "top: {$link[1]}%", + "width: {$link[2]}%", + "height: {$link[3]}%", + ]), + 'title' => $link[5] ?? '', + ]), + $links, + )); + + $imageUrl = proxy_media($map['imageUrl']); + $imageAttributes = [ + 'class' => 'imagemap__image', + 'loading' => 'lazy', + 'src' => $imageUrl, + ]; + $imageSize = fast_imagesize($imageUrl); + if ($imageSize !== null) { + $imageAttributes['width'] = $imageSize[0]; + $imageAttributes['height'] = $imageSize[1]; + } + $imageHtml = tag('img', $imageAttributes); + + return tag('div', ['class' => 'imagemap'], $imageHtml.$linksHtml); + }, + html_entity_decode_better(BBCodeForDB::extraUnescape($m[1])), + ); + }, + $text, + ); + } + public function parseItalic($text) { $text = str_replace("[i:{$this->uid}]", '', $text); @@ -195,12 +241,11 @@ public function parseList($text) $text = preg_replace("#\[list:{$this->uid}\]\s*\[\*:{$this->uid}\]#", '
  1. ', $text); // convert list items. - $text = preg_replace("#\[/\*(:m)?:{$this->uid}\]\n?#", '
  2. ', $text); - $text = str_replace("[*:{$this->uid}]", '
  3. ', $text); + $text = preg_replace("#\[/\*(:m)?:{$this->uid}\]\n?\n?#", '
  4. ', $text); + $text = preg_replace("#\s*\[\*:{$this->uid}\]#", '
  5. ', $text); // close list tags. - $text = str_replace("[/list:o:{$this->uid}]", '
', $text); - $text = str_replace("[/list:u:{$this->uid}]", '', $text); + $text = preg_replace("#\s*\[/list:(o|u):{$this->uid}\]\n?\n?#", '', $text); // list with "title", with it being just a list without style. $text = preg_replace("#\[list=[^]]+:{$this->uid}\](.+?)(
  • |)#s", '
    • $1
      $2', $text); @@ -236,13 +281,12 @@ public function parseQuote($text) { $text = preg_replace("#\[quote="([^:]+)":{$this->uid}\]\s*#", '

      \\1 wrote:

      ', $text); $text = preg_replace("#\[quote:{$this->uid}\]\s*#", '
      ', $text); - $text = preg_replace("#\s*\[/quote:{$this->uid}\]\s*#", '
      ', $text); + $text = preg_replace("#\s*\[/quote:{$this->uid}\]\n?\n?#", '
      ', $text); return $text; } // stolen from: www/forum/includes/functions.php:2845 - public function parseSmilies($text) { return preg_replace('#uid}\]#", "", $text); - $text = str_replace("[/size:{$this->uid}]", '', $text); + $text = preg_replace_callback( + "#\[size=(\d+):{$this->uid}\]#", + fn ($m) => '', + $text, + ); + $text = strtr($text, ["[/size:{$this->uid}]" => '']); return $text; } @@ -293,8 +341,8 @@ public function parseUrl($text) public function parseYoutube($text) { - $text = str_replace("[youtube:{$this->uid}]", "
      ", $text); + $text = str_replace("[youtube:{$this->uid}]", "
      ", $text); return $text; } @@ -304,13 +352,13 @@ public function toHTML() $text = $this->text; // block + $text = $this->parseImagemap($text); $text = $this->parseBox($text); $text = $this->parseCode($text); $text = $this->parseList($text); $text = $this->parseNotice($text); $text = $this->parseQuote($text); $text = $this->parseHeading($text); - $text = $this->clearSpacesBetweenTags($text); // inline $text = $this->parseAudio($text); diff --git a/app/Libraries/ChangeUsername.php b/app/Libraries/ChangeUsername.php index 5f78384b058..87cebeb65fa 100644 --- a/app/Libraries/ChangeUsername.php +++ b/app/Libraries/ChangeUsername.php @@ -68,7 +68,7 @@ public function validate(): ValidationErrors return $this->validationErrors()->merge(UsernameValidation::validateAvailability($this->username)); } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'user'; } diff --git a/app/Libraries/CleanHTML.php b/app/Libraries/CleanHTML.php index f4293501be7..3327563a77e 100644 --- a/app/Libraries/CleanHTML.php +++ b/app/Libraries/CleanHTML.php @@ -28,6 +28,7 @@ public function __construct() $config->set('Cache.SerializerPath', $cachePath); $config->set('Attr.AllowedRel', ['nofollow']); $config->set('HTML.Trusted', true); + $config->set('CSS.Trusted', true); $def = $config->getHTMLDefinition(true); diff --git a/app/Libraries/Elasticsearch/Hit.php b/app/Libraries/Elasticsearch/Hit.php index 56324eef39f..82f92e638b0 100644 --- a/app/Libraries/Elasticsearch/Hit.php +++ b/app/Libraries/Elasticsearch/Hit.php @@ -83,22 +83,22 @@ public function source($key = null) // ArrayAccess //================ - public function offsetExists($key) + public function offsetExists($key): bool { return array_has($this->raw, $key); } - public function offsetGet($key) + public function offsetGet($key): mixed { return data_get($this->raw, $key); } - public function offsetSet($key, $value) + public function offsetSet($key, $value): void { throw new \BadMethodCallException('not supported'); } - public function offsetUnset($key) + public function offsetUnset($key): void { throw new \BadMethodCallException('not supported'); } diff --git a/app/Libraries/Elasticsearch/SearchResponse.php b/app/Libraries/Elasticsearch/SearchResponse.php index caefa081602..28dc2eecf6e 100644 --- a/app/Libraries/Elasticsearch/SearchResponse.php +++ b/app/Libraries/Elasticsearch/SearchResponse.php @@ -30,7 +30,7 @@ public function aggregations(string $name = null) : $this->raw['aggregations'][$name] ?? []; } - public function count() + public function count(): int { return count($this->hits()); } @@ -136,22 +136,22 @@ public function total() // ArrayAccess //================ - public function offsetExists($key) + public function offsetExists($key): bool { return array_has($this->hits(), $key); } - public function offsetGet($key) + public function offsetGet($key): mixed { return new Hit(data_get($this->hits(), $key)); } - public function offsetSet($key, $value) + public function offsetSet($key, $value): void { throw new \BadMethodCallException('not supported'); } - public function offsetUnset($key) + public function offsetUnset($key): void { throw new \BadMethodCallException('not supported'); } @@ -160,27 +160,27 @@ public function offsetUnset($key) // Iterator //================ - public function current() + public function current(): mixed { return $this[$this->index]; } - public function key() + public function key(): mixed { return $this->index; } - public function next() + public function next(): void { $this->index++; } - public function rewind() + public function rewind(): void { $this->index = 0; } - public function valid() + public function valid(): bool { return $this->offsetExists($this->index); } diff --git a/app/Libraries/Fulfillments/BannerFulfillment.php b/app/Libraries/Fulfillments/BannerFulfillment.php index e568f2e86f2..c1036d9e5fb 100644 --- a/app/Libraries/Fulfillments/BannerFulfillment.php +++ b/app/Libraries/Fulfillments/BannerFulfillment.php @@ -87,12 +87,12 @@ private function revokeBanner(OrderItem $orderItem) //================ // Validatable //================ - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'fulfillments.banner-supporter'; } - public function validationErrorsKeyBase() + public function validationErrorsKeyBase(): string { return 'model_validation/'; } diff --git a/app/Libraries/Fulfillments/GenericFulfillment.php b/app/Libraries/Fulfillments/GenericFulfillment.php index 9c67b43ccfa..ffdaee29d01 100644 --- a/app/Libraries/Fulfillments/GenericFulfillment.php +++ b/app/Libraries/Fulfillments/GenericFulfillment.php @@ -31,7 +31,7 @@ public function revoke() event("store.fulfillments.revoke.{$this->taggedName()}", new OrderFulfillerEvent($this->order)); } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return ''; } diff --git a/app/Libraries/Fulfillments/OrderFulfiller.php b/app/Libraries/Fulfillments/OrderFulfiller.php index a3c4b829ba7..e351fd350f7 100644 --- a/app/Libraries/Fulfillments/OrderFulfiller.php +++ b/app/Libraries/Fulfillments/OrderFulfiller.php @@ -49,7 +49,7 @@ protected function throwOnFail(bool $valid = false) } } - abstract public function validationErrorsTranslationPrefix(); + abstract public function validationErrorsTranslationPrefix(): string; protected function dispatchValidationFailed() { diff --git a/app/Libraries/Fulfillments/SupporterTagFulfillment.php b/app/Libraries/Fulfillments/SupporterTagFulfillment.php index 0e21c1a1efa..8abe13c4756 100644 --- a/app/Libraries/Fulfillments/SupporterTagFulfillment.php +++ b/app/Libraries/Fulfillments/SupporterTagFulfillment.php @@ -73,7 +73,7 @@ private function afterRun() ); } - if (present($donor->user_email)) { + if (is_valid_email_format($donor->user_email)) { $donationTotal = $items->sum('cost'); $totalDuration = $isGift ? null : $items->sum('extra_data.duration'); // duration is not relevant for gift. @@ -90,7 +90,7 @@ private function afterRun() Event::generate('userSupportGift', ['user' => $giftee, 'date' => $this->order->paid_at]); - if (present($giftee->user_email)) { + if (is_valid_email_format($giftee->user_email)) { $duration = 0; $messages = []; @@ -172,12 +172,12 @@ private function createFulfiller(OrderItem $item) //================ // Validatable //================ - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'fulfillments.supporter_tag'; } - public function validationErrorsKeyBase() + public function validationErrorsKeyBase(): string { return 'model_validation/'; } diff --git a/app/Libraries/Fulfillments/UsernameChangeFulfillment.php b/app/Libraries/Fulfillments/UsernameChangeFulfillment.php index f6169fcd0f8..6b16547de55 100644 --- a/app/Libraries/Fulfillments/UsernameChangeFulfillment.php +++ b/app/Libraries/Fulfillments/UsernameChangeFulfillment.php @@ -113,12 +113,12 @@ private function getNewUsername() //================ // Validatable //================ - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'fulfillments.username_change'; } - public function validationErrorsKeyBase() + public function validationErrorsKeyBase(): string { return 'model_validation/'; } diff --git a/app/Libraries/LocaleMeta.php b/app/Libraries/LocaleMeta.php index 88b8370d40c..1e419d7dae6 100644 --- a/app/Libraries/LocaleMeta.php +++ b/app/Libraries/LocaleMeta.php @@ -22,6 +22,10 @@ class LocaleMeta 'flag' => 'BG', 'name' => 'Български', ], + 'ca' => [ + 'flag' => 'AD', // ES-CA in crowdin + 'name' => 'català', + ], 'cs' => [ 'flag' => 'CZ', 'name' => 'Česky', @@ -51,10 +55,18 @@ class LocaleMeta 'flag' => 'FI', 'name' => 'Suomi', ], + 'fil' => [ + 'flag' => 'PH', + 'name' => 'Wikang Filipino', + ], 'fr' => [ 'flag' => 'FR', 'name' => 'français', ], + 'he' => [ + 'flag' => 'IL', + 'name' => 'עִבְרִית‎', + ], 'hu' => [ 'flag' => 'HU', 'name' => 'Magyar', @@ -75,6 +87,10 @@ class LocaleMeta 'flag' => 'KR', 'name' => '한국어', ], + 'lt' => [ + 'flag' => 'LT', + 'name' => 'lietuvių kalba', + ], 'nl' => [ 'flag' => 'NL', 'name' => 'Nederlands', @@ -110,6 +126,15 @@ class LocaleMeta 'flag' => 'SK', 'name' => 'Slovenčina', ], + 'sl' => [ + 'flag' => 'SI', + 'name' => 'slovenščina', + ], + 'sr' => [ + 'flag' => 'RS', + 'moment' => 'sr-cyrl', + 'name' => 'српски', + ], 'sv' => [ 'flag' => 'SE', 'name' => 'Svenska', diff --git a/app/Libraries/MorphMap.php b/app/Libraries/MorphMap.php index 895e5d4edf4..91f41953dae 100644 --- a/app/Libraries/MorphMap.php +++ b/app/Libraries/MorphMap.php @@ -10,6 +10,7 @@ use App\Models\Beatmapset; use App\Models\Build; use App\Models\Chat\Channel; +use App\Models\Chat\Message; use App\Models\Comment; use App\Models\Forum; use App\Models\LegacyMatch; @@ -30,6 +31,7 @@ class MorphMap Forum\Post::class => 'forum_post', Forum\Topic::class => 'forum_topic', LegacyMatch\Score::class => 'legacy_match_score', + Message::class => 'message', NewsPost::class => 'news_post', Score\Best\Fruits::class => 'score_best_fruits', Score\Best\Mania::class => 'score_best_mania', diff --git a/app/Libraries/NewForumTopic.php b/app/Libraries/NewForumTopic.php index fb5d8e6b26f..5ada3c175b7 100644 --- a/app/Libraries/NewForumTopic.php +++ b/app/Libraries/NewForumTopic.php @@ -54,12 +54,12 @@ public function post() ]); } - public function titlePlaceholder() + public function titlePlaceholder(): ?string { - if ($this->forum->isHelpForum()) { + return $this->forum->isHelpForum() // In English language forum, no localization. - return 'What is your problem (50 characters)'; - } + ? 'What is your problem (50 characters)' + : null; } public function toArray() diff --git a/app/Libraries/OsuAuthorize.php b/app/Libraries/OsuAuthorize.php index 4302c1ffd11..9b90e5a5f82 100644 --- a/app/Libraries/OsuAuthorize.php +++ b/app/Libraries/OsuAuthorize.php @@ -887,6 +887,17 @@ public function checkBeatmapsetOffsetEdit(): string return 'unauthorized'; } + public function checkBeatmapsetTagsEdit(?User $user): string + { + $this->ensureLoggedIn($user); + + if ($user->isModerator()) { + return 'ok'; + } + + return 'unauthorized'; + } + /** * @param User|null $user * @return string @@ -1470,20 +1481,22 @@ public function checkForumPostStore(?User $user, Forum $forum): string $this->ensureLoggedIn($user); $this->ensureCleanRecord($user); - $plays = $user->playCount(); - $posts = $user->user_posts; - $forInitialHelpForum = in_array($forum->forum_id, config('osu.forum.initial_help_forum_ids'), true); + if (!$user->isBot()) { + $plays = $user->playCount(); + $posts = $user->user_posts; + $forInitialHelpForum = in_array($forum->forum_id, config('osu.forum.initial_help_forum_ids'), true); - if ($forInitialHelpForum) { - if ($plays < 10 && $posts > 10) { - return $prefix.'too_many_help_posts'; - } - } else { - if ($plays < config('osu.forum.minimum_plays') && $plays < $posts + 1) { - return $prefix.'play_more'; - } + if ($forInitialHelpForum) { + if ($plays < 10 && $posts > 10) { + return $prefix.'too_many_help_posts'; + } + } else { + if ($plays < config('osu.forum.minimum_plays') && $plays < $posts + 1) { + return $prefix.'play_more'; + } - $this->ensureHasPlayed($user); + $this->ensureHasPlayed($user); + } } return 'ok'; @@ -1788,6 +1801,19 @@ public function checkIsSpecialScope(?User $user): string return 'unauthorized'; } + public function checkLegacyIrcKeyStore(?User $user): string + { + $this->ensureLoggedIn($user); + $this->ensureCleanRecord($user); + + // isBot checks user primary group + if (!$user->isGroup(app('groups')->byIdentifier('bot')) && $user->playCount() < 100) { + return 'play_more'; + } + + return 'ok'; + } + /** * @param User|null $user * @return string @@ -1808,6 +1834,14 @@ public function checkNewsPostUpdate(?User $user): string return 'unauthorized'; } + public function checkLegacyApiKeyStore(?User $user): string + { + $this->ensureLoggedIn($user); + $this->ensureCleanRecord($user); + + return 'ok'; + } + /** * @param User|null $user * @return string diff --git a/app/Libraries/Payments/PaymentProcessor.php b/app/Libraries/Payments/PaymentProcessor.php index 55dee0b5b14..c33b8fd92cc 100644 --- a/app/Libraries/Payments/PaymentProcessor.php +++ b/app/Libraries/Payments/PaymentProcessor.php @@ -354,22 +354,22 @@ protected function throwValidationFailed(Exception $exception) /** * implements ArrayAccess. */ - public function offsetExists($key) + public function offsetExists($key): bool { return array_has($this->params, $key); } - public function offsetGet($key) + public function offsetGet($key): mixed { return data_get($this->params, $key); } - public function offsetSet($key, $value) + public function offsetSet($key, $value): void { throw new \BadMethodCallException('not supported'); } - public function offsetUnset($key) + public function offsetUnset($key): void { throw new \BadMethodCallException('not supported'); } @@ -377,12 +377,12 @@ public function offsetUnset($key) /** * Validatable. */ - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'payments'; } - public function validationErrorsKeyBase() + public function validationErrorsKeyBase(): string { return 'model_validation/'; } diff --git a/app/Libraries/RouteScopesHelper.php b/app/Libraries/RouteScopesHelper.php index dc08dde2c24..ad26c2150a3 100644 --- a/app/Libraries/RouteScopesHelper.php +++ b/app/Libraries/RouteScopesHelper.php @@ -126,6 +126,6 @@ public function toCsv(string $filename) public function toJson(string $filename) { - file_put_contents($filename, str_replace('\/', '/', json_encode($this->toArray(), JSON_PRETTY_PRINT)."\n")); + file_put_contents($filename, json_encode($this->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n"); } } diff --git a/app/Libraries/Search/BeatmapsetSearch.php b/app/Libraries/Search/BeatmapsetSearch.php index 07a21d32e72..e4bf3056351 100644 --- a/app/Libraries/Search/BeatmapsetSearch.php +++ b/app/Libraries/Search/BeatmapsetSearch.php @@ -14,6 +14,7 @@ use App\Models\Beatmapset; use App\Models\Follow; use App\Models\Score; +use App\Models\User; class BeatmapsetSearch extends RecordSearch { @@ -85,8 +86,8 @@ public function getQuery() $this->addRecommendedFilter($nested); $this->addSimpleFilters($query, $nested); + $this->addCreatorFilter($query, $nested); $this->addTextFilter($query, 'artist', ['artist', 'artist_unicode']); - $this->addTextFilter($query, 'creator', ['creator']); $query->filter([ 'nested' => [ @@ -148,6 +149,23 @@ private function addBlockedUsersFilter($query) $query->mustNot(['terms' => ['user_id' => $this->params->blockedUserIds()]]); } + private function addCreatorFilter(BoolQuery $query, BoolQuery $nested): void + { + $value = $this->params->creator; + + if (!present($value)) { + return; + } + + $user = User::lookup($value); + + if ($user === null) { + $this->addTextFilter($query, 'creator', ['creator']); + } else { + $nested->filter(['term' => ['beatmaps.user_id' => $user->getKey()]]); + } + } + private function addExtraFilter($query) { foreach ($this->params->extra as $val) { diff --git a/app/Libraries/Search/BeatmapsetSearchRequestParams.php b/app/Libraries/Search/BeatmapsetSearchRequestParams.php index a85fd4a2817..c0f8bbfb62e 100644 --- a/app/Libraries/Search/BeatmapsetSearchRequestParams.php +++ b/app/Libraries/Search/BeatmapsetSearchRequestParams.php @@ -9,6 +9,7 @@ use App\Libraries\Elasticsearch\Sort; use App\Libraries\Elasticsearch\Utils\SearchAfterParam; use App\Models\Beatmap; +use App\Models\Beatmapset; use App\Models\Genre; use App\Models\Language; use App\Models\User; @@ -308,5 +309,11 @@ private function setSorts(): void // generic tie-breaker. $this->sorts[] = new Sort('id', $sort->order); + + foreach ($this->sorts as $sort) { + if ((Beatmapset::CASTS[$sort->field] ?? null) === 'datetime') { + $sort->extras['missing'] = 0; + } + } } } diff --git a/app/Libraries/Search/MultiSearch.php b/app/Libraries/Search/MultiSearch.php index 6a93d0b3f1b..ea8bd66901c 100644 --- a/app/Libraries/Search/MultiSearch.php +++ b/app/Libraries/Search/MultiSearch.php @@ -36,23 +36,24 @@ class MultiSearch private $options; private $query; private $searches; - private $request; + private array $request; public function __construct(Request $request, array $options = []) { - $this->query = trim($request['query']); + $this->request = $request->all(); + $this->query = trim(get_string($this->request['query'] ?? null) ?? ''); $this->options = $options; - $this->request = $request; } public function getMode() { - return presence($this->request['mode']) ?? 'all'; + return presence($this->request['mode'] ?? null) ?? 'all'; } public function hasQuery() { - return present($this->query); + return present($this->query) + || ($this->getMode() === 'forum_post' && present(get_string($this->request['username'] ?? null))); } public function searches() @@ -70,7 +71,7 @@ public function searches() $class = $settings['type']; $paramsClass = $settings['paramsType']; - $params = new $paramsClass($this->request->all(), $this->options['user']); + $params = new $paramsClass($this->request, $this->options['user']); $search = new $class($params); if ($search instanceof BeatmapsetSearch) { $search->source(false); diff --git a/app/Libraries/User/ForceReactivation.php b/app/Libraries/User/ForceReactivation.php index 2872bb16ee9..7e6783d9629 100644 --- a/app/Libraries/User/ForceReactivation.php +++ b/app/Libraries/User/ForceReactivation.php @@ -49,7 +49,7 @@ public function run() LegacySession::where('session_user_id', $userId)->delete(); UserClient::where('user_id', $userId)->update(['verified' => false]); - if (!$waitingActivation && present($this->user->user_email)) { + if (!$waitingActivation && is_valid_email_format($this->user->user_email)) { Mail::to($this->user)->send(new UserForceReactivation([ 'user' => $this->user, 'reason' => $this->reason, diff --git a/app/Libraries/User/UserSignatures.php b/app/Libraries/User/UserSignatures.php new file mode 100644 index 00000000000..e27ae840a31 --- /dev/null +++ b/app/Libraries/User/UserSignatures.php @@ -0,0 +1,30 @@ +. 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\Models\User; + +class UserSignatures +{ + private array $html = []; + + public function get(User $user): ?string + { + $userId = $user->getKey(); + + if (!array_key_exists($userId, $this->html)) { + $sig = $user->user_sig; + + $this->html[$userId] = present($sig) + ? bbcode($user->user_sig, $user->user_sig_bbcode_uid) + : null; + } + + return $this->html[$userId]; + } +} diff --git a/app/Libraries/UserVerification.php b/app/Libraries/UserVerification.php index db6ee578044..14d370f6666 100644 --- a/app/Libraries/UserVerification.php +++ b/app/Libraries/UserVerification.php @@ -96,7 +96,7 @@ public function issue() { $user = $this->user; - if (!present($user->user_email)) { + if (!is_valid_email_format($user->user_email)) { return; } diff --git a/app/Listeners/Fulfillments/PaymentSubscribers.php b/app/Listeners/Fulfillments/PaymentSubscribers.php index 26e1b0d7a77..a9e8c2c6cce 100644 --- a/app/Listeners/Fulfillments/PaymentSubscribers.php +++ b/app/Listeners/Fulfillments/PaymentSubscribers.php @@ -126,6 +126,9 @@ private static function sendPaymentCompletedMail(Order $order) return; } - Mail::to($order->user)->queue(new StorePaymentCompleted($order)); + $user = $order->user; + if (is_valid_email_format($user->user_email)) { + Mail::to($user)->queue(new StorePaymentCompleted($order)); + } } } diff --git a/app/Models/ApiKey.php b/app/Models/ApiKey.php index 7dd67be3871..5f661f094d0 100644 --- a/app/Models/ApiKey.php +++ b/app/Models/ApiKey.php @@ -3,8 +3,15 @@ // 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. +declare(strict_types=1); + namespace App\Models; +use App\Traits\Validatable; +use Cache; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + /** * @property string $api_key * @property string $app_name @@ -14,11 +21,77 @@ * @property int $key * @property int $miss_count * @property int $revoked + * @property-read User|null $user * @property int $user_id */ class ApiKey extends Model { + use Validatable; + + const MAX_FIELD_LENGTHS = [ + 'app_name' => 100, + 'app_url' => 512, + ]; + + public $casts = [ + 'revoked' => 'boolean', + ]; + public $timestamps = false; + protected $table = 'osu_apikeys'; protected $primaryKey = 'key'; - public $timestamps = false; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function scopeAvailable(Builder $query): Builder + { + return $query->where('revoked', false); + } + + public function isValid(): bool + { + $this->validationErrors()->reset(); + + $this->validateDbFieldLengths(); + + foreach (['app_name', 'api_key'] as $field) { + if (!present($this->$field)) { + $this->validationErrors()->add($field, 'required'); + } + } + + if (!filter_var($this->app_url ?? '', FILTER_VALIDATE_URL)) { + $this->validationErrors()->add('app_url', 'url'); + } + + if (!$this->exists && static::where(['user_id' => $this->user_id])->available()->exists()) { + $this->validationErrors()->add('base', '.exists'); + } + + return $this->validationErrors()->isEmpty(); + } + + public function save(array $options = []) + { + // Prevent multiple isValid check from running simultaneously + // as it checks for some sort of uniqueness without database + // constraint. + $lock = Cache::lock("legacy_api_key_store:{$this->user_id}", 600); + + try { + $lock->block(5); + + return $this->isValid() && parent::save($options); + } finally { + $lock->release(); + } + } + + public function validationErrorsTranslationPrefix(): string + { + return 'legacy_api_key'; + } } diff --git a/app/Models/BeatmapDiscussion.php b/app/Models/BeatmapDiscussion.php index bda609a6f1b..5c524f34a8d 100644 --- a/app/Models/BeatmapDiscussion.php +++ b/app/Models/BeatmapDiscussion.php @@ -497,7 +497,7 @@ public function isValid() return $this->validationErrors()->isEmpty(); } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'beatmapset_discussion'; } diff --git a/app/Models/BeatmapDiscussionPost.php b/app/Models/BeatmapDiscussionPost.php index 3839c5fa1aa..179eff3f923 100644 --- a/app/Models/BeatmapDiscussionPost.php +++ b/app/Models/BeatmapDiscussionPost.php @@ -226,15 +226,15 @@ public function isValid() $this->validationErrors()->add('message', 'required'); } - if (optional($this->beatmapDiscussion)->timestamp !== null && mb_strlen($this->message) > static::MESSAGE_LIMIT_TIMELINE) { - $this->validationErrors()->add('message', 'too_long', ['limit' => static::MESSAGE_LIMIT_TIMELINE]); + if ($this->beatmapDiscussion?->timestamp !== null) { + $this->validateDbFieldLength(static::MESSAGE_LIMIT_TIMELINE, 'message'); } } return $this->validationErrors()->isEmpty(); } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'beatmapset_discussion_post'; } diff --git a/app/Models/BeatmapPack.php b/app/Models/BeatmapPack.php index a75aa759716..b57b9849ebe 100644 --- a/app/Models/BeatmapPack.php +++ b/app/Models/BeatmapPack.php @@ -26,6 +26,8 @@ class BeatmapPack extends Model const TAG_MAPPINGS = [ 'standard' => 'S', 'featured' => 'F', + 'tournament' => 'P', // since 'T' is taken and 'P' goes for 'pool' + 'loved' => 'L', 'chart' => 'R', 'theme' => 'T', 'artist' => 'A', diff --git a/app/Models/Beatmapset.php b/app/Models/Beatmapset.php index 6eac7a88588..ae1047d3a0e 100644 --- a/app/Models/Beatmapset.php +++ b/app/Models/Beatmapset.php @@ -108,11 +108,7 @@ class Beatmapset extends Model implements AfterCommit, Commentable, Indexable, T { use Memoizes, SoftDeletes, Traits\CommentableDefaults, Traits\Es\BeatmapsetSearch, Traits\Reportable, Validatable; - protected $_storage = null; - protected $table = 'osu_beatmapsets'; - protected $primaryKey = 'beatmapset_id'; - - protected $casts = [ + const CASTS = [ 'active' => 'boolean', 'approved_date' => 'datetime', 'comment_locked' => 'boolean', @@ -131,7 +127,11 @@ class Beatmapset extends Model implements AfterCommit, Commentable, Indexable, T 'video' => 'boolean', ]; - public $timestamps = false; + const HYPEABLE_STATES = [-1, 0, 3]; + + const MAX_FIELD_LENGTHS = [ + 'tags' => 1000, + ]; const STATES = [ 'graveyard' => -2, @@ -142,7 +142,13 @@ class Beatmapset extends Model implements AfterCommit, Commentable, Indexable, T 'qualified' => 3, 'loved' => 4, ]; - const HYPEABLE_STATES = [-1, 0, 3]; + + public $timestamps = false; + + protected $_storage = null; + protected $casts = self::CASTS; + protected $primaryKey = 'beatmapset_id'; + protected $table = 'osu_beatmapsets'; public static function coverSizes() { @@ -463,15 +469,20 @@ public function fetchBeatmapsetArchive() return false; } - $contents = file_get_contents($url); - if ($contents === false) { - throw new BeatmapProcessorException('Error retrieving beatmap'); - } + $curl = curl_init($url); + curl_setopt_array($curl, [ + CURLOPT_FILE => $oszFile, + CURLOPT_TIMEOUT => 30, + ]); + curl_exec($curl); - $bytesWritten = fwrite($oszFile, $contents); + if (curl_errno($curl) > 0) { + throw new BeatmapProcessorException('Failed downloading osz: '.curl_error($curl)); + } - if ($bytesWritten === false) { - throw new BeatmapProcessorException('Failed writing stream'); + $statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); + if ($statusCode !== 200) { + throw new BeatmapProcessorException('Failed downloading osz: HTTP Error '.$statusCode); } return new BeatmapsetArchive(get_stream_filename($oszFile)); @@ -1481,7 +1492,7 @@ public function notificationCover() return $this->coverURL('card'); } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'beatmapset'; } @@ -1498,6 +1509,8 @@ public function isValid() $this->validationErrors()->add('genre_id', 'invalid'); } + $this->validateDbFieldLengths(); + return $this->validationErrors()->isEmpty(); } diff --git a/app/Models/BeatmapsetEvent.php b/app/Models/BeatmapsetEvent.php index d91adf7f0f3..b97ee3c71e0 100644 --- a/app/Models/BeatmapsetEvent.php +++ b/app/Models/BeatmapsetEvent.php @@ -53,6 +53,7 @@ class BeatmapsetEvent extends Model const LANGUAGE_EDIT = 'language_edit'; const NSFW_TOGGLE = 'nsfw_toggle'; const OFFSET_EDIT = 'offset_edit'; + const TAGS_EDIT = 'tags_edit'; const BEATMAP_OWNER_CHANGE = 'beatmap_owner_change'; diff --git a/app/Models/Chat/Channel.php b/app/Models/Chat/Channel.php index 4feddd33fc3..8da0f466849 100644 --- a/app/Models/Chat/Channel.php +++ b/app/Models/Chat/Channel.php @@ -397,14 +397,7 @@ public function isValid() $this->validationErrors()->add('name', 'required'); } - foreach (static::MAX_FIELD_LENGTHS as $field => $limit) { - if ($this->isDirty($field)) { - $val = $this->$field; - if ($val !== null && mb_strlen($val) > $limit) { - $this->validationErrors()->add($field, 'too_long', ['limit' => $limit]); - } - } - } + $this->validateDbFieldLengths(); return $this->validationErrors()->isEmpty(); } @@ -617,7 +610,7 @@ public function unhide(?User $user = null) } } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'chat.channel'; } diff --git a/app/Models/Chat/Message.php b/app/Models/Chat/Message.php index 7b114dc4fc8..ad7f117f10f 100644 --- a/app/Models/Chat/Message.php +++ b/app/Models/Chat/Message.php @@ -5,6 +5,8 @@ namespace App\Models\Chat; +use App\Models\Traits\Reportable; +use App\Models\Traits\ReportableInterface; use App\Models\User; /** @@ -17,8 +19,10 @@ * @property \Carbon\Carbon $timestamp * @property int $user_id */ -class Message extends Model +class Message extends Model implements ReportableInterface { + use Reportable; + public ?string $uuid = null; protected $primaryKey = 'message_id'; @@ -57,7 +61,41 @@ public function getAttribute($key) 'timestamp_json' => $this->getJsonTimeFast($key), 'channel', + 'reportedIn', 'sender' => $this->getRelationValue($key), }; } + + public function reportableAdditionalInfo(): ?string + { + $history = static + ::where('message_id', '<=', $this->getKey()) + ->whereHas('channel', fn ($ch) => $ch->where('type', '<>', Channel::TYPES['pm'])) + ->where('user_id', $this->user_id) + ->orderBy('timestamp', 'DESC') + ->with('channel') + ->limit(5) + ->get() + ->map(fn ($m) => "**{$m->timestamp_json} {$m->channel->name}:**\n{$m->content}\n") + ->reverse() + ->join("\n"); + + $channel = $this->channel; + $header = 'Reported in: '.($channel->isPM() ? 'pm' : '**'.$channel->name.'** ('.strtolower($channel->type).')'); + + return "{$header}\n\n{$history}"; + } + + public function trashed(): bool + { + return false; + } + + protected function newReportableExtraParams(): array + { + return [ + 'reason' => 'Spam', + 'user_id' => $this->user_id, + ]; + } } diff --git a/app/Models/Comment.php b/app/Models/Comment.php index 4419d8313d9..124fa6d86de 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -46,9 +46,11 @@ class Comment extends Model implements Traits\ReportableInterface MorphMap::MAP[NewsPost::class], ]; - // FIXME: decide on good number. - // some people seem to put song lyrics in comment which inflated the size. - const MESSAGE_LIMIT = 10000; + const MAX_FIELD_LENGTHS = [ + // FIXME: decide on good number. + // some people seem to put song lyrics in comment which inflated the size. + 'message' => 10000, + ]; const SORTS = [ 'new' => [ @@ -119,7 +121,7 @@ public function replies() public function setMessageAttribute($value) { - return $this->attributes['message'] = unzalgo($value); + return $this->attributes['message'] = trim(unzalgo($value)); } public function votes() @@ -192,19 +194,15 @@ public function isValid() { $this->validationErrors()->reset(); - $messageLength = mb_strlen(trim($this->message)); - if ($this->isDirty('pinned') && $this->pinned && $this->parent_id !== null) { $this->validationErrors()->add('pinned', '.top_only'); } - if ($messageLength === 0) { + if (!present($this->message)) { $this->validationErrors()->add('message', 'required'); } - if ($messageLength > static::MESSAGE_LIMIT) { - $this->validationErrors()->add('message', 'too_long', ['limit' => static::MESSAGE_LIMIT]); - } + $this->validateDbFieldLengths(); if ($this->isDirty('parent_id') && $this->parent_id !== null) { if ($this->parent === null) { @@ -232,7 +230,7 @@ public function url() return route('comments.show', ['comment' => $this->getKey()]); } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'comment'; } diff --git a/app/Models/Contest.php b/app/Models/Contest.php index ea559f4ce03..aa6c8797c2a 100644 --- a/app/Models/Contest.php +++ b/app/Models/Contest.php @@ -289,21 +289,25 @@ public function defaultJson($user = null) $includes[] = 'artMeta'; } - if ($this->show_votes) { + $showVotes = $this->show_votes; + if ($showVotes) { $includes[] = 'results'; } + if ($this->showEntryUser()) { + $includes[] = 'user'; + } $contestJson = json_item( $this, new ContestTransformer(), - $this->show_votes ? ['users_voted_count'] : null, + $showVotes ? ['users_voted_count'] : null, ); if ($this->isVotingStarted()) { $contestJson['entries'] = json_collection($this->entriesByType($user), new ContestEntryTransformer(), $includes); } if (!empty($contestJson['entries'])) { - if (!$this->show_votes) { + if (!$showVotes) { if ($this->unmasked) { // For unmasked contests, we sort alphabetically. usort($contestJson['entries'], function ($a, $b) { @@ -386,4 +390,9 @@ public function getForcedHeight() { return $this->getExtraOptions()['forced_height'] ?? null; } + + public function showEntryUser(): bool + { + return $this->show_votes || ($this->getExtraOptions()['show_entry_user'] ?? false); + } } diff --git a/app/Models/Event.php b/app/Models/Event.php index b03b6e5c197..00950cfa230 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -247,7 +247,7 @@ public function parseFailure($reason) 'Failed parsing event', null, (new Scope()) - ->setExtra('reason', $reason) + ->setTag('reason', $reason) ->setExtra('event', $this->toArray()) ); diff --git a/app/Models/Follow.php b/app/Models/Follow.php index 95b8f41038a..b93e7877a31 100644 --- a/app/Models/Follow.php +++ b/app/Models/Follow.php @@ -66,7 +66,7 @@ public function setUserAttribute($value) $this->user()->associate($value); } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'follow'; } diff --git a/app/Models/Forum/FeatureVote.php b/app/Models/Forum/FeatureVote.php index 8171cbbf27f..95c6d6f0957 100644 --- a/app/Models/Forum/FeatureVote.php +++ b/app/Models/Forum/FeatureVote.php @@ -123,7 +123,7 @@ public static function createNew($params) return $star; } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'forum.feature_vote'; } diff --git a/app/Models/Forum/Forum.php b/app/Models/Forum/Forum.php index 5681815d9f4..cf61fb0a7a4 100644 --- a/app/Models/Forum/Forum.php +++ b/app/Models/Forum/Forum.php @@ -7,6 +7,7 @@ use App\Models\User; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; /** * @property bool $allow_topic_covers @@ -184,6 +185,11 @@ public function scopeDisplayList($query) $query->orderBy('left_id'); } + public function scopeSearchable(Builder $query): Builder + { + return $query->where('enable_indexing', true); + } + public function setForumParentsAttribute($value) { $this->attributes['forum_parents'] = $value === null || count($value) === 0 ? '' : serialize($value); diff --git a/app/Models/Forum/PollVote.php b/app/Models/Forum/PollVote.php index 5935676e48a..5cca1695a46 100644 --- a/app/Models/Forum/PollVote.php +++ b/app/Models/Forum/PollVote.php @@ -35,7 +35,7 @@ public function user() return $this->belongsTo(User::class, 'vote_user_id'); } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'forum.poll_vote'; } diff --git a/app/Models/Forum/Post.php b/app/Models/Forum/Post.php index a6803dc858d..b3496367c28 100644 --- a/app/Models/Forum/Post.php +++ b/app/Models/Forum/Post.php @@ -355,9 +355,7 @@ public function isValid() } } - if ($this->isDirty('post_text') && mb_strlen($this->body_raw) > config('osu.forum.max_post_length')) { - $this->validationErrors()->add('post_text', 'too_long', ['limit' => config('osu.forum.max_post_length')]); - } + $this->validateDbFieldLength(config('osu.forum.max_post_length'), 'post_text', 'body_raw'); if (!$this->skipBeatmapPostRestrictions) { // don't forget to sync with views.forum.topics._posts @@ -379,10 +377,10 @@ public function save(array $options = []) // record edit history if ($this->exists && $this->isDirty('post_text')) { - $this->fill([ - 'post_edit_time' => Carbon::now(), - 'post_edit_count' => DB::raw('post_edit_count + 1'), - ]); + $this->post_edit_time = Carbon::now(); + if ($this->post_edit_count < 64000) { + $this->post_edit_count = DB::raw('post_edit_count + 1'); + } } return parent::save($options); @@ -397,7 +395,7 @@ public function isBeatmapsetPost() } } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'forum.post'; } diff --git a/app/Models/Forum/Topic.php b/app/Models/Forum/Topic.php index a59a39347ce..9aa811f627b 100644 --- a/app/Models/Forum/Topic.php +++ b/app/Models/Forum/Topic.php @@ -158,7 +158,7 @@ public static function typeInt($typeIntOrStr) } } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'forum.topic'; } @@ -333,15 +333,7 @@ public function isValid() $this->validationErrors()->add('topic_title', 'required'); } - foreach (static::MAX_FIELD_LENGTHS as $field => $limit) { - if ($this->isDirty($field)) { - $val = $this->$field; - - if (mb_strlen($val) > $limit) { - $this->validationErrors()->add($field, 'too_long', ['limit' => $limit]); - } - } - } + $this->validateDbFieldLengths(); return $this->validationErrors()->isEmpty(); } diff --git a/app/Models/Forum/TopicPoll.php b/app/Models/Forum/TopicPoll.php index e5c6adac595..3538b75e6a5 100644 --- a/app/Models/Forum/TopicPoll.php +++ b/app/Models/Forum/TopicPoll.php @@ -15,9 +15,21 @@ class TopicPoll private $topic; private $validated = false; - private $params; + private $params = [ + 'hide_results' => false, + 'length_days' => 0, + 'max_options' => 1, + 'options' => [], + 'title' => null, + 'vote_change' => false, + ]; private $votedBy = []; + public function __get(string $field) + { + return $this->params[$field]; + } + public function canEdit() { return $this->topic->topic_time > Carbon::now()->subHours(config('osu.forum.poll_edit_hours')); @@ -30,12 +42,10 @@ public function exists() public function fill($params) { - $this->params = array_merge([ - 'hide_results' => false, - 'length_days' => 0, - 'max_options' => 1, - 'vote_change' => false, - ], $params); + $this->params = [ + ...$this->params, + ...$params, + ]; $this->validated = false; return $this; @@ -56,13 +66,11 @@ public function isValid($revalidate = false) $this->validated = true; $this->validationErrors()->reset(); - if (!isset($this->params['title']) || !present($this->params['title'])) { + if (!present($this->params['title'])) { $this->validationErrors()->add('title', 'required'); } - if (mb_strlen($this->params['title']) > 255) { - $this->validationErrors()->add('title', 'too_long', ['limit' => 255]); - } + $this->validateFieldLength(255, 'title'); if (count($this->params['options']) > count(array_unique($this->params['options']))) { $this->validationErrors()->add('options', '.duplicate_options'); @@ -156,7 +164,7 @@ public function totalVoteCount(): int : 0; } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'forum.topic_poll'; } diff --git a/app/Models/Forum/TopicVote.php b/app/Models/Forum/TopicVote.php index 3bfff0b1d16..958ab26a22f 100644 --- a/app/Models/Forum/TopicVote.php +++ b/app/Models/Forum/TopicVote.php @@ -82,7 +82,7 @@ public function save() }); } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'forum.topic_vote'; } diff --git a/app/Models/LegacyIrcKey.php b/app/Models/LegacyIrcKey.php new file mode 100644 index 00000000000..59b20fc8d9a --- /dev/null +++ b/app/Models/LegacyIrcKey.php @@ -0,0 +1,31 @@ +. 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\Models; + +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +/** + * @property int $user_id + * @property User $user + * @property string $token + * @property \Carbon\Carbon $timestamp + */ +class LegacyIrcKey extends Model +{ + public $incrementing = false; + public $timestamps = false; + + protected $casts = ['timestamp' => 'datetime']; + protected $primaryKey = 'user_id'; + protected $table = 'osu_user_ircauth'; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/Model.php b/app/Models/Model.php index ee8e05633e7..0b50170c7b5 100644 --- a/app/Models/Model.php +++ b/app/Models/Model.php @@ -11,6 +11,7 @@ use App\Libraries\Transactions\AfterRollback; use App\Libraries\TransactionStateManager; use App\Scopes\MacroableModelScope; +use App\Traits\Validatable; use Carbon\Carbon; use Exception; use Illuminate\Database\ClassMorphViolationException; @@ -19,7 +20,9 @@ abstract class Model extends BaseModel { - use HasFactory; + use HasFactory, Validatable; + + const MAX_FIELD_LENGTHS = []; protected $connection = 'mysql'; protected $guarded = []; @@ -191,9 +194,8 @@ public function saveOrExplode($options = []) $result = $this->save($options); if ($result === false) { - $message = method_exists($this, 'validationErrors') ? - $this->validationErrors()->toSentence() : - 'failed saving model'; + $errors = $this->validationErrors(); + $message = $errors->isEmpty() ? 'failed saving model' : $errors->toSentence(); throw new ModelNotSavedException($message); } @@ -271,6 +273,25 @@ protected function setKeysForSelectQuery($query) return $this->setKeysForSaveQuery($query); } + protected function validateDbFieldLength(int $limit, string $dbField, ?string $checkField = null): void + { + if ($this->isDirty($dbField)) { + $this->validateFieldLength($limit, $dbField, $checkField); + } + } + + protected function validateDbFieldLengths(): void + { + foreach (static::MAX_FIELD_LENGTHS as $field => $limit) { + $this->validateDbFieldLength($limit, $field, $field); + } + } + + protected function validationErrorsTranslationPrefix(): string + { + return ''; + } + private function enlistCallbacks($model, $connection) { $transaction = resolve(TransactionStateManager::class)->current($connection); diff --git a/app/Models/OAuth/Client.php b/app/Models/OAuth/Client.php index 3e0a16fa015..de0060a1b7f 100644 --- a/app/Models/OAuth/Client.php +++ b/app/Models/OAuth/Client.php @@ -8,10 +8,27 @@ use App\Exceptions\InvariantException; use App\Models\User; use App\Traits\Validatable; +use Carbon\Carbon; use DB; use Laravel\Passport\Client as PassportClient; use Laravel\Passport\RefreshToken; +/** + * @property Carbon|null $created_at + * @property int $id + * @property string $name + * @property bool $password_client + * @property bool $personal_access_client + * @property string $provider + * @property string $redirect + * @property-read Collection refreshTokens + * @property bool $revoked + * @property string $secret + * @property-read Collection tokens + * @property Carbon|null $updated_at + * @property-read User|null $user + * @property int|null $user_id + */ class Client extends PassportClient { use Validatable; @@ -56,6 +73,11 @@ public function refreshTokens() ); } + public function setRedirectAttribute(string $value) + { + $this->attributes['redirect'] = implode(',', array_unique(preg_split('/[\s,]+/', $value, 0, PREG_SPLIT_NO_EMPTY))); + } + public function isValid() { $this->validationErrors()->reset(); @@ -71,10 +93,13 @@ public function isValid() $this->validationErrors()->add('name', 'required'); } - $redirect = trim($this->redirect); - // TODO: this url validation is not very good. - if (present($redirect) && !filter_var($redirect, FILTER_VALIDATE_URL)) { - $this->validationErrors()->add('redirect', '.url'); + $redirects = explode(',', $this->redirect ?? ''); + foreach ($redirects as $redirect) { + // TODO: this url validation is not very good. + if (present($redirect) && !filter_var($redirect, FILTER_VALIDATE_URL)) { + $this->validationErrors()->add('redirect', '.url'); + break; + } } return $this->validationErrors()->isEmpty(); @@ -144,7 +169,7 @@ public function user() return $this->belongsTo(User::class, 'user_id'); } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'oauth.client'; } diff --git a/app/Models/Store/OrderItem.php b/app/Models/Store/OrderItem.php index e2118c261cd..764e1aa0177 100644 --- a/app/Models/Store/OrderItem.php +++ b/app/Models/Store/OrderItem.php @@ -157,7 +157,7 @@ public function reserveProduct() } } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'store.order_item'; } diff --git a/app/Models/Tournament.php b/app/Models/Tournament.php index 7eefde123ed..4b97e30d01a 100644 --- a/app/Models/Tournament.php +++ b/app/Models/Tournament.php @@ -5,6 +5,7 @@ namespace App\Models; +use App\Exceptions\InvariantException; use App\Models\Store\Product; use Carbon\Carbon; @@ -122,7 +123,7 @@ public function unregister($user) { //sanity check: we shouldn't be touching users once the tournament is already in action. if ($this->isTournamentRunning()) { - return; + throw new InvariantException('tournament is already running'); } $this->registrations()->where('user_id', '=', $user->user_id)->delete(); @@ -136,7 +137,7 @@ public function register($user) //sanity check: we shouldn't be touching users once the tournament is already in action. if ($this->isTournamentRunning()) { - return; + throw new InvariantException('tournament is already running'); } $reg = new TournamentRegistration(); diff --git a/app/Models/Traits/Reportable.php b/app/Models/Traits/Reportable.php index bbb1f864ec0..6c440018f52 100644 --- a/app/Models/Traits/Reportable.php +++ b/app/Models/Traits/Reportable.php @@ -17,6 +17,11 @@ trait Reportable { abstract protected function newReportableExtraParams(): array; + public function reportableAdditionalInfo(): ?string + { + return null; + } + public function reportedIn() { return $this->morphMany(UserReport::class, 'reportable'); @@ -36,7 +41,7 @@ public function reportBy(User $reporter, array $params = []): ?UserReport $attributes['comments'] = $params['comments'] ?? ''; $attributes['reporter_id'] = $reporter->getKey(); - if (array_key_exists('reason', $params)) { + if (present($params['reason'] ?? null)) { $attributes['reason'] = $params['reason']; } diff --git a/app/Models/Traits/ReportableInterface.php b/app/Models/Traits/ReportableInterface.php index 173129d4b5c..1595a405f69 100644 --- a/app/Models/Traits/ReportableInterface.php +++ b/app/Models/Traits/ReportableInterface.php @@ -10,6 +10,7 @@ interface ReportableInterface { + public function reportableAdditionalInfo(): ?string; public function reportBy(User $reporter, array $params): ?UserReport; public function trashed(); } diff --git a/app/Models/User.php b/app/Models/User.php index b1125765704..7a0c80134df 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -25,8 +25,6 @@ use Carbon\Carbon; use DB; use Ds\Set; -use Egulias\EmailValidator\EmailValidator; -use Egulias\EmailValidator\Validation\NoRFCWarningsValidation; use Exception; use Hash; use Illuminate\Auth\Authenticatable; @@ -35,6 +33,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\QueryException; use Laravel\Passport\HasApiTokens; use League\OAuth2\Server\Exception\OAuthServerException; @@ -42,7 +41,7 @@ /** * @property-read Collection $accountHistories - * @property-read ApiKey|null $apiKey + * @property-read Collection $apiKeys * @property-read Collection $badges * @property-read Collection $beatmapDiscussionVotes * @property-read Collection $beatmapDiscussions @@ -730,12 +729,12 @@ public function cover() public function setUserTwitterAttribute($value) { - $this->attributes['user_twitter'] = ltrim($value, '@'); + $this->attributes['user_twitter'] = trim(ltrim($value, '@')); } public function setUserDiscordAttribute($value) { - $this->attributes['user_jabber'] = $value; + $this->attributes['user_jabber'] = trim($value); } public function setUserColourAttribute($value) @@ -855,7 +854,7 @@ public function getAttribute($key) // relations 'accountHistories', - 'apiKey', + 'apiKeys', 'badges', 'beatmapDiscussionVotes', 'beatmapDiscussions', @@ -878,6 +877,7 @@ public function getAttribute($key) 'friends', 'githubUsers', 'givenKudosu', + 'legacyIrcKey', 'monthlyPlaycounts', 'notificationOptions', 'oauthClients', @@ -1162,6 +1162,11 @@ public function githubUsers() return $this->hasMany(GithubUser::class); } + public function legacyIrcKey(): HasOne + { + return $this->hasOne(LegacyIrcKey::class); + } + public function monthlyPlaycounts() { return $this->hasMany(UserMonthlyPlaycount::class); @@ -1253,9 +1258,9 @@ public function beatmapPlaycounts() return $this->hasMany(BeatmapPlaycount::class); } - public function apiKey() + public function apiKeys() { - return $this->hasOne(ApiKey::class); + return $this->hasMany(ApiKey::class); } public function profileBanners() @@ -1766,7 +1771,10 @@ public function authHash(): string public function resetSessions(): void { SessionStore::destroy($this->getKey()); - $this->tokens()->with('refreshToken')->get()->each->revokeRecursive(); + $this + ->tokens() + ->with('refreshToken') + ->chunkById(1000, fn ($tokens) => $tokens->each->revokeRecursive()); } public function title(): ?string @@ -1990,6 +1998,9 @@ public function setCurrentPasswordAttribute($value) $this->currentPassword = $value; } + /** + * Enables email presence and confirmation field equality check. + */ public function validateEmailConfirmation() { $this->validateEmailConfirmation = true; @@ -2236,6 +2247,10 @@ public function isValid() } if ($this->validateEmailConfirmation) { + if ($this->user_email === null) { + $this->validationErrors()->add('user_email', '.required'); + } + if ($this->user_email !== $this->emailConfirmation) { $this->validationErrors()->add('user_email_confirmation', '.wrong_email_confirmation'); } @@ -2261,9 +2276,9 @@ public function isValid() // user_discord is an accessor for user_jabber if ($this->isDirty('user_jabber') && present($this->user_discord)) { // This is a basic check and not 100% compliant to Discord's spec, only validates that input: - // - is a 2-32 char username (excluding chars @#:) - // - ends with a # and 4-digit discriminator - if (!preg_match('/^[^@#:]{2,32}#\d{4}$/i', $this->user_discord)) { + // - is a 2-32 char username (excluding chars @#:) and 4-digit discriminator for old-style usernames; or, + // - 2-32 char alphanumeric + period username for new-style usernames; consecutive periods are not validated. + if (!preg_match('/^([^@#:]{2,32}#\d{4}|[\w.]{2,32})$/i', $this->user_discord)) { $this->validationErrors()->add('user_discord', '.invalid_discord'); } } @@ -2275,14 +2290,7 @@ public function isValid() } } - foreach (self::MAX_FIELD_LENGTHS as $field => $limit) { - if ($this->isDirty($field)) { - $val = $this->$field; - if ($val && mb_strlen($val) > $limit) { - $this->validationErrors()->add($field, '.too_long', ['limit' => $limit]); - } - } - } + $this->validateDbFieldLengths(); if ($this->isDirty('group_id') && app('groups')->byId($this->group_id) === null) { $this->validationErrors()->add('group_id', 'invalid'); @@ -2293,8 +2301,7 @@ public function isValid() public function isValidEmail() { - $emailValidator = new EmailValidator(); - if (!$emailValidator->isValid($this->user_email, new NoRFCWarningsValidation())) { + if (!is_valid_email_format($this->user_email)) { $this->validationErrors()->add('user_email', '.invalid_email'); // no point validating further if address isn't valid. @@ -2329,7 +2336,7 @@ public function url() return route('users.show', ['user' => $this->getKey()]); } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'user'; } diff --git a/app/Models/UserNotificationOption.php b/app/Models/UserNotificationOption.php index 6924d70ae0e..08e02d2f7a3 100644 --- a/app/Models/UserNotificationOption.php +++ b/app/Models/UserNotificationOption.php @@ -129,7 +129,7 @@ public function save(array $options = []) return $this->isValid() && parent::save($options); } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'user_notification_option'; } diff --git a/app/Models/UserReport.php b/app/Models/UserReport.php index bb31e588cab..c283bcb98bf 100644 --- a/app/Models/UserReport.php +++ b/app/Models/UserReport.php @@ -7,6 +7,7 @@ use App\Exceptions\ValidationException; use App\Libraries\MorphMap; +use App\Models\Chat\Message; use App\Models\Score\Best; use App\Models\Score\Best\Model as BestModel; use App\Traits\Validatable; @@ -33,7 +34,9 @@ class UserReport extends Model use RoutesNotifications, Validatable; const BEATMAPSET_TYPE_REASONS = ['UnwantedContent', 'Other']; - const MAX_LENGTH = 2000; + const MAX_FIELD_LENGTHS = [ + 'comments' => 2000, + ]; const POST_TYPE_REASONS = ['Insults', 'Spam', 'UnwantedContent', 'Nonsense', 'Other']; const SCORE_TYPE_REASONS = ['Cheating', 'MultipleAccounts', 'Other']; @@ -44,6 +47,7 @@ class UserReport extends Model MorphMap::MAP[Best\Mania::class] => self::SCORE_TYPE_REASONS, MorphMap::MAP[Best\Osu::class] => self::SCORE_TYPE_REASONS, MorphMap::MAP[Best\Taiko::class] => self::SCORE_TYPE_REASONS, + MorphMap::MAP[Chat\Message::class] => self::POST_TYPE_REASONS, MorphMap::MAP[Comment::class] => self::POST_TYPE_REASONS, MorphMap::MAP[Forum\Post::class] => self::POST_TYPE_REASONS, MorphMap::MAP[Solo\Score::class] => self::SCORE_TYPE_REASONS, @@ -80,7 +84,17 @@ public function routeNotificationForSlack(?Notification $_notification): ?string ) { return config('osu.user_report_notification.endpoint_cheating'); } else { - return config('osu.user_report_notification.endpoint_moderation'); + $type = match ($reportableModel::class) { + BeatmapDiscussionPost::class => 'beatmapset_discussion', + Beatmapset::class => 'beatmapset', + Chat\Message::class => 'chat', + Comment::class => 'comment', + Forum\Post::class => 'forum', + User::class => 'user', + }; + + return config("osu.user_report_notification.endpoint.{$type}") + ?? config('osu.user_report_notification.endpoint_moderation'); } } @@ -93,7 +107,7 @@ public function isValid() { $this->validationErrors()->reset(); - if (!present(trim($this->comments))) { + if (!present(trim($this->comments)) && (!($this->reportable instanceof Chat\Message) || $this->reason === 'Other')) { $this->validationErrors()->add('comments', 'required'); } @@ -104,15 +118,18 @@ public function isValid() ); } - $allowedReasons = static::ALLOWED_REASONS[$this->reportable_type] ?? null; - if ($allowedReasons !== null) { - if (!in_array($this->reason, $allowedReasons, true)) { - $this->validationErrors()->add( - 'reason', - '.reason_not_valid', - ['reason' => $this->reason] - ); - } + $allowedReasons = static::ALLOWED_REASONS[$this->reportable_type] ?? [ + ...static::BEATMAPSET_TYPE_REASONS, + ...static::POST_TYPE_REASONS, + ...static::SCORE_TYPE_REASONS, + ]; + + if (!in_array($this->reason, $allowedReasons, true)) { + $this->validationErrors()->add( + 'reason', + '.reason_not_valid', + ['reason' => $this->reason] + ); } if ($this->reportable instanceof Beatmapset && $this->reportable->isScoreable()) { @@ -122,14 +139,19 @@ public function isValid() ); } - if (mb_strlen($this->comments) > static::MAX_LENGTH) { + if ( + $this->reportable instanceof Message + && $this->reportable->channel->isHideable() + && !$this->reportable->channel->hasUser($this->reporter) + ) { $this->validationErrors()->add( - 'comments', - 'too_long', - ['limit' => static::MAX_LENGTH] + 'reportable', + '.not_in_channel' ); } + $this->validateDbFieldLengths(); + return $this->validationErrors()->isEmpty(); } @@ -142,7 +164,7 @@ public function save(array $options = []) return parent::save(); } - public function validationErrorsTranslationPrefix() + public function validationErrorsTranslationPrefix(): string { return 'user_report'; } diff --git a/app/Models/UserStatistics/Model.php b/app/Models/UserStatistics/Model.php index 4bce769d439..6e40322f3b4 100644 --- a/app/Models/UserStatistics/Model.php +++ b/app/Models/UserStatistics/Model.php @@ -41,16 +41,6 @@ public function user() return $this->belongsTo(User::class, 'user_id'); } - public function setCreatedAt($value) - { - // Do nothing. - } - - public function getCreatedAtColumn() - { - // Do nothing. - } - public function getCountryAcronymAttribute($value) { return presence($value); @@ -84,7 +74,10 @@ public function totalHits() public static function calculateRecommendedStarDifficulty(?self $stats) { if ($stats !== null && $stats->rank_score > 0) { - return pow($stats->rank_score, 0.4) * 0.195; + return match ($stats->getMode()) { + 'taiko' => pow($stats->rank_score, 0.35) * 0.27, + default => pow($stats->rank_score, 0.4) * 0.195, + }; } return 1.0; diff --git a/app/Notifications/UserReportNotification.php b/app/Notifications/UserReportNotification.php index cc532d8b587..d213212b8a4 100644 --- a/app/Notifications/UserReportNotification.php +++ b/app/Notifications/UserReportNotification.php @@ -48,15 +48,22 @@ public function toSlack(UserReport $notifiable): SlackMessage ? "<{$reportableUrl}|{$notifiable->reportable_type} {$notifiable->reportable_id}>" : "{$notifiable->reportable_type} {$notifiable->reportable_id}"; + $fields = [ + 'Reporter' => $this->discordMarkdownLink($this->reporter->url(), $this->reporter->username), + 'Reported' => $reportedText, + 'User' => $this->discordMarkdownLink($userUrl, $user), + 'Reason' => $notifiable->reason, + ]; + + $additionalInfo = $reportable->reportableAdditionalInfo(); + if ($additionalInfo !== null) { + $fields['Additional Info'] = $additionalInfo; + } + $attachment ->color('warning') ->content($notifiable->comments) - ->fields([ - 'Reporter' => "<{$this->reporter->url()}|{$this->reporter->username}>", - 'Reported' => $reportedText, - 'User' => "<{$userUrl}|{$user}>", - 'Reason' => $notifiable->reason, - ]); + ->fields($fields); }); } @@ -64,4 +71,13 @@ public function via($notifiable) { return ['slack']; } + + private function discordMarkdownLink(string $url, string $text): string + { + // I couldn't find any way to escape them so this seems to be the next best thing. + // The alternative characters were taken from https://github.com/python-discord/sir-lancebot/pull/820 + $text = strtr($text, ['[' => '⦋', ']' => '⦌']); + + return "[{$text}]({$url})"; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b3c57b0ea91..8b9a59b0335 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -143,9 +143,10 @@ public function register() fn ($app) => $app->bound(Server::class) ? new SwooleTaskDispatcher() : new SequentialTaskDispatcher() ); - if ($this->app->environment('testing')) { + $env = $this->app->environment(); + if ($env === 'testing' || $env === 'dusk.local') { // This is needed for testing with Dusk. - $this->app->register('\App\Providers\AdditionalDuskServiceProvider'); + $this->app->register(AdditionalDuskServiceProvider::class); // This is for testing after commit broadcastable events. $this->app->singleton(BroadcastsPendingForTests::class, fn () => new BroadcastsPendingForTests()); diff --git a/app/Traits/Validatable.php b/app/Traits/Validatable.php index 5b85c0f72f5..6e6594a282d 100644 --- a/app/Traits/Validatable.php +++ b/app/Traits/Validatable.php @@ -11,9 +11,9 @@ trait Validatable { protected $_validationErrors = null; - abstract public function validationErrorsTranslationPrefix(); + abstract public function validationErrorsTranslationPrefix(): string; - public function validationErrorsKeyBase() + public function validationErrorsKeyBase(): string { return 'model_validation.'; } @@ -29,4 +29,13 @@ public function validationErrors(): ValidationErrors return $this->_validationErrors; } + + protected function validateFieldLength(int $limit, string $field, ?string $checkField = null): void + { + $checkField ??= $field; + $val = $this->$checkField; + if ($val !== null && mb_strlen($val) > $limit) { + $this->validationErrors()->add($field, 'too_long', ['limit' => $limit]); + } + } } diff --git a/app/Transformers/BeatmapsetCompactTransformer.php b/app/Transformers/BeatmapsetCompactTransformer.php index d396b79da86..7e3cc5c1a12 100644 --- a/app/Transformers/BeatmapsetCompactTransformer.php +++ b/app/Transformers/BeatmapsetCompactTransformer.php @@ -127,10 +127,6 @@ public function includeCurrentUserAttributes(Beatmapset $beatmapset) { $currentUser = Auth::user(); - if ($currentUser === null) { - return; - } - $hypeValidation = $beatmapset->validateHypeBy($currentUser); return $this->primitive([ @@ -138,14 +134,15 @@ public function includeCurrentUserAttributes(Beatmapset $beatmapset) 'can_delete' => !$beatmapset->isScoreable() && priv_check('BeatmapsetDelete', $beatmapset)->can(), 'can_edit_metadata' => priv_check('BeatmapsetMetadataEdit', $beatmapset)->can(), 'can_edit_offset' => priv_check('BeatmapsetOffsetEdit')->can(), + 'can_edit_tags' => priv_check('BeatmapsetTagsEdit')->can(), 'can_hype' => $hypeValidation['result'], 'can_hype_reason' => $hypeValidation['message'] ?? null, 'can_love' => $beatmapset->isLoveable() && priv_check('BeatmapsetLove')->can(), 'can_remove_from_loved' => $beatmapset->isLoved() && priv_check('BeatmapsetRemoveFromLoved')->can(), 'is_watching' => BeatmapsetWatch::check($beatmapset, Auth::user()), - 'new_hype_time' => json_time($currentUser->newHypeTime()), - 'nomination_modes' => $currentUser->nominationModes(), - 'remaining_hype' => $currentUser->remainingHype(), + 'new_hype_time' => json_time($currentUser?->newHypeTime()), + 'nomination_modes' => $currentUser?->nominationModes(), + 'remaining_hype' => $currentUser?->remainingHype() ?? 0, ]); } diff --git a/app/Transformers/BuildTransformer.php b/app/Transformers/BuildTransformer.php index 3b986572e9b..41042f76d7d 100644 --- a/app/Transformers/BuildTransformer.php +++ b/app/Transformers/BuildTransformer.php @@ -23,11 +23,12 @@ class BuildTransformer extends TransformerAbstract public function transform(Build $build) { return [ - 'id' => $build->getKey(), - 'version' => $build->version, + 'created_at' => json_time($build->date), 'display_version' => $build->displayVersion(), + 'id' => $build->getKey(), 'users' => $build->users ?? 0, - 'created_at' => json_time($build->date), + 'version' => $build->version, + 'youtube_id' => $build->youtube_id, ]; } diff --git a/app/Transformers/ContestEntryTransformer.php b/app/Transformers/ContestEntryTransformer.php index 2ba45f76b93..aeb25e51ce4 100644 --- a/app/Transformers/ContestEntryTransformer.php +++ b/app/Transformers/ContestEntryTransformer.php @@ -12,8 +12,9 @@ class ContestEntryTransformer extends TransformerAbstract { protected array $availableIncludes = [ - 'results', 'artMeta', + 'results', + 'user', ]; public function transform(ContestEntry $entry) @@ -35,12 +36,18 @@ public function includeResults(ContestEntry $entry) { return $this->primitive([ 'actual_name' => $entry->name, - 'user_id' => $entry->user_id, - 'username' => ($entry->user ?? (new DeletedUser()))->username, 'votes' => (int) $entry->votes_count, ]); } + public function includeUser(ContestEntry $entry) + { + return $this->primitive([ + 'id' => $entry->user_id, + 'username' => ($entry->user ?? (new DeletedUser()))->username, + ]); + } + public function includeArtMeta(ContestEntry $entry) { if (!$entry->contest->hasThumbnails() || !presence($entry->entry_url)) { diff --git a/app/Transformers/LegacyApiKeyTransformer.php b/app/Transformers/LegacyApiKeyTransformer.php new file mode 100644 index 00000000000..9e4ba0b1b54 --- /dev/null +++ b/app/Transformers/LegacyApiKeyTransformer.php @@ -0,0 +1,22 @@ +. 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\Transformers; + +use App\Models\ApiKey; + +class LegacyApiKeyTransformer extends TransformerAbstract +{ + public function transform(ApiKey $legacyApi): array + { + return [ + 'api_key' => $legacyApi->api_key, + 'app_name' => $legacyApi->app_name, + 'app_url' => $legacyApi->app_url, + ]; + } +} diff --git a/app/Transformers/LegacyIrcKeyTransformer.php b/app/Transformers/LegacyIrcKeyTransformer.php new file mode 100644 index 00000000000..c5289ffc9eb --- /dev/null +++ b/app/Transformers/LegacyIrcKeyTransformer.php @@ -0,0 +1,20 @@ +. 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\Transformers; + +use App\Models\LegacyIrcKey; + +class LegacyIrcKeyTransformer extends TransformerAbstract +{ + public function transform(LegacyIrcKey $key): array + { + return [ + 'token' => $key->token, + ]; + } +} diff --git a/app/Transformers/SelectOptionTransformer.php b/app/Transformers/SelectOptionTransformer.php index 9f9d777f2a4..7bd75f025b2 100644 --- a/app/Transformers/SelectOptionTransformer.php +++ b/app/Transformers/SelectOptionTransformer.php @@ -7,13 +7,14 @@ namespace App\Transformers; +use App\Models\Country; use App\Models\Multiplayer\Room; use App\Models\Season; use App\Models\Spotlight; class SelectOptionTransformer extends TransformerAbstract { - public function transform(Room|Season|Spotlight $item): array + public function transform(Country|Room|Season|Spotlight $item): array { return [ 'id' => $item->getKey(), diff --git a/app/Transformers/UserContestEntryTransformer.php b/app/Transformers/UserContestEntryTransformer.php index a38dfd8a10c..309e002411e 100644 --- a/app/Transformers/UserContestEntryTransformer.php +++ b/app/Transformers/UserContestEntryTransformer.php @@ -5,6 +5,7 @@ namespace App\Transformers; +use App\Models\DeletedUser; use App\Models\UserContestEntry; class UserContestEntryTransformer extends TransformerAbstract diff --git a/app/helpers.php b/app/helpers.php index a119224e513..8fa5e3b164c 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -5,6 +5,8 @@ use App\Libraries\LocaleMeta; use App\Models\LoginAttempt; +use Egulias\EmailValidator\EmailValidator; +use Egulias\EmailValidator\Validation\NoRFCWarningsValidation; use Illuminate\Support\Arr; use Illuminate\Support\HtmlString; @@ -806,6 +808,20 @@ function is_json_request() return is_api_request() || request()->expectsJson(); } +function is_valid_email_format(?string $email): bool +{ + if ($email === null) { + return false; + } + + static $validator; + $validator ??= new EmailValidator(); + static $lexer; + $lexer ??= new NoRFCWarningsValidation(); + + return $validator->isValid($email, $lexer); +} + function is_sql_unique_exception($ex) { return starts_with( @@ -1216,6 +1232,15 @@ function i18n_date($datetime, $format = IntlDateFormatter::LONG, $pattern = null return $formatter->format($datetime); } +function i18n_date_auto(DateTimeInterface $date, string $skeleton): string +{ + $locale = App::getLocale(); + $generator = new IntlDatePatternGenerator($locale); + $pattern = $generator->getBestPattern($skeleton); + + return IntlDateFormatter::formatObject($date, $pattern, $locale); +} + function i18n_number_format($number, $style = null, $pattern = null, $precision = null, $locale = null) { if ($style === null && $pattern === null && $precision === null) { @@ -1306,9 +1331,9 @@ function fast_imagesize($url) CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5, + CURLOPT_TIMEOUT => 10, ]); $data = curl_exec($curl); - curl_close($curl); $result = read_image_properties_from_string($data); @@ -1714,18 +1739,18 @@ function clamp($number, $min, $max) } // e.g. 100634983048665 -> 100.63 trillion -function suffixed_number_format($number) +function suffixed_number_format(float|int $number, ?string $locale = null): string { - $suffixes = ['', 'k', 'million', 'billion', 'trillion']; // TODO: localize - $k = 1000; + $locale ??= App::getLocale(); - if ($number < $k) { - return $number; - } + static $formatters = []; - $i = floor(log($number) / log($k)); + if (!isset($formatters[$locale])) { + $formatters[$locale] = new NumberFormatter($locale, NumberFormatter::PADDING_POSITION); + $formatters[$locale]->setAttribute(NumberFormatter::FRACTION_DIGITS, 2); + } - return number_format($number / pow($k, $i), 2).' '.$suffixes[$i]; + return $formatters[$locale]->format($number); } function suffixed_number_format_tag($number) @@ -1780,10 +1805,7 @@ function check_url(string $url): bool ]); curl_exec($ch); - $errored = curl_errno($ch) > 0 || curl_getinfo($ch, CURLINFO_HTTP_CODE) !== 200; - curl_close($ch); - - return !$errored; + return curl_errno($ch) === 0 && curl_getinfo($ch, CURLINFO_HTTP_CODE) === 200; } function mini_asset(string $url): string diff --git a/composer.json b/composer.json index 9151612fcc1..b21c86f756c 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,6 @@ "knuckleswtf/scribe": "^4.0", "laravel/framework": "^8.58", "laravel/helpers": "^1.1", - "laravel/legacy-factories": "^1.1", "laravel/octane": "^1.3", "laravel/passport": "*", "laravel/slack-notification-channel": "^2.0", diff --git a/composer.lock b/composer.lock index 997dd23e5e0..20cc7ae013b 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": "ac3bc032a4040eacb1214baab50e885a", + "content-hash": "272ab9aedfc83c2637d5b3bcf20c495f", "packages": [ { "name": "anhskohbo/no-captcha", @@ -2270,16 +2270,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "1.9.0", + "version": "1.9.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "e98e3e6d4f86621a9b75f623996e6bbdeb4b9318" + "reference": "e4490cabc77465aaee90b20cfc9a770f8c04be6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/e98e3e6d4f86621a9b75f623996e6bbdeb4b9318", - "reference": "e98e3e6d4f86621a9b75f623996e6bbdeb4b9318", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/e4490cabc77465aaee90b20cfc9a770f8c04be6b", + "reference": "e4490cabc77465aaee90b20cfc9a770f8c04be6b", "shasum": "" }, "require": { @@ -2298,11 +2298,6 @@ "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.9-dev" - } - }, "autoload": { "files": [ "src/functions_include.php" @@ -2360,7 +2355,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/1.9.0" + "source": "https://github.com/guzzle/psr7/tree/1.9.1" }, "funding": [ { @@ -2376,7 +2371,7 @@ "type": "tidelift" } ], - "time": "2022-06-20T21:43:03+00:00" + "time": "2023-04-17T16:00:37+00:00" }, { "name": "http-interop/http-factory-guzzle", @@ -2883,22 +2878,22 @@ }, { "name": "laminas/laminas-diactoros", - "version": "2.23.0", + "version": "2.25.2", "source": { "type": "git", "url": "https://github.com/laminas/laminas-diactoros.git", - "reference": "a738cecb420e3bcff34c33177f1ce9f68902695c" + "reference": "9f3f4bf5b99c9538b6f1dbcc20f6fec357914f9e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/a738cecb420e3bcff34c33177f1ce9f68902695c", - "reference": "a738cecb420e3bcff34c33177f1ce9f68902695c", + "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/9f3f4bf5b99c9538b6f1dbcc20f6fec357914f9e", + "reference": "9f3f4bf5b99c9538b6f1dbcc20f6fec357914f9e", "shasum": "" }, "require": { "php": "~8.0.0 || ~8.1.0 || ~8.2.0", "psr/http-factory": "^1.0", - "psr/http-message": "^1.0" + "psr/http-message": "^1.1" }, "conflict": { "zendframework/zend-diactoros": "*" @@ -2913,11 +2908,11 @@ "ext-gd": "*", "ext-libxml": "*", "http-interop/http-factory-tests": "^0.9.0", - "laminas/laminas-coding-standard": "^2.4.0", + "laminas/laminas-coding-standard": "^2.5", "php-http/psr7-integration-tests": "^1.2", - "phpunit/phpunit": "^9.5.26", - "psalm/plugin-phpunit": "^0.18.0", - "vimeo/psalm": "^5.0.0" + "phpunit/phpunit": "^9.5.28", + "psalm/plugin-phpunit": "^0.18.4", + "vimeo/psalm": "^5.6" }, "type": "library", "extra": { @@ -2976,7 +2971,7 @@ "type": "community_bridge" } ], - "time": "2022-12-14T22:31:50+00:00" + "time": "2023-04-17T15:44:17+00:00" }, { "name": "laravel/framework", @@ -3207,62 +3202,6 @@ }, "time": "2022-01-12T15:58:51+00:00" }, - { - "name": "laravel/legacy-factories", - "version": "v1.3.0", - "source": { - "type": "git", - "url": "https://github.com/laravel/legacy-factories.git", - "reference": "5edc7e7eb76e7b4b29221f32139bcbf806c8870f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/legacy-factories/zipball/5edc7e7eb76e7b4b29221f32139bcbf806c8870f", - "reference": "5edc7e7eb76e7b4b29221f32139bcbf806c8870f", - "shasum": "" - }, - "require": { - "illuminate/macroable": "^8.0|^9.0", - "php": "^7.3|^8.0", - "symfony/finder": "^3.4|^4.0|^5.0|^6.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - }, - "laravel": { - "providers": [ - "Illuminate\\Database\\Eloquent\\LegacyFactoryServiceProvider" - ] - } - }, - "autoload": { - "files": [ - "helpers.php" - ], - "psr-4": { - "Illuminate\\Database\\Eloquent\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "The legacy version of the Laravel Eloquent factories.", - "homepage": "http://laravel.com", - "support": { - "issues": "https://github.com/laravel/framework/issues", - "source": "https://github.com/laravel/framework" - }, - "time": "2022-01-13T08:45:08+00:00" - }, { "name": "laravel/octane", "version": "v1.3.9", @@ -5498,38 +5437,39 @@ }, { "name": "nyholm/psr7", - "version": "1.5.1", + "version": "1.7.0", "source": { "type": "git", "url": "https://github.com/Nyholm/psr7.git", - "reference": "f734364e38a876a23be4d906a2a089e1315be18a" + "reference": "ed7cf98f6562831dbc3c962406b5e49dc8179c8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Nyholm/psr7/zipball/f734364e38a876a23be4d906a2a089e1315be18a", - "reference": "f734364e38a876a23be4d906a2a089e1315be18a", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/ed7cf98f6562831dbc3c962406b5e49dc8179c8c", + "reference": "ed7cf98f6562831dbc3c962406b5e49dc8179c8c", "shasum": "" }, "require": { - "php": ">=7.1", + "php": ">=7.2", "php-http/message-factory": "^1.0", "psr/http-factory": "^1.0", - "psr/http-message": "^1.0" + "psr/http-message": "^1.1 || ^2.0" }, "provide": { + "php-http/message-factory-implementation": "1.0", "psr/http-factory-implementation": "1.0", "psr/http-message-implementation": "1.0" }, "require-dev": { "http-interop/http-factory-tests": "^0.9", - "php-http/psr7-integration-tests": "^1.0", + "php-http/psr7-integration-tests": "^1.0@dev", "phpunit/phpunit": "^7.5 || 8.5 || 9.4", "symfony/error-handler": "^4.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.4-dev" + "dev-master": "1.7-dev" } }, "autoload": { @@ -5559,7 +5499,7 @@ ], "support": { "issues": "https://github.com/Nyholm/psr7/issues", - "source": "https://github.com/Nyholm/psr7/tree/1.5.1" + "source": "https://github.com/Nyholm/psr7/tree/1.7.0" }, "funding": [ { @@ -5571,7 +5511,7 @@ "type": "github" } ], - "time": "2022-06-22T07:13:36+00:00" + "time": "2023-04-20T08:38:48+00:00" }, { "name": "opis/closure", @@ -6245,26 +6185,26 @@ }, { "name": "php-http/message-factory", - "version": "v1.0.2", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/php-http/message-factory.git", - "reference": "a478cb11f66a6ac48d8954216cfed9aa06a501a1" + "reference": "4d8778e1c7d405cbb471574821c1ff5b68cc8f57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-http/message-factory/zipball/a478cb11f66a6ac48d8954216cfed9aa06a501a1", - "reference": "a478cb11f66a6ac48d8954216cfed9aa06a501a1", + "url": "https://api.github.com/repos/php-http/message-factory/zipball/4d8778e1c7d405cbb471574821c1ff5b68cc8f57", + "reference": "4d8778e1c7d405cbb471574821c1ff5b68cc8f57", "shasum": "" }, "require": { "php": ">=5.4", - "psr/http-message": "^1.0" + "psr/http-message": "^1.0 || ^2.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "1.x-dev" } }, "autoload": { @@ -6293,9 +6233,9 @@ ], "support": { "issues": "https://github.com/php-http/message-factory/issues", - "source": "https://github.com/php-http/message-factory/tree/master" + "source": "https://github.com/php-http/message-factory/tree/1.1.0" }, - "time": "2015-12-19T14:08:53+00:00" + "time": "2023-04-14T14:16:17+00:00" }, { "name": "php-http/multipart-stream-builder", @@ -6850,21 +6790,21 @@ }, { "name": "psr/http-factory", - "version": "1.0.1", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/http-factory.git", - "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be" + "reference": "e616d01114759c4c489f93b099585439f795fe35" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be", - "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/e616d01114759c4c489f93b099585439f795fe35", + "reference": "e616d01114759c4c489f93b099585439f795fe35", "shasum": "" }, "require": { "php": ">=7.0.0", - "psr/http-message": "^1.0" + "psr/http-message": "^1.0 || ^2.0" }, "type": "library", "extra": { @@ -6884,7 +6824,7 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "homepage": "https://www.php-fig.org/" } ], "description": "Common interfaces for PSR-7 HTTP message factories", @@ -6899,31 +6839,31 @@ "response" ], "support": { - "source": "https://github.com/php-fig/http-factory/tree/master" + "source": "https://github.com/php-fig/http-factory/tree/1.0.2" }, - "time": "2019-04-30T12:38:16+00:00" + "time": "2023-04-10T20:10:41+00:00" }, { "name": "psr/http-message", - "version": "1.0.1", + "version": "1.1", "source": { "type": "git", "url": "https://github.com/php-fig/http-message.git", - "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", - "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.1.x-dev" } }, "autoload": { @@ -6952,9 +6892,9 @@ "response" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/master" + "source": "https://github.com/php-fig/http-message/tree/1.1" }, - "time": "2016-08-06T14:39:51+00:00" + "time": "2023-04-04T09:50:52+00:00" }, { "name": "psr/log", diff --git a/config/app.php b/config/app.php index 7e54bf59294..89b35247c5e 100644 --- a/config/app.php +++ b/config/app.php @@ -81,9 +81,15 @@ /* * Make sure to check locale name mapping for other components. - * Carbon is in Http\Middleware\SetLocale (no helper... yet?). + * carbon is in Http\Middleware\SetLocale (no helper... yet?). * html, momentjs, and laravel are in LocaleMeta. + * php (IntlDateFormatter etc) isn't mapped at the moment. * Check respective packages for supported list of languages. + * + * carbon: list in vendor/nesbot/carbon/src/Carbon/Lang/ + * html: lang attribute in html tag. Mainly for uppercasing country code if used. + * laravel: list in vendor/laravel/framework/src/Illuminate/Translation/MessageSelector.php + * momentjs: list in node_modules/moment/locale/ */ 'available_locales' => [ // separate the default @@ -93,18 +99,22 @@ 'ar', 'be', 'bg', + 'ca', 'cs', 'da', 'de', 'el', 'es', 'fi', + 'fil', 'fr', + 'he', 'hu', 'id', 'it', 'ja', 'ko', + 'lt', 'nl', 'no', 'pl', @@ -113,6 +123,8 @@ 'ro', 'ru', 'sk', + 'sl', + 'sr', 'sv', 'th', 'tr', diff --git a/config/octane.php b/config/octane.php index 7bcde28962d..d39e5a36f7d 100644 --- a/config/octane.php +++ b/config/octane.php @@ -127,6 +127,13 @@ 'flush' => [ ], + 'swoole' => [ + 'options' => [ + // default of 10mb is too low for beatmap contest uploads + 'package_max_length' => 32 * 1024 * 1024, + ], + ], + /* |-------------------------------------------------------------------------- | Octane Cache Table @@ -213,4 +220,5 @@ 'max_execution_time' => 180, + 'state_file' => presence(env('OCTANE_STATE_FILE')) ?? storage_path('logs/octane-server-state.json'), ]; diff --git a/config/osu.php b/config/osu.php index 45c4838a216..d951fee48c0 100644 --- a/config/osu.php +++ b/config/osu.php @@ -270,8 +270,17 @@ 'ban_persist_days' => get_int(env('BAN_PERSIST_DAYS')) ?? 28, ], 'user_report_notification' => [ - 'endpoint_moderation' => presence(env('USER_REPORT_NOTIFICATION_ENDPOINT_MODERATION')), 'endpoint_cheating' => presence(env('USER_REPORT_NOTIFICATION_ENDPOINT_CHEATING')), + 'endpoint_moderation' => presence(env('USER_REPORT_NOTIFICATION_ENDPOINT_MODERATION')), + + 'endpoint' => [ + 'beatmapset_discussion' => presence(env('USER_REPORT_NOTIFICATION_ENDPOINT_BEATMAPSET_DISCUSSION')), + 'beatmapset' => presence(env('USER_REPORT_NOTIFICATION_ENDPOINT_BEATMAPSET')), + 'chat' => presence(env('USER_REPORT_NOTIFICATION_ENDPOINT_CHAT')), + 'comment' => presence(env('USER_REPORT_NOTIFICATION_ENDPOINT_COMMENT')), + 'forum' => presence(env('USER_REPORT_NOTIFICATION_ENDPOINT_FORUM')), + 'user' => presence(env('USER_REPORT_NOTIFICATION_ENDPOINT_USER')), + ], ], 'wiki' => [ 'branch' => presence(env('WIKI_BRANCH'), 'master'), diff --git a/crowdin.yml b/crowdin.yml index 72a33d2fa3c..2c9b1190808 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -8,6 +8,7 @@ files: ar: ar be: be bg: bg + ca: ca cs: cs da: da de: de @@ -15,12 +16,15 @@ files: es-ES: es fi-FI: fi fi: fi + fil: fil fr: fr + he: he hu: hu id: id it: it ja: ja ko: ko + lt: lt nl: nl 'no': 'no' pl: pl @@ -29,6 +33,8 @@ files: ro: ro ru: ru sk: sk + sl: sl + sr: sr sv-SE: sv th: th tr: tr diff --git a/database/factories/ApiKeyFactory.php b/database/factories/ApiKeyFactory.php new file mode 100644 index 00000000000..8199bd8ccfe --- /dev/null +++ b/database/factories/ApiKeyFactory.php @@ -0,0 +1,26 @@ +. 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 Database\Factories; + +use App\Models\ApiKey; +use App\Models\User; + +class ApiKeyFactory extends Factory +{ + protected $model = ApiKey::class; + + public function definition(): array + { + return [ + 'api_key' => bin2hex(random_bytes(20)), + 'app_name' => fn () => $this->faker->word(), + 'app_url' => fn () => "https://{$this->faker->word()}", + 'user_id' => User::factory(), + ]; + } +} diff --git a/database/factories/ArtistFactory.php b/database/factories/ArtistFactory.php index d98a887bc72..eae967d2d31 100644 --- a/database/factories/ArtistFactory.php +++ b/database/factories/ArtistFactory.php @@ -3,19 +3,25 @@ // 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. -$factory->define(App\Models\Artist::class, function (Faker\Generator $faker) { - return [ - 'name' => function () use ($faker) { - return $faker->lastName().' '.$faker->colorName(); - }, - 'description' => function () use ($faker) { - return $faker->realText(); - }, - 'website' => function () use ($faker) { - return $faker->safeEmailDomain(); - }, - 'cover_url' => '/images/headers/generic.jpg', - 'header_url' => '/images/headers/generic.jpg', - 'visible' => 1, - ]; -}); +declare(strict_types=1); + +namespace Database\Factories; + +use App\Models\Artist; + +class ArtistFactory extends Factory +{ + protected $model = Artist::class; + + public function definition(): array + { + return [ + 'name' => fn() => "{$this->faker->lastName()} {$this->faker->colorName()}", + 'description' => fn() => $this->faker->realText(), + 'website' => fn() => $this->faker->safeEmailDomain(), + 'cover_url' => '/images/headers/generic.jpg', + 'header_url' => '/images/headers/generic.jpg', + 'visible' => 1, + ]; + } +} diff --git a/database/factories/BanchoStatsFactory.php b/database/factories/BanchoStatsFactory.php index fe70ca43317..fe90b74e419 100644 --- a/database/factories/BanchoStatsFactory.php +++ b/database/factories/BanchoStatsFactory.php @@ -3,24 +3,24 @@ // 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. -/* -|-------------------------------------------------------------------------- -| Model Factories -|-------------------------------------------------------------------------- -| -| Here you may define all of your model factories. Model factories give -| you a convenient way to create models for testing and seeding your -| database. Just tell the factory how a default model should look. -| -*/ +declare(strict_types=1); +namespace Database\Factories; + +use App\Models\BanchoStats; use Carbon\Carbon; -$factory->define(App\Models\BanchoStats::class, function (Faker\Generator $faker) { - return [ - 'users_irc' => 100 + $faker->randomNumber(2), - 'users_osu' => 10000 + $faker->randomNumber(4), - 'multiplayer_games' => 200 + $faker->randomNumber(3), - 'date' => new Carbon(), - ]; -}); +class BanchoStatsFactory extends Factory +{ + protected $model = BanchoStats::class; + + public function definition(): array + { + return [ + 'users_irc' => fn() => 100 + $this->faker->randomNumber(2), + 'users_osu' => fn() => 10000 + $this->faker->randomNumber(4), + 'multiplayer_games' => fn() => 200 + $this->faker->randomNumber(3), + 'date' => fn() => Carbon::now(), + ]; + } +} diff --git a/database/factories/BeatmapFailtimesFactory.php b/database/factories/BeatmapFailtimesFactory.php index eb632748df4..ef47ca48646 100644 --- a/database/factories/BeatmapFailtimesFactory.php +++ b/database/factories/BeatmapFailtimesFactory.php @@ -2,17 +2,36 @@ // 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. -$factory->define(App\Models\BeatmapFailtimes::class, function (Faker\Generator $faker) { - $array = []; - for ($i = 1; $i <= 100; $i++) { - $field = 'p'.strval($i); - $array = array_merge($array, [$field => rand(1, 10000)]); - } +declare(strict_types=1); + +namespace Database\Factories; + +use App\Models\BeatmapFailtimes; - return $array; -}); +class BeatmapFailtimesFactory extends Factory +{ + protected $model = BeatmapFailtimes::class; -$factory->state(App\Models\BeatmapFailtimes::class, 'fail', ['type' => 'fail']); + public function definition(): array + { + $array = []; -$factory->state(App\Models\BeatmapFailtimes::class, 'retry', ['type' => 'exit']); + for ($i = 1; $i <= 100; $i++) { + $field = 'p'.strval($i); + $array[$field] = rand(1, 10000); + } + + return $array; + } + + public function fail(): static + { + return $this->state(['type' => 'fail']); + } + + public function retry(): static + { + return $this->state(['type' => 'exit']); + } +} diff --git a/database/factories/BeatmapMirrorFactory.php b/database/factories/BeatmapMirrorFactory.php index ee2b3641045..86ed3daf5f8 100644 --- a/database/factories/BeatmapMirrorFactory.php +++ b/database/factories/BeatmapMirrorFactory.php @@ -3,23 +3,32 @@ // 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. +declare(strict_types=1); + +namespace Database\Factories; + use App\Models\BeatmapMirror; -$factory->define(BeatmapMirror::class, function (Faker\Generator $faker) { - return [ - 'base_url' => 'http://beatmap-download.test/', - 'traffic_used' => rand(0, pow(2, 32)), - 'secret_key' => function () use ($faker) { - return $faker->password(); - }, - 'provider_user_id' => 2, - 'enabled' => 1, - 'version' => BeatmapMirror::MIN_VERSION_TO_USE, - ]; -}); +class BeatmapMirrorFactory extends Factory +{ + protected $model = BeatmapMirror::class; + + public function default(): static + { + return $this->state([ + 'mirror_id' => config('osu.beatmap_processor.mirrors_to_use')[0], + ]); + } -$factory->state(BeatmapMirror::class, 'default', function () { - return [ - 'mirror_id' => config('osu.beatmap_processor.mirrors_to_use')[0], - ]; -}); + public function definition(): array + { + return [ + 'base_url' => 'http://beatmap-download.test/', + 'traffic_used' => rand(0, pow(2, 32)), + 'secret_key' => fn() => $this->faker->password(), + 'provider_user_id' => 2, + 'enabled' => 1, + 'version' => BeatmapMirror::MIN_VERSION_TO_USE, + ]; + } +} diff --git a/database/factories/ChangelogFactory.php b/database/factories/ChangelogFactory.php index 1063f72891c..e19a0014d9a 100644 --- a/database/factories/ChangelogFactory.php +++ b/database/factories/ChangelogFactory.php @@ -3,30 +3,26 @@ // 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. -/* -|-------------------------------------------------------------------------- -| Model Factories -|-------------------------------------------------------------------------- -| -| Here you may define all of your model factories. Model factories give -| you a convenient way to create models for testing and seeding your -| database. Just tell the factory how a default model should look. -| -*/ +declare(strict_types=1); +namespace Database\Factories; + +use App\Models\Changelog; use App\Models\User; -$factory->define(App\Models\Changelog::class, function (Faker\Generator $faker) { - return [ - 'user_id' => function () { - $u = User::inRandomOrder()->first() ?? User::factory()->create(); +class ChangelogFactory extends Factory +{ + protected $model = Changelog::class; - return $u->getKey(); - }, - 'prefix' => $faker->randomElement(['*', '+', '?']), - 'category' => $faker->randomElement(['Web', 'Audio', 'Code', 'Editor', 'Gameplay', 'Graphics']), - 'message' => $faker->catchPhrase, - 'checksum' => $faker->md5, - 'date' => $faker->dateTimeBetween('-6 weeks', 'now'), - ]; -}); + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'prefix' => fn() => $this->faker->randomElement(['*', '+', '?']), + 'category' => fn() => $this->faker->randomElement(['Web', 'Audio', 'Code', 'Editor', 'Gameplay', 'Graphics']), + 'message' => fn() => $this->faker->catchPhrase(), + 'checksum' => fn() => $this->faker->md5, + 'date' => fn() => $this->faker->dateTimeBetween('-6 weeks'), + ]; + } +} diff --git a/database/factories/Chat/ChannelFactory.php b/database/factories/Chat/ChannelFactory.php index d74eb17a62f..56d5824cb2f 100644 --- a/database/factories/Chat/ChannelFactory.php +++ b/database/factories/Chat/ChannelFactory.php @@ -25,12 +25,12 @@ public function definition(): array ]; } - public function moderated() + public function moderated(): static { return $this->state(['moderated' => true]); } - public function pm(User ...$users) + public function pm(User ...$users): static { if (empty($users)) { $users = User::factory()->count(2)->create(); @@ -47,9 +47,9 @@ public function pm(User ...$users) ])->withUsers(...$users); } - public function tourney() + public function tourney(): static { - $match = factory(LegacyMatch::class)->states('tourney')->create(); + $match = LegacyMatch::factory()->tourney()->create(); return $this->state([ 'name' => "#mp_{$match->getKey()}", @@ -57,7 +57,7 @@ public function tourney() ]); } - public function type(string $type, array $users = []) + public function type(string $type, array $users = []): static { if ($type === 'tourney') { return $this->tourney(); @@ -68,7 +68,7 @@ public function type(string $type, array $users = []) return $this->state(['type' => Channel::TYPES[$type]])->withUsers(...$users); } - public function withUsers(User ...$users) + public function withUsers(User ...$users): static { return $this->afterCreating(function (Channel $channel) use ($users) { foreach ($users as $user) { diff --git a/database/factories/ChatFilterFactory.php b/database/factories/ChatFilterFactory.php index e45a56fb318..b99e0be119a 100644 --- a/database/factories/ChatFilterFactory.php +++ b/database/factories/ChatFilterFactory.php @@ -3,9 +3,21 @@ // 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. -$factory->define(App\Models\ChatFilter::class, function (Faker\Generator $faker) { - return [ - 'match' => $faker->unique()->word, - 'replacement' => $faker->word, - ]; -}); +declare(strict_types=1); + +namespace Database\Factories; + +use App\Models\ChatFilter; + +class ChatFilterFactory extends Factory +{ + protected $model = ChatFilter::class; + + public function definition(): array + { + return [ + 'match' => fn() => $this->faker->unique()->word, + 'replacement' => fn() => $this->faker->word, + ]; + } +} diff --git a/database/factories/ContestEntryFactory.php b/database/factories/ContestEntryFactory.php index 0ebf6f124c5..67028e085a6 100644 --- a/database/factories/ContestEntryFactory.php +++ b/database/factories/ContestEntryFactory.php @@ -3,13 +3,24 @@ // 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. -$factory->define(App\Models\ContestEntry::class, function (Faker\Generator $faker) { - return [ - 'user_id' => function () { - return App\Models\User::factory()->create()->user_id; - }, - 'entry_url' => '/images/headers/generic.jpg', - 'name' => $faker->words(3, true), - 'masked_name' => $faker->words(3, true), - ]; -}); +declare(strict_types=1); + +namespace Database\Factories; + +use App\Models\ContestEntry; +use App\Models\User; + +class ContestEntryFactory extends Factory +{ + protected $model = ContestEntry::class; + + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'entry_url' => '/images/headers/generic.jpg', + 'name' => fn() => $this->faker->words(3, true), + 'masked_name' => fn() => $this->faker->words(3, true), + ]; + } +} diff --git a/database/factories/ContestFactory.php b/database/factories/ContestFactory.php index 3fbc2720aad..53499e45527 100644 --- a/database/factories/ContestFactory.php +++ b/database/factories/ContestFactory.php @@ -3,55 +3,66 @@ // 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. -$factory->define(App\Models\Contest::class, function (Faker\Generator $faker) { - return [ - 'name' => function () use ($faker) { - return $faker->sentence(); - }, - 'description_enter' => function () use ($faker) { - return $faker->paragraph(); - }, - 'description_voting' => function () use ($faker) { - return $faker->paragraph(); - }, - 'type' => 'art', - 'header_url' => '/images/headers/generic.jpg', - 'visible' => 1, - ]; -}); - -$factory->state(App\Models\Contest::class, 'pending', function (Faker\Generator $faker) { - return [ - 'entry_starts_at' => Carbon\Carbon::now()->addMonths(1), - 'entry_ends_at' => Carbon\Carbon::now()->addMonths(2), - 'voting_starts_at' => Carbon\Carbon::now()->addMonths(3), - 'voting_ends_at' => Carbon\Carbon::now()->addMonths(4), - ]; -}); - -$factory->state(App\Models\Contest::class, 'entry', function (Faker\Generator $faker) { - return [ - 'entry_starts_at' => Carbon\Carbon::now()->subMonths(1), - 'entry_ends_at' => Carbon\Carbon::now()->addMonths(1), - 'voting_starts_at' => Carbon\Carbon::now()->addMonths(2), - 'voting_ends_at' => Carbon\Carbon::now()->addMonths(3), - ]; -}); - -$factory->state(App\Models\Contest::class, 'voting', function (Faker\Generator $faker) { - return [ - 'entry_starts_at' => Carbon\Carbon::now()->subMonths(3), - 'entry_ends_at' => Carbon\Carbon::now()->subMonths(2), - 'voting_starts_at' => Carbon\Carbon::now()->subMonths(1), - 'voting_ends_at' => Carbon\Carbon::now()->addMonths(1), - ]; -}); - -$factory->state(App\Models\Contest::class, 'completed', function (Faker\Generator $faker) { - return [ - 'entry_starts_at' => Carbon\Carbon::now()->subMonths(4), - 'entry_ends_at' => Carbon\Carbon::now()->subMonths(3), - 'voting_starts_at' => Carbon\Carbon::now()->subMonths(2), - 'voting_ends_at' => Carbon\Carbon::now()->subMonths(1), - ]; -}); +declare(strict_types=1); + +namespace Database\Factories; + +use App\Models\Contest; +use Carbon\Carbon; + +class ContestFactory extends Factory +{ + protected $model = Contest::class; + + public function completed(): static + { + return $this->state([ + 'entry_starts_at' => fn() => Carbon::now()->subMonths(4), + 'entry_ends_at' => fn() => Carbon::now()->subMonths(3), + 'voting_starts_at' => fn() => Carbon::now()->subMonths(2), + 'voting_ends_at' => fn() => Carbon::now()->subMonths(1), + ]); + } + + public function definition(): array + { + return [ + 'name' => fn() => $this->faker->sentence(), + 'description_enter' => fn() => $this->faker->paragraph(), + 'description_voting' => fn() => $this->faker->paragraph(), + 'type' => 'art', + 'header_url' => '/images/headers/generic.jpg', + 'visible' => 1, + ]; + } + + public function entry(): static + { + return $this->state([ + 'entry_starts_at' => fn() => Carbon::now()->subMonths(1), + 'entry_ends_at' => fn() => Carbon::now()->addMonths(1), + 'voting_starts_at' => fn() => Carbon::now()->addMonths(2), + 'voting_ends_at' => fn() => Carbon::now()->addMonths(3), + ]); + } + + public function pending(): static + { + return $this->state([ + 'entry_starts_at' => fn() => Carbon::now()->addMonths(1), + 'entry_ends_at' => fn() => Carbon::now()->addMonths(2), + 'voting_starts_at' => fn() => Carbon::now()->addMonths(3), + 'voting_ends_at' => fn() => Carbon::now()->addMonths(4), + ]); + } + + public function voting(): static + { + return $this->state([ + 'entry_starts_at' => fn() => Carbon::now()->subMonths(3), + 'entry_ends_at' => fn() => Carbon::now()->subMonths(2), + 'voting_starts_at' => fn() => Carbon::now()->subMonths(1), + 'voting_ends_at' => fn() => Carbon::now()->addMonths(1), + ]); + } +} diff --git a/database/factories/Factory.php b/database/factories/Factory.php index 7324b49a227..d01c965e18e 100644 --- a/database/factories/Factory.php +++ b/database/factories/Factory.php @@ -52,7 +52,4 @@ protected function newInstance(array $arguments = []): static return $factory; } - - // TODO: remove following line after removing legacy-factories - // fooling legacy-factories' "isLegacyFactory" check: class Hello extends Factory } diff --git a/database/factories/GroupFactory.php b/database/factories/GroupFactory.php index eef3da7d35d..18d6626f8cf 100644 --- a/database/factories/GroupFactory.php +++ b/database/factories/GroupFactory.php @@ -3,13 +3,21 @@ // 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. -$factory->define(App\Models\Group::class, function (Faker\Generator $faker) { - return [ - 'group_name' => function () use ($faker) { - return $faker->colorName().' '.$faker->domainWord(); - }, - 'group_desc' => function () use ($faker) { - return $faker->sentence(); - }, - ]; -}); +declare(strict_types=1); + +namespace Database\Factories; + +use App\Models\Group; + +class GroupFactory extends Factory +{ + protected $model = Group::class; + + public function definition(): array + { + return [ + 'group_name' => fn() => "{$this->faker->colorName()} {$this->faker->domainWord()}", + 'group_desc' => fn() => $this->faker->sentence(), + ]; + } +} diff --git a/database/factories/LegacyIrcKeyFactory.php b/database/factories/LegacyIrcKeyFactory.php new file mode 100644 index 00000000000..9b6d7caf4d7 --- /dev/null +++ b/database/factories/LegacyIrcKeyFactory.php @@ -0,0 +1,24 @@ +. 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 Database\Factories; + +use App\Models\LegacyIrcKey; +use App\Models\User; + +class LegacyIrcKeyFactory extends Factory +{ + protected $model = LegacyIrcKey::class; + + public function definition(): array + { + return [ + 'token' => bin2hex(random_bytes(4)), + 'user_id' => User::factory(), + ]; + } +} diff --git a/database/factories/LegacyMatch/EventFactory.php b/database/factories/LegacyMatch/EventFactory.php index 76e413bc4e5..8f06ff1e31d 100644 --- a/database/factories/LegacyMatch/EventFactory.php +++ b/database/factories/LegacyMatch/EventFactory.php @@ -3,54 +3,62 @@ // 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. +declare(strict_types=1); + +namespace Database\Factories\LegacyMatch; + use App\Models\LegacyMatch\Event; use App\Models\LegacyMatch\Game; use App\Models\LegacyMatch\LegacyMatch; +use App\Models\User; +use Carbon\Carbon; +use Database\Factories\Factory; + +class EventFactory extends Factory +{ + protected $model = Event::class; + + public function definition(): array + { + return [ + 'match_id' => LegacyMatch::factory(), + 'timestamp' => fn() => Carbon::now(), + 'user_id' => User::factory(), + ]; + } + + public function stateCreate(): static + { + return $this->state([ + 'text' => 'CREATE', + 'user_id' => null, + ]); + } + + public function disband(): static + { + return $this->state([ + 'text' => 'DISBAND', + 'user_id' => null, + ]); + } + + public function join(): static + { + return $this->state(['text' => 'JOIN']); + } + + public function part(): static + { + return $this->state(['text' => 'PART']); + } -$factory->define(Event::class, function (Faker\Generator $faker) { - return [ - 'match_id' => function () { - return factory(LegacyMatch::class)->create()->user_id; - }, - 'user_id' => function () { - return App\Models\User::factory()->create()->user_id; - }, - 'timestamp' => Carbon\Carbon::now(), - ]; -}); - -$factory->state(Event::class, 'create', function (Faker\Generator $faker) { - return [ - 'user_id' => null, - 'text' => 'CREATE', - ]; -}); - -$factory->state(Event::class, 'disband', function (Faker\Generator $faker) { - return [ - 'user_id' => null, - 'text' => 'DISBAND', - ]; -}); - -$factory->state(Event::class, 'join', function (Faker\Generator $faker) { - return [ - 'text' => 'JOIN', - ]; -}); - -$factory->state(Event::class, 'part', function (Faker\Generator $faker) { - return [ - 'text' => 'PART', - ]; -}); - -$factory->state(Event::class, 'game', function (Faker\Generator $faker) { - return [ - 'text' => 'test game', - 'user_id' => null, - 'game_id' => function () { - return factory(Game::class)->states('in_progress')->create()->game_id; - }, - ]; -}); + public function game(): static + { + return $this->state([ + 'game_id' => Game::factory()->inProgress(), + 'text' => 'test game', + 'user_id' => null, + ]); + } +} diff --git a/database/factories/LegacyMatch/GameFactory.php b/database/factories/LegacyMatch/GameFactory.php index dd9be13fda8..e0784483c64 100644 --- a/database/factories/LegacyMatch/GameFactory.php +++ b/database/factories/LegacyMatch/GameFactory.php @@ -3,29 +3,38 @@ // 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. +declare(strict_types=1); + +namespace Database\Factories\LegacyMatch; + +use App\Models\Beatmap; use App\Models\LegacyMatch\Game; use Carbon\Carbon; +use Database\Factories\Factory; + +class GameFactory extends Factory +{ + protected $model = Game::class; + + public function definition(): array + { + return [ + 'beatmap_id' => Beatmap::factory(), + 'scoring_type' => fn() => $this->faker->numberBetween(0, 3), + 'team_type' => fn() => $this->faker->numberBetween(0, 3), + + 'play_mode' => fn(array $attributes) => Beatmap::find($attributes['beatmap_id'])->playmode, + 'start_time' => fn(array $attributes) => Carbon::now()->subSeconds(Beatmap::find($attributes['beatmap_id'])->total_length), + ]; + } + + public function inProgress(): static + { + return $this->state(['end_time' => null]); + } -$factory->define(Game::class, function (Faker\Generator $faker) { - $beatmap = App\Models\Beatmap::inRandomOrder()->first(); - - return [ - 'beatmap_id' => $beatmap->beatmap_id, - 'start_time' => Carbon::now()->subSeconds($beatmap->total_length), - 'play_mode' => $beatmap->playmode, - 'scoring_type' => $faker->numberBetween(0, 3), - 'team_type' => $faker->numberBetween(0, 3), - ]; -}); - -$factory->state(Game::class, 'in_progress', function (Faker\Generator $faker) { - return [ - 'end_time' => null, - ]; -}); - -$factory->state(Game::class, 'complete', function (Faker\Generator $faker) { - return [ - 'end_time' => Carbon::now(), - ]; -}); + public function complete(): static + { + return $this->state(['end_time' => fn() => Carbon::now()]); + } +} diff --git a/database/factories/LegacyMatch/LegacyMatchFactory.php b/database/factories/LegacyMatch/LegacyMatchFactory.php index ddfae525e39..dd64815b030 100644 --- a/database/factories/LegacyMatch/LegacyMatchFactory.php +++ b/database/factories/LegacyMatch/LegacyMatchFactory.php @@ -3,26 +3,32 @@ // 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. +namespace Database\Factories\LegacyMatch; + use App\Models\LegacyMatch\LegacyMatch; +use Carbon\Carbon; +use Database\Factories\Factory; + +class LegacyMatchFactory extends Factory +{ + protected $model = LegacyMatch::class; -$factory->define(LegacyMatch::class, function (Faker\Generator $faker) { - return [ - 'name' => function () use ($faker) { - return $faker->sentence(); - }, - 'start_time' => Carbon\Carbon::now(), - 'private' => 0, - ]; -}); + public function definition(): array + { + return [ + 'name' => fn() => $this->faker->sentence(), + 'private' => 0, + 'start_time' => fn() => Carbon::now(), + ]; + } -$factory->state(LegacyMatch::class, 'private', function (Faker\Generator $faker) { - return [ - 'private' => 1, - ]; -}); + public function private(): static + { + return $this->state(['private' => 1]); + } -$factory->state(LegacyMatch::class, 'tourney', function (Faker\Generator $faker) { - return [ - 'keep_forever' => 1, - ]; -}); + public function tourney(): static + { + return $this->state(['keep_forever' => 1]); + } +} diff --git a/database/factories/Multiplayer/PlaylistItemFactory.php b/database/factories/Multiplayer/PlaylistItemFactory.php index c73a17e1a02..01ef8fdf95c 100644 --- a/database/factories/Multiplayer/PlaylistItemFactory.php +++ b/database/factories/Multiplayer/PlaylistItemFactory.php @@ -3,21 +3,29 @@ // 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. -$factory->define(App\Models\Multiplayer\PlaylistItem::class, function (Faker\Generator $faker) { - return [ - 'beatmap_id' => function () { - return App\Models\Beatmap::factory()->create()->getKey(); - }, - 'room_id' => function () { - return factory(App\Models\Multiplayer\Room::class)->create()->getKey(); - }, - 'ruleset_id' => function (array $self) { - return App\Models\Beatmap::find($self['beatmap_id'])->playmode; - }, - 'allowed_mods' => [], - 'required_mods' => [], - 'owner_id' => function () { - return App\Models\User::factory()->create()->getKey(); - }, - ]; -}); +declare(strict_types=1); + +namespace Database\Factories\Multiplayer; + +use App\Models\Beatmap; +use App\Models\Multiplayer\PlaylistItem; +use App\Models\Multiplayer\Room; +use App\Models\User; +use Database\Factories\Factory; + +class PlaylistItemFactory extends Factory +{ + protected $model = PlaylistItem::class; + + public function definition(): array + { + return [ + 'beatmap_id' => Beatmap::factory(), + 'room_id' => Room::factory(), + 'ruleset_id' => fn(array $attributes) => Beatmap::find($attributes['beatmap_id'])->playmode, + 'allowed_mods' => [], + 'required_mods' => [], + 'owner_id' => User::factory(), + ]; + } +} diff --git a/database/factories/Multiplayer/RoomFactory.php b/database/factories/Multiplayer/RoomFactory.php index 9820be8ab02..03edf0d650f 100644 --- a/database/factories/Multiplayer/RoomFactory.php +++ b/database/factories/Multiplayer/RoomFactory.php @@ -3,29 +3,41 @@ // 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. +declare(strict_types=1); + +namespace Database\Factories\Multiplayer; + use App\Models\Chat\Channel; use App\Models\Multiplayer\Room; +use App\Models\User; +use Carbon\Carbon; +use Database\Factories\Factory; + +class RoomFactory extends Factory +{ + protected $model = Room::class; + + public function configure(): static + { + return $this->afterCreating(function (Room $room) { + $channel = Channel::createMultiplayer($room); + + $room->update(['channel_id' => $channel->getKey()]); + }); + } + + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'name' => fn() => $this->faker->realText(20), + 'starts_at' => fn() => Carbon::now()->subHours(1), + 'ends_at' => fn() => Carbon::now()->addHours(1), + ]; + } -$factory->define(Room::class, function (Faker\Generator $faker) { - return [ - 'user_id' => function (array $self) { - return App\Models\User::factory()->create()->getKey(); - }, - 'name' => function () use ($faker) { - return $faker->realText(20); - }, - 'starts_at' => Carbon\Carbon::now()->subHours(1), - 'ends_at' => Carbon\Carbon::now()->addHours(1), - ]; -}); - -$factory->state(Room::class, 'ended', function (Faker\Generator $faker) { - return [ - 'ends_at' => Carbon\Carbon::now()->subMinutes(1), - ]; -}); - -$factory->afterCreating(Room::class, function (Room $room, $faker) { - $channel = Channel::createMultiplayer($room); - $room->update(['channel_id' => $channel->getKey()]); -}); + public function ended(): static + { + return $this->state(['ends_at' => Carbon::now()->subMinutes(1)]); + } +} diff --git a/database/factories/Multiplayer/ScoreFactory.php b/database/factories/Multiplayer/ScoreFactory.php index d19d9e4971f..e72320d3713 100644 --- a/database/factories/Multiplayer/ScoreFactory.php +++ b/database/factories/Multiplayer/ScoreFactory.php @@ -3,51 +3,51 @@ // 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. +declare(strict_types=1); + +namespace Database\Factories\Multiplayer; + use App\Models\Multiplayer\PlaylistItem; use App\Models\Multiplayer\Score; use App\Models\User; +use Carbon\Carbon; +use Database\Factories\Factory; + +class ScoreFactory extends Factory +{ + protected $model = Score::class; + + public function completed(): static + { + return $this->state(['ended_at' => Carbon::now()->subMinutes(1)]); + } + + public function definition(): array + { + return [ + 'playlist_item_id' => PlaylistItem::factory(), + 'beatmap_id' => fn(array $attributes) => PlaylistItem::find($attributes['playlist_item_id'])->beatmap_id, + 'room_id' => fn(array $attributes) => PlaylistItem::find($attributes['playlist_item_id'])->room_id, + 'user_id' => User::factory(), + 'total_score' => 1, + 'started_at' => fn() => Carbon::now()->subMinutes(5), + 'accuracy' => 0.5, + 'pp' => 1, + ]; + } + + public function failed(): static + { + return $this->completed()->state(['passed' => false]); + } + + public function passed(): static + { + return $this->completed()->state(['passed' => true]); + } -$factory->define(Score::class, function (Faker\Generator $faker) { - return [ - 'playlist_item_id' => function () { - return factory(PlaylistItem::class)->create()->getKey(); - }, - 'beatmap_id' => function (array $self) { - return PlaylistItem::find($self['playlist_item_id'])->beatmap_id; - }, - 'room_id' => function (array $self) { - return PlaylistItem::find($self['playlist_item_id'])->room_id; - }, - 'user_id' => function () { - return User::factory()->create()->getKey(); - }, - 'total_score' => 1, - 'started_at' => now()->subMinutes(5), - 'accuracy' => 0.5, - 'pp' => 1, - ]; -}); - -$factory->state(Score::class, 'completed', function (Faker\Generator $faker) { - return [ - 'ended_at' => now(), - ]; -}); - -$factory->state(Score::class, 'passed', function (Faker\Generator $faker) { - return [ - 'passed' => true, - ]; -}); - -$factory->state(Score::class, 'failed', function (Faker\Generator $faker) { - return [ - 'passed' => false, - ]; -}); - -$factory->state(Score::class, 'scoreless', function (Faker\Generator $faker) { - return [ - 'total_score' => 0, - ]; -}); + public function scoreless(): static + { + return $this->state(['total_score' => 0]); + } +} diff --git a/database/factories/NotificationFactory.php b/database/factories/NotificationFactory.php index 70ec5cfd327..f1e023d59b0 100644 --- a/database/factories/NotificationFactory.php +++ b/database/factories/NotificationFactory.php @@ -3,14 +3,24 @@ // 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. +declare(strict_types=1); + +namespace Database\Factories; + use App\Libraries\MorphMap; use App\Models\Notification; -$factory->define(Notification::class, function (Faker\Generator $faker) { - return [ - 'notifiable_type' => array_rand_val(MorphMap::MAP), - 'notifiable_id' => rand(), - 'name' => array_rand(Notification::NAME_TO_CATEGORY), - 'details' => [], - ]; -}); +class NotificationFactory extends Factory +{ + protected $model = Notification::class; + + public function definition(): array + { + return [ + 'details' => [], + 'name' => array_rand(Notification::NAME_TO_CATEGORY), + 'notifiable_id' => rand(), + 'notifiable_type' => array_rand_val(MorphMap::MAP), + ]; + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php deleted file mode 100644 index c61d805d0f9..00000000000 --- a/database/factories/OrderFactory.php +++ /dev/null @@ -1,53 +0,0 @@ -. Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -$factory->define(App\Models\Store\Order::class, function (Faker\Generator $faker) { - return [ - 'user_id' => function () { - return App\Models\User::factory()->create(['user_sig' => ''])->user_id; - }, - ]; -}); - -$factory->state(App\Models\Store\Order::class, 'paid', function (Faker\Generator $faker) use ($factory) { - $date = Carbon\Carbon::now(); - - return [ - 'paid_at' => $date, - 'status' => 'paid', - 'transaction_id' => "test-{$date->timestamp}", - ]; -}); - -$factory->state(App\Models\Store\Order::class, 'incart', function (Faker\Generator $faker) { - return [ - 'status' => 'incart', - ]; -}); - -$factory->state(App\Models\Store\Order::class, 'processing', function (Faker\Generator $faker) { - return [ - 'status' => 'processing', - ]; -}); - -$factory->state(App\Models\Store\Order::class, 'checkout', function (Faker\Generator $faker) { - return [ - 'status' => 'checkout', - ]; -}); - -$factory->state(App\Models\Store\Order::class, 'shipped', function (Faker\Generator $faker) { - return [ - 'status' => 'shipped', - ]; -}); - -$factory->state(App\Models\Store\Order::class, 'shopify', function (Faker\Generator $faker) { - return [ - // Doesn't need to be a gid for tests. - 'transaction_id' => App\Models\Store\Order::PROVIDER_SHOPIFY.'-'.now()->timestamp, - ]; -}); diff --git a/database/factories/OrderItemFactory.php b/database/factories/OrderItemFactory.php deleted file mode 100644 index 2179b7f4571..00000000000 --- a/database/factories/OrderItemFactory.php +++ /dev/null @@ -1,47 +0,0 @@ -. Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -use App\Models\Store\Order; -use App\Models\Store\OrderItem; -use App\Models\Store\Product; - -$factory->define(App\Models\Store\OrderItem::class, function (Faker\Generator $faker) { - return [ - 'order_id' => function () { - return factory(Order::class)->create()->order_id; - }, - 'product_id' => function () { - return factory(Product::class)->create()->product_id; - }, - 'quantity' => 1, - 'cost' => 12.0, - ]; -}); - -$factory->state(OrderItem::class, 'supporter_tag', function (Faker\Generator $faker) use ($factory) { - return [ - 'product_id' => Product::customClass(Product::SUPPORTER_TAG_NAME)->first(), - 'cost' => 4, - 'extra_data' => function (array $self) { - // find the user for the generated item's order - $order = Order::find($self['order_id']); - $user = $order->user; - - return [ - 'target_id' => $user->getKey(), - 'username' => $user->username, - 'duration' => 1, - ]; - }, - ]; -}); - -$factory->state(OrderItem::class, 'username_change', function (Faker\Generator $faker) use ($factory) { - return [ - 'product_id' => Product::customClass('username-change')->first(), - 'cost' => 0, - 'extra_info' => 'new_username', - ]; -}); diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php deleted file mode 100644 index 0557e6fb94b..00000000000 --- a/database/factories/ProductFactory.php +++ /dev/null @@ -1,97 +0,0 @@ -. Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -/* -|-------------------------------------------------------------------------- -| Model Factories -|-------------------------------------------------------------------------- -| -| Here you may define all of your model factories. Model factories give -| you a convenient way to create models for testing and seeding your -| database. Just tell the factory how a default model should look. -| -*/ - -$factory->define(App\Models\Store\Product::class, function (Faker\Generator $faker) { - return [ - 'name' => 'Imagination / '.$faker->colorName, - 'cost' => 16.00, - 'weight' => 100, - 'base_shipping' => 5.00, - 'next_shipping' => 4.00, - 'stock' => rand(1, 100), - 'max_quantity' => 1, - ]; -}); - -$factory->state(App\Models\Store\Product::class, 'master_tshirt', function (Faker\Generator $faker) { - return [ - 'name' => 'osu! t-shirt (triangles) / '.$faker->colorName, - 'cost' => 16.00, - 'weight' => 100, - 'base_shipping' => 5.00, - 'next_shipping' => 4.00, - 'stock' => rand(1, 100), - 'max_quantity' => 5, - 'header_description' => '# osu! t-shirt swag', - 'promoted' => 1, - 'description' => "Brand new osu! t-shirts have arrived! Featuring a tasty triangle design by osu! designer flyte, it's a welcome addition to any avid osu! player’s wardrobe. - -* 100% cotton -* Medium weight, pre-shrunk -* Sizes: S, M, L, XL - -``` -Size S M L XL -Garment Length 66cm 70cm 74cm 78cm -Body width 49cm 52cm 55cm 58cm -Shoulder width 44cm 47cm 50cm 53cm -Sleeve length 19cm 20cm 22cm 24cm -``` - -NOTE: These are Japanese sizes. Overseas customers are advised to check the size chart above! -", - 'header_image' => 'https://puu.sh/hzgoB/1142f14e8b.jpg', - 'images_json' => json_encode([ - ['https://puu.sh/hxpsp/d0b8704769.jpg', 'https://puu.sh/hxpsp/d0b8704769.jpg'], - ['https://puu.sh/hxptO/71121e05e7.jpg', 'https://puu.sh/hxptO/71121e05e7.jpg'], - ['https://puu.sh/hzfUF/1b9af4dbd1.jpg', 'https://puu.sh/hzfUF/1b9af4dbd1.jpg'], - ]), - ]; -}); - -$factory->state(App\Models\Store\Product::class, 'child_tshirt', function (Faker\Generator $faker) { - return [ - 'name' => 'osu! t-shirt (triangles) / '.$faker->colorName, - 'cost' => 16.00, - 'weight' => 100, - 'base_shipping' => 5.00, - 'next_shipping' => 4.00, - 'stock' => rand(1, 100), - 'max_quantity' => 5, - ]; -}); - -$factory->state(App\Models\Store\Product::class, 'child_banners', function (Faker\Generator $faker) { - $params = [ - // 'name' => 'supply your own name', - 'cost' => 5.00, - 'weight' => null, - 'stock' => null, - 'base_shipping' => 0.00, - 'next_shipping' => 0.00, - 'max_quantity' => 1, - 'display_order' => -10, - 'custom_class' => 'mwc7-supporter', - ]; - - return $params; -}); - -$factory->state(App\Models\Store\Product::class, 'disabled', function (Faker\Generator $faker) { - return [ - 'enabled' => false, - ]; -}); diff --git a/database/factories/Score/Best/FruitsFactory.php b/database/factories/Score/Best/FruitsFactory.php index ea5e54c1d07..f8e7f0251af 100644 --- a/database/factories/Score/Best/FruitsFactory.php +++ b/database/factories/Score/Best/FruitsFactory.php @@ -12,7 +12,4 @@ class FruitsFactory extends ModelFactory { protected $model = Fruits::class; - - // TODO: remove following line after removing legacy-factories - // fooling legacy-factories' "isLegacyFactory" check: class Hello extends Factory } diff --git a/database/factories/Score/Best/ManiaFactory.php b/database/factories/Score/Best/ManiaFactory.php index 2dd7ccad784..2d11756385b 100644 --- a/database/factories/Score/Best/ManiaFactory.php +++ b/database/factories/Score/Best/ManiaFactory.php @@ -12,7 +12,4 @@ class ManiaFactory extends ModelFactory { protected $model = Mania::class; - - // TODO: remove following line after removing legacy-factories - // fooling legacy-factories' "isLegacyFactory" check: class Hello extends Factory } diff --git a/database/factories/Score/Best/OsuFactory.php b/database/factories/Score/Best/OsuFactory.php index 91bb64e7236..1b4955c4ec5 100644 --- a/database/factories/Score/Best/OsuFactory.php +++ b/database/factories/Score/Best/OsuFactory.php @@ -12,7 +12,4 @@ class OsuFactory extends ModelFactory { protected $model = Osu::class; - - // TODO: remove following line after removing legacy-factories - // fooling legacy-factories' "isLegacyFactory" check: class Hello extends Factory } diff --git a/database/factories/Score/Best/TaikoFactory.php b/database/factories/Score/Best/TaikoFactory.php index 136da67395c..38a1a795025 100644 --- a/database/factories/Score/Best/TaikoFactory.php +++ b/database/factories/Score/Best/TaikoFactory.php @@ -12,7 +12,4 @@ class TaikoFactory extends ModelFactory { protected $model = Taiko::class; - - // TODO: remove following line after removing legacy-factories - // fooling legacy-factories' "isLegacyFactory" check: class Hello extends Factory } diff --git a/database/factories/SpotlightFactory.php b/database/factories/SpotlightFactory.php index 0247154111b..35768c2c91b 100644 --- a/database/factories/SpotlightFactory.php +++ b/database/factories/SpotlightFactory.php @@ -3,63 +3,65 @@ // 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. -$factory->define(App\Models\Spotlight::class, function (Faker\Generator $faker) { - $startDate = $faker->dateTimeBetween('-6 years', 'now'); - $endDate = Carbon\Carbon::instance($startDate)->addMonths(1); +declare(strict_types=1); - return [ - 'acronym' => 'T'.strtoupper(substr(uniqid(), 8)), - 'name' => $faker->realText(40), - 'start_date' => $startDate, - 'end_date' => $endDate, - 'mode_specific' => true, - 'type' => 'test', - 'active' => true, - ]; -}); +namespace Database\Factories; -$factory->state(App\Models\Spotlight::class, 'monthly', function (Faker\Generator $faker) { - $chartDate = Carbon\Carbon::instance($faker->dateTimeBetween('-6 years', 'now'))->startOfMonth(); +use App\Models\Spotlight; +use Carbon\Carbon; - return [ - 'acronym' => function (array $self) { - return "MONTH{$self['chart_month']->format('ym')}"; - }, - 'name' => function (array $self) { - return "Spotlight {$self['chart_month']->format('F Y')}"; - }, - 'start_date' => function (array $self) { - return ($self['chart_month'] ?? $chartDate)->copy()->addMonths(1)->addDays(rand(0, 27)); - }, - 'end_date' => function (array $self) { - return ($self['chart_month'] ?? $chartDate)->copy()->addMonths(2)->addDays(rand(0, 27)); - }, - 'mode_specific' => true, - 'type' => 'monthly', - 'active' => true, - 'chart_month' => $chartDate, - ]; -}); +class SpotlightFactory extends Factory +{ + protected $model = Spotlight::class; -$factory->state(App\Models\Spotlight::class, 'bestof', function (Faker\Generator $faker) { - $chartDate = Carbon\Carbon::instance($faker->dateTimeBetween('-6 years', 'now'))->endOfYear(); + public function configure(): static + { + return $this->afterMaking(function (Spotlight $spotlight) { + $spotlight->end_date ??= $spotlight->start_date?->addMonths(1)->addDays(rand(0, 27)); + }); + } - return [ - 'acronym' => function (array $self) { - return "BEST{$self['chart_month']->format('Y')}"; - }, - 'name' => function (array $self) { - return "Best of {$self['chart_month']->format('Y')}"; - }, - 'start_date' => function (array $self) { - return ($self['chart_month'] ?? $chartDate)->copy()->startOfMonth()->addMonths(1)->addDays(rand(0, 27)); - }, - 'end_date' => function (array $self) { - return ($self['chart_month'] ?? $chartDate)->copy()->startOfMonth()->addMonths(2)->addDays(rand(0, 27)); - }, - 'mode_specific' => true, - 'type' => 'bestof', - 'active' => true, - 'chart_month' => $chartDate, - ]; -}); + public function definition(): array + { + return [ + 'acronym' => 'T'.strtoupper(substr(uniqid(), 8)), + 'active' => true, + 'mode_specific' => true, + 'name' => fn () => $this->faker->realText(40), + 'start_date' => fn () => $this->faker->dateTimeBetween('-6 years'), + 'type' => 'test', + ]; + } + + public function monthly(): static + { + $chartMonth = Carbon::instance($this->faker->dateTimeBetween('-6 years'))->startOfMonth(); + + return $this->state([ + 'active' => true, + 'chart_month' => $chartMonth, + 'mode_specific' => true, + 'type' => 'monthly', + + 'acronym' => fn (array $attr) => "MONTH{$attr['chart_month']->format('ym')}", + 'name' => fn (array $attr) => "Spotlight {$attr['chart_month']->format('F Y')}", + 'start_date' => fn (array $attr) => $attr['chart_month']->copy()->addMonths(1)->addDays(rand(0, 27)), + ]); + } + + public function bestof(): static + { + $chartMonth = Carbon::instance($this->faker->dateTimeBetween('-6 years'))->endOfYear(); + + return [ + 'active' => true, + 'chart_month' => $chartMonth, + 'mode_specific' => true, + 'type' => 'bestof', + + 'acronym' => fn (array $attr) => "BEST{$attr['chart_month']->format('Y')}", + 'name' => fn (array $attr) => "Best of {$attr['chart_month']->format('Y')}", + 'start_date' => fn (array $attr) => $attr['chart_month']->copy()->addMonths(1)->addDays(rand(0, 27)), + ]; + } +} diff --git a/database/factories/StatsFactory.php b/database/factories/StatsFactory.php deleted file mode 100644 index 5b662320de0..00000000000 --- a/database/factories/StatsFactory.php +++ /dev/null @@ -1,61 +0,0 @@ -. Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -if (!function_exists('generateStats')) { - function generateStats() - { - // Base template for all modes to use - - $acc = (float) (rand(850000, 1000000)) / 10000; // 85.0000 - 100.0000 - $score = (float) rand(500000, 2000000000) * 2; // 500k - 4bil - $pp = (float) rand(1, 15000); - $playcount = rand(1000, 250000); // 1k - 250k - - return [ - 'level' => rand(1, 104), - 'count300' => rand(10000, 5000000), // 10k to 5mil - 'count100' => rand(10000, 2000000), // 10k to 2mil - 'count50' => rand(10000, 1000000), // 10k to 1mil - 'countMiss' => rand(10000, 1000000), // 10k to 1mil - 'accuracy_total' => rand(1000, 250000), // 1k to 250k. unsure what field is for - 'accuracy_count' => rand(1000, 250000), // 1k to 250k. unsure what field is for - 'accuracy' => $acc / 100, - 'accuracy_new' => $acc, - 'playcount' => $playcount, - 'fail_count' => rand($playcount * 0.1, $playcount * 0.2), - 'exit_count' => rand($playcount * 0.2, $playcount * 0.3), - 'rank' => rand(1, 500000), - 'ranked_score' => $score, - 'total_score' => $score * 1.4, - 'total_seconds_played' => rand($playcount * 120 * 0.3, $playcount * 120 * 0.7), - 'x_rank_count' => round($playcount * 0.001), - 'xh_rank_count' => round($playcount * 0.0003), - 's_rank_count' => round($playcount * 0.05), - 'sh_rank_count' => round($playcount * 0.02), - 'a_rank_count' => round($playcount * 0.2), - 'rank_score' => $pp, - 'rank_score_index' => rand(1, 500000), - 'max_combo' => rand(500, 4000), - ]; - } -} - -foreach (array_keys(App\Models\Beatmap::MODES) as $mode) { - $factory->define(App\Models\UserStatistics\Model::getClass($mode), function (Faker\Generator $faker) { - return generateStats(); - }); - - $factory->define(App\Models\UserStatistics\Spotlight\Model::getClass($mode), function (Faker\Generator $faker) { - $stats = generateStats(); - unset($stats['accuracy_new']); - unset($stats['total_seconds_played']); - unset($stats['xh_rank_count']); - unset($stats['sh_rank_count']); - unset($stats['rank_score']); - unset($stats['rank_score_index']); - - return $stats; - }); -} diff --git a/database/factories/Store/OrderFactory.php b/database/factories/Store/OrderFactory.php new file mode 100644 index 00000000000..ac69f5cd248 --- /dev/null +++ b/database/factories/Store/OrderFactory.php @@ -0,0 +1,64 @@ +. 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 Database\Factories\Store; + +use App\Models\Store\Order; +use App\Models\User; +use Carbon\Carbon; +use Database\Factories\Factory; + +class OrderFactory extends Factory +{ + protected $model = Order::class; + + public function checkout(): static + { + return $this->state(['status' => 'checkout']); + } + + public function definition(): array + { + return [ + 'user_id' => User::factory(), + ]; + } + + public function paid(): static + { + $date = Carbon::now(); + + return $this->state([ + 'paid_at' => $date, + 'status' => 'paid', + 'transaction_id' => "test-{$date->timestamp}", + ]); + } + + public function incart(): static + { + return $this->state(['status' => 'incart']); + } + + public function processing(): static + { + return $this->state(['status' => 'processing']); + } + + public function shipped(): static + { + return $this->state(['status' => 'shipped']); + } + + public function shopify(): static + { + return $this->state([ + // Doesn't need to be a gid for tests. + 'transaction_id' => Order::PROVIDER_SHOPIFY.'-'.time(), + ]); + } +} diff --git a/database/factories/Store/OrderItemFactory.php b/database/factories/Store/OrderItemFactory.php new file mode 100644 index 00000000000..0ff19170bb2 --- /dev/null +++ b/database/factories/Store/OrderItemFactory.php @@ -0,0 +1,54 @@ +. 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 Database\Factories\Store; + +use App\Models\Store\Order; +use App\Models\Store\OrderItem; +use App\Models\Store\Product; +use Database\Factories\Factory; + +class OrderItemFactory extends Factory +{ + protected $model = OrderItem::class; + + public function definition(): array + { + return [ + 'cost' => 12.0, + 'order_id' => Order::factory(), + 'product_id' => Product::factory(), + 'quantity' => 1, + ]; + } + + public function supporterTag(): static + { + return $this->state([ + 'cost' => 4, + 'extra_data' => function (array $attributes) { + $user = Order::find($attributes['order_id'])->user; + + return [ + 'duration' => 1, + 'target_id' => (string) $user->getKey(), + 'username' => $user->username, + ]; + }, + 'product_id' => Product::customClass('supporter-tag')->first(), + ]); + } + + public function usernameChange(): static + { + return $this->state([ + 'cost' => 0, + 'extra_info' => 'new_username', + 'product_id' => Product::customClass('username-change')->first(), + ]); + } +} diff --git a/database/factories/Store/ProductFactory.php b/database/factories/Store/ProductFactory.php new file mode 100644 index 00000000000..cece4411d43 --- /dev/null +++ b/database/factories/Store/ProductFactory.php @@ -0,0 +1,99 @@ +. 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 Database\Factories\Store; + +use App\Models\Store\Product; +use Database\Factories\Factory; + +class ProductFactory extends Factory +{ + protected $model = Product::class; + + public function childBanners(): static + { + return $this->state([ + 'base_shipping' => 0.00, + 'cost' => 5.00, + 'custom_class' => 'mwc7-supporter', + 'display_order' => -10, + 'max_quantity' => 1, + 'next_shipping' => 0.00, + 'stock' => null, + 'weight' => null, + ]); + } + + public function childTshirt(): static + { + return $this->state([ + 'base_shipping' => 5.00, + 'cost' => 16.00, + 'max_quantity' => 5, + 'name' => fn() => "osu! t-shirt (triangles) / {$this->faker->colorName}", + 'next_shipping' => 4.00, + 'stock' => rand(1, 100), + 'weight' => 100, + ]); + } + + public function definition(): array + { + return [ + 'base_shipping' => 5.00, + 'cost' => 16.00, + 'max_quantity' => 1, + 'name' => fn() => "Imagination / {$this->faker->colorName}", + 'next_shipping' => 4.00, + 'stock' => rand(1, 100), + 'weight' => 100, + ]; + } + + public function disabled(): static + { + return $this->state(['enabled' => false]); + } + + public function masterTshirt(): static + { + return $this->state([ + 'base_shipping' => 5.00, + 'cost' => 16.00, + 'description' => << '# osu! t-shirt swag', + 'header_image' => 'https://puu.sh/hzgoB/1142f14e8b.jpg', + 'images_json' => json_encode([ + ['https://puu.sh/hxpsp/d0b8704769.jpg', 'https://puu.sh/hxpsp/d0b8704769.jpg'], + ['https://puu.sh/hxptO/71121e05e7.jpg', 'https://puu.sh/hxptO/71121e05e7.jpg'], + ['https://puu.sh/hzfUF/1b9af4dbd1.jpg', 'https://puu.sh/hzfUF/1b9af4dbd1.jpg'], + ]), + 'max_quantity' => 5, + 'name' => fn() => "osu! t-shirt (triangles) / {$this->faker->colorName}", + 'next_shipping' => 4.00, + 'promoted' => 1, + 'stock' => rand(1, 100), + 'weight' => 100, + ]); + } +} diff --git a/database/factories/UpdateStreamFactory.php b/database/factories/UpdateStreamFactory.php index 9010dfe5c17..159f11abf4f 100644 --- a/database/factories/UpdateStreamFactory.php +++ b/database/factories/UpdateStreamFactory.php @@ -3,13 +3,22 @@ // 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. -$factory->define(App\Models\UpdateStream::class, function (Faker\Generator $faker) { - return [ - 'name' => function () use ($faker) { - return $faker->colorName(); - }, - 'pretty_name' => function () use ($faker) { - return $faker->colorName(); - }, - ]; -}); +declare(strict_types=1); + +namespace Database\Factories; + +use App\Models\UpdateStream; + +class UpdateStreamFactory extends Factory +{ + protected $model = UpdateStream::class; + + public function definition(): array + { + return [ + 'name' => $this->faker->colorName(), + + 'pretty_name' => fn (array $attr) => $attr['name'], + ]; + } +} diff --git a/database/factories/UserDonationFactory.php b/database/factories/UserDonationFactory.php index b3b838133a0..5431dbf67dd 100644 --- a/database/factories/UserDonationFactory.php +++ b/database/factories/UserDonationFactory.php @@ -3,24 +3,40 @@ // 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. -$factory->define(App\Models\UserDonation::class, function (Faker\Generator $faker) { - return [ - 'user_id' => function () { - return App\Models\User::factory()->create()->user_id; - }, - 'target_user_id' => function (array $self) { - return $self['user_id']; - }, - 'transaction_id' => 'faked-'.time()."-{$faker->randomNumber()}", - 'length' => 1, - 'amount' => 4, - 'cancel' => false, - ]; -}); - -$factory->state(App\Models\UserDonation::class, 'cancelled', function (Faker\Generator $faker) { - return [ - 'transaction_id' => 'faked-'.time()."-{$faker->randomNumber()}-cancel", - 'cancel' => true, - ]; -}); +declare(strict_types=1); + +namespace Database\Factories; + +use App\Models\User; +use App\Models\UserDonation; + +class UserDonationFactory extends Factory +{ + protected $model = UserDonation::class; + + public function definition(): array + { + return [ + 'amount' => 4, + 'cancel' => false, + 'length' => 1, + 'transaction_id' => fn () => $this->transactionId(), + 'user_id' => User::factory(), + + 'target_user_id' => fn (array $attr) => $attr['user_id'], + ]; + } + + public function cancelled(): static + { + return $this->state([ + 'cancel' => true, + 'transaction_id' => fn () => "{$this->transactionId()}-cancel", + ]); + } + + private function transactionId(): string + { + return 'faked-'.time().'-'.$this->faker->randomNumber(); + } +} diff --git a/database/factories/UserGroupFactory.php b/database/factories/UserGroupFactory.php index 0c3e3a6699c..3ccd2c541e9 100644 --- a/database/factories/UserGroupFactory.php +++ b/database/factories/UserGroupFactory.php @@ -2,9 +2,22 @@ // 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. -$factory->define(App\Models\UserGroup::class, function (Faker\Generator $faker) { - return [ - 'group_leader' => 0, - 'user_pending' => 0, - ]; -}); + +declare(strict_types=1); + +namespace Database\Factories; + +use App\Models\UserGroup; + +class UserGroupFactory extends Factory +{ + protected $model = UserGroup::class; + + public function definition(): array + { + return [ + 'group_leader' => 0, + 'user_pending' => 0, + ]; + } +} diff --git a/database/factories/UserMonthlyPlaycountFactory.php b/database/factories/UserMonthlyPlaycountFactory.php index 4afb211becf..cd320bd47a9 100644 --- a/database/factories/UserMonthlyPlaycountFactory.php +++ b/database/factories/UserMonthlyPlaycountFactory.php @@ -3,11 +3,21 @@ // 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. -$factory->define(App\Models\UserMonthlyPlaycount::class, function (Faker\Generator $faker) { - return [ - 'year_month' => function () use ($faker) { - return sprintf('%02d%02d', $faker->numberBetween(7, Carbon\Carbon::now()->format('y')), $faker->numberBetween(1, 12)); - }, - 'playcount' => $faker->numberBetween(500, 2000), - ]; -}); +declare(strict_types=1); + +namespace Database\Factories; + +use App\Models\UserMonthlyPlaycount; + +class UserMonthlyPlaycountFactory extends Factory +{ + protected $model = UserMonthlyPlaycount::class; + + public function definition(): array + { + return [ + 'playcount' => fn () => $this->faker->numberBetween(500, 2000), + 'year_month' => fn () => $this->faker->dateTimeBetween('-6 years')->format('ym'), + ]; + } +} diff --git a/database/factories/UserNotificationFactory.php b/database/factories/UserNotificationFactory.php index ae973b61034..ee248e1b88d 100644 --- a/database/factories/UserNotificationFactory.php +++ b/database/factories/UserNotificationFactory.php @@ -3,12 +3,24 @@ // 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. +declare(strict_types=1); + +namespace Database\Factories; + +use App\Models\Notification; +use App\Models\User; use App\Models\UserNotification; -$factory->define(UserNotification::class, function (Faker\Generator $faker) { - return [ - 'delivery' => UserNotification::deliveryMask(array_rand(UserNotification::DELIVERY_OFFSETS)), - 'notification_id' => rand(), - 'user_id' => rand(), - ]; -}); +class UserNotificationFactory extends Factory +{ + protected $model = UserNotification::class; + + public function definition(): array + { + return [ + 'delivery' => UserNotification::deliveryMask(array_rand(UserNotification::DELIVERY_OFFSETS)), + 'notification_id' => Notification::factory(), + 'user_id' => User::factory(), + ]; + } +} diff --git a/database/factories/UserRelationFactory.php b/database/factories/UserRelationFactory.php index 687a9b1ec89..9636f5c4437 100644 --- a/database/factories/UserRelationFactory.php +++ b/database/factories/UserRelationFactory.php @@ -2,16 +2,29 @@ // 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. + +declare(strict_types=1); + +namespace Database\Factories; + use App\Models\UserRelation; -$factory->define(UserRelation::class, function (Faker\Generator $faker) { - return []; -}); +class UserRelationFactory extends Factory +{ + protected $model = UserRelation::class; + + public function block(): static + { + return $this->state(['foe' => true]); + } -$factory->state(UserRelation::class, 'friend', function (Faker\Generator $faker) { - return ['friend' => true]; -}); + public function definition(): array + { + return []; + } -$factory->state(UserRelation::class, 'block', function (Faker\Generator $faker) { - return ['foe' => true]; -}); + public function friend(): static + { + return $this->state(['friend' => true]); + } +} diff --git a/database/factories/UserStatistics/FruitsFactory.php b/database/factories/UserStatistics/FruitsFactory.php index 36388cf94f1..bfd04fe3bcf 100644 --- a/database/factories/UserStatistics/FruitsFactory.php +++ b/database/factories/UserStatistics/FruitsFactory.php @@ -7,12 +7,9 @@ namespace Database\Factories\UserStatistics; -use App\Models\UserStatistics\Osu; +use App\Models\UserStatistics\Fruits; class FruitsFactory extends ModelFactory { - protected $model = Osu::class; - - // TODO: remove following line after removing legacy-factories - // fooling legacy-factories' "isLegacyFactory" check: class Hello extends Factory + protected $model = Fruits::class; } diff --git a/database/factories/UserStatistics/ManiaFactory.php b/database/factories/UserStatistics/ManiaFactory.php index bbf8c9e44f2..48a1d39e113 100644 --- a/database/factories/UserStatistics/ManiaFactory.php +++ b/database/factories/UserStatistics/ManiaFactory.php @@ -12,7 +12,4 @@ class ManiaFactory extends ModelFactory { protected $model = Mania::class; - - // TODO: remove following line after removing legacy-factories - // fooling legacy-factories' "isLegacyFactory" check: class Hello extends Factory } diff --git a/database/factories/UserStatistics/ModelFactory.php b/database/factories/UserStatistics/ModelFactory.php index c525e89e279..e3714c3c4e3 100644 --- a/database/factories/UserStatistics/ModelFactory.php +++ b/database/factories/UserStatistics/ModelFactory.php @@ -7,12 +7,61 @@ namespace Database\Factories\UserStatistics; +use App\Models\User; use Database\Factories\Factory; abstract class ModelFactory extends Factory { + private static function randPlaycount(array $attr, float $min, float $max): int + { + return rand((int) ($attr['playcount'] * $min), (int) ($attr['playcount'] * $max)); + } + + private static function getUser(array $attr): ?User + { + return isset($attr['user_id']) + ? ($attr['user_id'] instanceof User + ? $attr['user_id'] + : User::find($attr['user_id']) + ) : null; + } + public function definition(): array { - return []; + return $this->generateStats(); + } + + private function generateStats(): array + { + return [ + 'accuracy_count' => rand(1000, 250000), // 1k to 250k. unsure what field is for + 'accuracy_new' => (float) (rand(850000, 1000000)) / 10000, // 85.0000 - 100.0000 + 'accuracy_total' => rand(1000, 250000), // 1k to 250k. unsure what field is for + 'count100' => rand(10000, 2000000), // 10k to 2mil + 'count300' => rand(10000, 5000000), // 10k to 5mil + 'count50' => rand(10000, 1000000), // 10k to 1mil + 'countMiss' => rand(10000, 1000000), // 10k to 1mil + 'level' => rand(1, 104), + 'max_combo' => rand(500, 4000), + 'playcount' => rand(1000, 250000), // 1k - 250k + 'rank' => rand(1, 500000), + 'rank_score' => (float) rand(1, 15000), + 'rank_score_index' => rand(1, 500000), + 'ranked_score' => (float) rand(500000, 2000000000) * 2, // 500k - 4bil + + 'accuracy' => fn (array $attr) => $attr['accuracy_new'] / 100, + 'country_acronym' => fn (array $attr) => static::getUser($attr)?->country_acronym ?? '', + 'exit_count' => fn (array $attr) => static::randPlaycount($attr, 0.2, 0.3), + 'fail_count' => fn (array $attr) => static::randPlaycount($attr, 0.1, 0.2), + 'total_score' => fn (array $attr) => $attr['ranked_score'] * 1.4, + 'total_seconds_played' => fn (array $attr) => static::randPlaycount($attr, 120 * 0.3, 120 * 0.7), + + // The multipliers should sum up to less than 1 + 'xh_rank_count' => fn (array $attr) => round($attr['playcount'] * 0.0003), + 'x_rank_count' => fn (array $attr) => round($attr['playcount'] * 0.001), + 'sh_rank_count' => fn (array $attr) => round($attr['playcount'] * 0.02), + 's_rank_count' => fn (array $attr) => round($attr['playcount'] * 0.05), + 'a_rank_count' => fn (array $attr) => round($attr['playcount'] * 0.2), + ]; } } diff --git a/database/factories/UserStatistics/OsuFactory.php b/database/factories/UserStatistics/OsuFactory.php index 99c42bc5696..8d40446962f 100644 --- a/database/factories/UserStatistics/OsuFactory.php +++ b/database/factories/UserStatistics/OsuFactory.php @@ -12,7 +12,4 @@ class OsuFactory extends ModelFactory { protected $model = Osu::class; - - // TODO: remove following line after removing legacy-factories - // fooling legacy-factories' "isLegacyFactory" check: class Hello extends Factory } diff --git a/database/factories/UserStatistics/Spotlight/FruitsFactory.php b/database/factories/UserStatistics/Spotlight/FruitsFactory.php new file mode 100644 index 00000000000..f58d7d4428e --- /dev/null +++ b/database/factories/UserStatistics/Spotlight/FruitsFactory.php @@ -0,0 +1,15 @@ +. 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 Database\Factories\UserStatistics\Spotlight; + +use App\Models\UserStatistics\Spotlight\Fruits; + +class FruitsFactory extends ModelFactory +{ + protected $model = Fruits::class; +} diff --git a/database/factories/UserStatistics/Spotlight/ManiaFactory.php b/database/factories/UserStatistics/Spotlight/ManiaFactory.php new file mode 100644 index 00000000000..862609f2f71 --- /dev/null +++ b/database/factories/UserStatistics/Spotlight/ManiaFactory.php @@ -0,0 +1,15 @@ +. 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 Database\Factories\UserStatistics\Spotlight; + +use App\Models\UserStatistics\Spotlight\Mania; + +class ManiaFactory extends ModelFactory +{ + protected $model = Mania::class; +} diff --git a/database/factories/UserStatistics/Spotlight/ModelFactory.php b/database/factories/UserStatistics/Spotlight/ModelFactory.php new file mode 100644 index 00000000000..c364be9dbc0 --- /dev/null +++ b/database/factories/UserStatistics/Spotlight/ModelFactory.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); + +namespace Database\Factories\UserStatistics\Spotlight; + +use Database\Factories\UserStatistics\ModelFactory as BaseFactory; + +abstract class ModelFactory extends BaseFactory +{ + public function definition(): array + { + $definition = parent::definition(); + + unset($definition['accuracy_new']); + unset($definition['rank_score']); + unset($definition['rank_score_index']); + unset($definition['sh_rank_count']); + unset($definition['total_seconds_played']); + unset($definition['xh_rank_count']); + + return $definition; + } +} diff --git a/database/factories/UserStatistics/Spotlight/OsuFactory.php b/database/factories/UserStatistics/Spotlight/OsuFactory.php new file mode 100644 index 00000000000..30089502dbe --- /dev/null +++ b/database/factories/UserStatistics/Spotlight/OsuFactory.php @@ -0,0 +1,15 @@ +. 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 Database\Factories\UserStatistics\Spotlight; + +use App\Models\UserStatistics\Spotlight\Osu; + +class OsuFactory extends ModelFactory +{ + protected $model = Osu::class; +} diff --git a/database/factories/UserStatistics/Spotlight/TaikoFactory.php b/database/factories/UserStatistics/Spotlight/TaikoFactory.php new file mode 100644 index 00000000000..d07a53671d5 --- /dev/null +++ b/database/factories/UserStatistics/Spotlight/TaikoFactory.php @@ -0,0 +1,15 @@ +. 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 Database\Factories\UserStatistics\Spotlight; + +use App\Models\UserStatistics\Spotlight\Taiko; + +class TaikoFactory extends ModelFactory +{ + protected $model = Taiko::class; +} diff --git a/database/factories/UserStatistics/TaikoFactory.php b/database/factories/UserStatistics/TaikoFactory.php index ce6b39ae39b..ac350c4d8a3 100644 --- a/database/factories/UserStatistics/TaikoFactory.php +++ b/database/factories/UserStatistics/TaikoFactory.php @@ -12,7 +12,4 @@ class TaikoFactory extends ModelFactory { protected $model = Taiko::class; - - // TODO: remove following line after removing legacy-factories - // fooling legacy-factories' "isLegacyFactory" check: class Hello extends Factory } diff --git a/database/migrations/2023_03_13_100000_add_tags_edit_to_beatmapset_events.php b/database/migrations/2023_03_13_100000_add_tags_edit_to_beatmapset_events.php new file mode 100644 index 00000000000..8d4db5ac299 --- /dev/null +++ b/database/migrations/2023_03_13_100000_add_tags_edit_to_beatmapset_events.php @@ -0,0 +1,86 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +use Illuminate\Database\Migrations\Migration; + +return new class extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + DB::statement("ALTER TABLE beatmapset_events CHANGE type type ENUM( + 'nominate', + 'qualify', + 'disqualify', + 'approve', + 'rank', + 'kudosu_allow', + 'kudosu_deny', + 'kudosu_gain', + 'kudosu_lost', + 'issue_resolve', + 'issue_reopen', + 'discussion_delete', + 'discussion_restore', + 'discussion_post_delete', + 'discussion_post_restore', + 'kudosu_recalculate', + 'nomination_reset', + 'love', + 'discussion_lock', + 'discussion_unlock', + 'genre_edit', + 'language_edit', + 'remove_from_loved', + 'nsfw_toggle', + 'beatmap_owner_change', + 'nomination_reset_received', + 'offset_edit', + 'tags_edit' + )"); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::statement("ALTER TABLE beatmapset_events CHANGE type type ENUM( + 'nominate', + 'qualify', + 'disqualify', + 'approve', + 'rank', + 'kudosu_allow', + 'kudosu_deny', + 'kudosu_gain', + 'kudosu_lost', + 'issue_resolve', + 'issue_reopen', + 'discussion_delete', + 'discussion_restore', + 'discussion_post_delete', + 'discussion_post_restore', + 'kudosu_recalculate', + 'nomination_reset', + 'love', + 'discussion_lock', + 'discussion_unlock', + 'genre_edit', + 'language_edit', + 'remove_from_loved', + 'nsfw_toggle', + 'beatmap_owner_change', + 'nomination_reset_received', + 'offset_edit' + )"); + } +}; diff --git a/database/migrations/2023_03_22_000000_add_chat_message_to_report_types.php b/database/migrations/2023_03_22_000000_add_chat_message_to_report_types.php new file mode 100644 index 00000000000..2051dc6f706 --- /dev/null +++ b/database/migrations/2023_03_22_000000_add_chat_message_to_report_types.php @@ -0,0 +1,33 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +use Illuminate\Database\Migrations\Migration; + +return new class extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + DB::statement("ALTER TABLE osu_user_reports + MODIFY COLUMN reportable_type + enum('user', 'comment', 'score_best_osu', 'score_best_taiko', 'score_best_fruits', 'score_best_mania', 'beatmapset_discussion_post', 'forum_post', 'beatmapset', 'solo_score', 'message')"); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::statement("ALTER TABLE osu_user_reports + MODIFY COLUMN reportable_type + enum('user', 'comment', 'score_best_osu', 'score_best_taiko', 'score_best_fruits', 'score_best_mania', 'beatmapset_discussion_post', 'forum_post', 'beatmapset', 'solo_score')"); + } +}; diff --git a/database/migrations/2023_04_07_051227_create_user_ircauth.php b/database/migrations/2023_04_07_051227_create_user_ircauth.php new file mode 100644 index 00000000000..e4c6a86617f --- /dev/null +++ b/database/migrations/2023_04_07_051227_create_user_ircauth.php @@ -0,0 +1,29 @@ +. 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::create('osu_user_ircauth', function (Blueprint $table) { + $table->unsignedInteger('user_id')->primary(); + $table->string('token', 50)->nullable(); + $table->timestampTz('timestamp')->nullable()->useCurrent()->useCurrentOnUpdate(); + $table->unique('token', 'token'); + $table->index('timestamp', 'timestamp'); + }); + } + + public function down(): void + { + Schema::dropIfExists('osu_user_ircauth'); + } +}; diff --git a/database/migrations/2023_06_15_090320_add_youtube_id_to_builds.php b/database/migrations/2023_06_15_090320_add_youtube_id_to_builds.php new file mode 100644 index 00000000000..6acb6ccf2ea --- /dev/null +++ b/database/migrations/2023_06_15_090320_add_youtube_id_to_builds.php @@ -0,0 +1,33 @@ +. 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 +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::table('osu_builds', function (Blueprint $table) { + $table->string('youtube_id')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('osu_builds', function (Blueprint $table) { + $table->dropColumn('youtube_id'); + }); + } +}; diff --git a/database/mods.json b/database/mods.json index 3bb66e849c5..ae2f20b68b1 100644 --- a/database/mods.json +++ b/database/mods.json @@ -297,6 +297,10 @@ "Name": "minimum_accuracy", "Type": "number" }, + { + "Name": "accuracy_judge_mode", + "Type": "string" + }, { "Name": "restart", "Type": "boolean" @@ -805,7 +809,9 @@ "Type": "string" } ], - "IncompatibleMods": [], + "IncompatibleMods": [ + "BU" + ], "RequiresConfiguration": false, "UserPlayable": true, "ValidForMultiplayer": true, @@ -903,7 +909,8 @@ "AP", "TR", "WG", - "RP" + "RP", + "BU" ], "RequiresConfiguration": false, "UserPlayable": true, @@ -927,7 +934,8 @@ "AP", "TR", "WG", - "MG" + "MG", + "BU" ], "RequiresConfiguration": false, "UserPlayable": true, @@ -978,6 +986,34 @@ "ValidForMultiplayer": true, "ValidForMultiplayerAsFreeMod": true }, + { + "Acronym": "BU", + "Name": "Bubbles", + "Description": "Don't let their popping distract you!", + "Type": "Fun", + "Settings": [], + "IncompatibleMods": [ + "BR", + "MG", + "RP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForMultiplayerAsFreeMod": true + }, + { + "Acronym": "SY", + "Name": "Synesthesia", + "Description": "Colours hit objects based on the rhythm.", + "Type": "Fun", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForMultiplayerAsFreeMod": true + }, { "Acronym": "TD", "Name": "Touch Device", @@ -1235,6 +1271,10 @@ "Name": "minimum_accuracy", "Type": "number" }, + { + "Name": "accuracy_judge_mode", + "Type": "string" + }, { "Name": "restart", "Type": "boolean" @@ -1329,6 +1369,22 @@ "ValidForMultiplayer": true, "ValidForMultiplayerAsFreeMod": true }, + { + "Acronym": "SG", + "Name": "Single Tap", + "Description": "One key for dons, one key for kats.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "AT", + "CN", + "RX" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForMultiplayerAsFreeMod": true + }, { "Acronym": "AT", "Name": "Autoplay", @@ -1340,6 +1396,7 @@ "SD", "PF", "AC", + "SG", "CN", "RX", "AS" @@ -1360,6 +1417,7 @@ "SD", "PF", "AC", + "SG", "AT", "CN", "RX", @@ -1381,6 +1439,7 @@ "SD", "PF", "AC", + "SG", "AT", "CN" ], @@ -1759,6 +1818,10 @@ "Name": "minimum_accuracy", "Type": "number" }, + { + "Name": "accuracy_judge_mode", + "Type": "string" + }, { "Name": "restart", "Type": "boolean" @@ -2298,6 +2361,10 @@ "Name": "minimum_accuracy", "Type": "number" }, + { + "Name": "accuracy_judge_mode", + "Type": "string" + }, { "Name": "restart", "Type": "boolean" diff --git a/database/seeders/ModelSeeders/BanchoStatsSeeder.php b/database/seeders/ModelSeeders/BanchoStatsSeeder.php index 569ff582bc2..79d3217fabb 100644 --- a/database/seeders/ModelSeeders/BanchoStatsSeeder.php +++ b/database/seeders/ModelSeeders/BanchoStatsSeeder.php @@ -13,20 +13,17 @@ class BanchoStatsSeeder extends Seeder { /** * Run the database seeds. - * - * @return void */ - public function run() + public function run(): void { - $date = new Carbon(); + $timestamp = Carbon::now(); - //Create 500 new data points - factory(BanchoStats::class, 500)->make()->each(function ($stat) use ($date) { - $stat->date = $date; - $stat->save(); + // Create 500 new data points + for ($i = 0; $i < 500; $i++) { + BanchoStats::factory()->create(['date' => $timestamp]); - //Increment the dates by 5 each time - $date->addMinutes(5); - }); + // Increment the timestamp by 5 each time + $timestamp->addMinutes(5); + } } } diff --git a/database/seeders/ModelSeeders/BeatmapSeeder.php b/database/seeders/ModelSeeders/BeatmapSeeder.php index 537f1d416fc..d97d36a33d4 100644 --- a/database/seeders/ModelSeeders/BeatmapSeeder.php +++ b/database/seeders/ModelSeeders/BeatmapSeeder.php @@ -171,8 +171,8 @@ private function createFailtimes($beatmap) BeatmapFailtimes::where('beatmap_id', $beatmap->beatmap_id)->delete(); $beatmap->failtimes()->saveMany([ - factory(BeatmapFailtimes::class)->states('fail')->make(), - factory(BeatmapFailtimes::class)->states('retry')->make(), + BeatmapFailtimes::factory()->fail()->make(), + BeatmapFailtimes::factory()->retry()->make(), ]); } diff --git a/database/seeders/ModelSeeders/ChangelogSeeder.php b/database/seeders/ModelSeeders/ChangelogSeeder.php index 9d872f6529d..a44c6cc228a 100644 --- a/database/seeders/ModelSeeders/ChangelogSeeder.php +++ b/database/seeders/ModelSeeders/ChangelogSeeder.php @@ -37,19 +37,19 @@ public function run() ->merge(Build::factory()->count(5)->create(['stream_id' => $fallback->stream_id])); foreach ($builds as $build) { - factory(Changelog::class, 5)->create([ + Changelog::factory()->count(5)->create([ 'build' => $build->version, 'stream_id' => $build->stream_id, ]); } // create some buildless changes - factory(Changelog::class, 15)->create([ + Changelog::factory()->count(15)->create([ 'build' => null, 'stream_id' => 5, ]); - factory(Changelog::class, 5)->create([ + Changelog::factory()->count(5)->create([ 'build' => null, 'stream_id' => 1, ]); diff --git a/database/seeders/ModelSeeders/MultiplayerSeeder.php b/database/seeders/ModelSeeders/MultiplayerSeeder.php index 988f9ffafd3..89562aea728 100644 --- a/database/seeders/ModelSeeders/MultiplayerSeeder.php +++ b/database/seeders/ModelSeeders/MultiplayerSeeder.php @@ -3,6 +3,8 @@ // 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. +declare(strict_types=1); + namespace Database\Seeders\ModelSeeders; use App\Models\Beatmap; @@ -22,15 +24,14 @@ class MultiplayerSeeder extends Seeder */ public function run() { - $rooms = factory(Room::class, 10)->create(); + $rooms = Room::factory()->count(10)->create(); foreach ($rooms as $room) { $beatmaps = Beatmap::orderByRaw('RAND()')->limit(rand(4, 10))->get(); foreach ($beatmaps as $beatmap) { - $playlistItem = factory(PlaylistItem::class)->create([ + $playlistItem = PlaylistItem::factory()->create([ 'room_id' => $room->getKey(), 'beatmap_id' => $beatmap->getKey(), - 'ruleset_id' => $beatmap->playmode, ]); $users = User::orderByRaw('RAND()')->limit(rand(4, 10))->get(); @@ -38,7 +39,7 @@ public function run() $attempts = rand(1, 10); for ($i = 0; $i < $attempts; $i++) { $completed = rand(0, 100) > 20; - factory(Score::class)->create([ + Score::factory()->create([ 'playlist_item_id' => $playlistItem->getKey(), 'user_id' => $user->getKey(), 'beatmap_id' => $beatmap->getKey(), diff --git a/database/seeders/ModelSeeders/ProductSeeder.php b/database/seeders/ModelSeeders/ProductSeeder.php index 9bf691b633e..f1af6cb5552 100644 --- a/database/seeders/ModelSeeders/ProductSeeder.php +++ b/database/seeders/ModelSeeders/ProductSeeder.php @@ -28,9 +28,9 @@ public function seedProducts() $this->product_ids = []; $this->count = 0; - $master_tshirt = factory(Product::class)->states('master_tshirt')->create(); - $child_shirts = factory(Product::class, 7)->states('child_tshirt')->create([ - 'master_product_id' => $master_tshirt->product_id, + $master_tshirt = Product::factory()->masterTshirt()->create(); + $child_shirts = Product::factory()->count(7)->childTshirt()->create([ + 'master_product_id' => $master_tshirt, ])->each(function ($s) { $this->product_ids[] = $s->product_id; $this->count++; @@ -69,12 +69,12 @@ public function seedBanners() $countries = Country::limit(6)->get()->toArray(); $master_country = array_shift($countries); - $master = factory(Product::class)->states('child_banners')->create([ - 'name' => "{$tournament->name} Support Banner ({$master_country['name']})", + $master = Product::factory()->childBanners()->create([ 'description' => ':)', + 'display_order' => 0, 'header_description' => "# {$tournament->name} Support Banners\nYayifications", + 'name' => "{$tournament->name} Support Banner ({$master_country['name']})", 'promoted' => true, - 'display_order' => 0, ]); $type_mappings_json = [ @@ -85,9 +85,9 @@ public function seedBanners() ]; foreach ($countries as $country) { - $product = factory(Product::class)->states('child_banners')->create([ + $product = Product::factory()->childBanners()->create([ + 'master_product_id' => $master, 'name' => "{$tournament->name} Support Banner ({$country['name']})", - 'master_product_id' => $master->product_id, ]); $type_mappings_json[$product->product_id] = [ diff --git a/database/seeders/ModelSeeders/SpotlightSeeder.php b/database/seeders/ModelSeeders/SpotlightSeeder.php index 3690bb21555..1d395258af4 100644 --- a/database/seeders/ModelSeeders/SpotlightSeeder.php +++ b/database/seeders/ModelSeeders/SpotlightSeeder.php @@ -42,7 +42,7 @@ public function seedMonthly($date) { // note: this does result in spotlights with beatmaps from the future. DB::transaction(function () use ($date) { - $spotlight = factory(Spotlight::class)->states('monthly')->make([ + $spotlight = Spotlight::factory()->monthly()->make([ 'chart_month' => $date, ]); @@ -55,7 +55,7 @@ public function seedMonthly($date) public function seedBestOf($date) { DB::transaction(function () use ($date) { - $spotlight = factory(Spotlight::class)->states('bestof')->make([ + $spotlight = Spotlight::factory()->bestof()->make([ 'chart_month' => $date->copy()->endOfYear(), ]); @@ -68,7 +68,7 @@ public function seedBestOf($date) public function seedNonPeriodic() { DB::transaction(function () { - $spotlight = factory(Spotlight::class)->make(); + $spotlight = Spotlight::factory()->make(); $spotlight->saveOrExplode(); static::seedData($spotlight); @@ -96,7 +96,7 @@ private static function seedData($spotlight) foreach ($users as $user) { // user_stats - $stats = factory(UserStatisticsModel::getClass($ruleset))->make(['user_id' => $user->user_id]); + $stats = UserStatisticsModel::getClass($ruleset)::factory()->make(['user_id' => $user]); $stats->setTable($spotlight->userStatsTableName($ruleset)); $stats->save(); diff --git a/database/seeders/ModelSeeders/UserSeeder.php b/database/seeders/ModelSeeders/UserSeeder.php index 35c01be45de..a3e293d36e5 100644 --- a/database/seeders/ModelSeeders/UserSeeder.php +++ b/database/seeders/ModelSeeders/UserSeeder.php @@ -5,11 +5,12 @@ namespace Database\Seeders\ModelSeeders; +use App\Models\Beatmap; +use App\Models\Country; use App\Models\RankHistory; use App\Models\User; use App\Models\UserAccountHistory; use App\Models\UserStatistics; -use Faker\Factory as Faker; use Illuminate\Database\Seeder; class UserSeeder extends Seeder @@ -21,108 +22,56 @@ class UserSeeder extends Seeder */ public function run() { - // DB::table('phpbb_users')->delete(); - // DB::table('osu_user_stats')->delete(); - // DB::table('osu_user_stats_fruits')->delete(); - // DB::table('osu_user_stats_mania')->delete(); - // DB::table('osu_user_stats_taiko')->delete(); - // DB::table('osu_user_performance_rank')->delete(); - - $this->faker = Faker::create(); - // Store some constants - $this->improvement_speeds = [ - rand(100, 110) / 100, // Fast Learner + $improvementSpeeds = [ rand(100, 102) / 100, // Slow Learner - rand(100, 115) / 100, // Genius / Multiaccounter :P + rand(102, 110) / 100, // Fast Learner + rand(110, 115) / 100, // Genius / Multiaccounter :P ]; - $this->common_countries = ['US', 'JP', 'CN', 'DE', 'TW', 'RU', 'KR', 'PL', 'CA', 'FR', 'BR', 'GB', 'AU']; - - // Create 10 users and their stats - User::factory()->count(10)->create([ - 'osu_subscriber' => 1, - ])->each(function ($u) { - - // USER STATS - $country_code = array_rand_val($this->common_countries); - - $rank0 = rand(1, 500000); - $rank1 = rand(1, 500000); - $rank2 = rand(1, 500000); - $rank3 = rand(1, 500000); - $st = $u->statisticsOsu()->save(factory(UserStatistics\Osu::class)->make(['country_acronym' => $country_code, 'rank' => $rank0, 'rank_score_index' => $rank0])); - $st1 = $u->statisticsOsu()->save(factory(UserStatistics\Taiko::class)->make(['country_acronym' => $country_code, 'rank' => $rank1, 'rank_score_index' => $rank1])); - $st2 = $u->statisticsOsu()->save(factory(UserStatistics\Fruits::class)->make(['country_acronym' => $country_code, 'rank' => $rank2, 'rank_score_index' => $rank2])); - $st3 = $u->statisticsOsu()->save(factory(UserStatistics\Mania::class)->make(['country_acronym' => $country_code, 'rank' => $rank3, 'rank_score_index' => $rank3])); - // END USER STATS - // RANK HISTORY + // Create up to 10 countries for the users + Country::factory()->count(max(10 - Country::count(), 0))->create(); - // Create rank histories for all 4 modes - for ($c = 0; $c <= 3; $c++) { - switch ($c) { - case 0: - $rank = $st->rank; - break; - case 1: - $rank = $st1->rank; - break; - case 2: - $rank = $st2->rank; - break; - case 3: - $rank = $st3->rank; - break; - default: - $rank = $st->rank; - } - - $hist = new RankHistory(); + // Create 10 users and their stats + foreach (User::factory()->count(10)->create(['osu_subscriber' => 1]) as $u) { + // Create statistics and rank histories for all 4 modes + foreach (Beatmap::MODES as $ruleset => $rulesetId) { + $rank = rand(1, 500000); + UserStatistics\Model::getClass($ruleset)::factory()->make([ + 'rank' => $rank, + 'rank_score_index' => $rank, + 'user_id' => $u, + ]); - $hist->mode = $c; // 0 = standard, 1 = taiko etc... + $hist = new RankHistory(['mode' => $rulesetId]); - $play_freq = rand(10, 35); // How regulary the user plays (as a % chance per day) + $playFreq = rand(10, 35); // How regularly the user plays (as a % chance per day) // Start with current rank, and move down (back in time) to r0 $hist->r89 = $rank; - for ($i = 88; $i >= 0; $i--) { $r = 'r'.$i; - $prev_r = 'r'.($i + 1); - $prev_rank = $hist->$prev_r; + $prevR = 'r'.($i + 1); // We wouldn't expect the user to improve every day - $does_improve = $this->faker->boolean($play_freq); - if ($does_improve === true) { - $extreme_improvement = $this->faker->boolean(2); // chance of extreme improvement today - if ($extreme_improvement === true) { - $improvement_modifier = 1.5; - } else { - $improvement_modifier = array_rand_val($this->improvement_speeds); - } - $new_rank = round($hist->$prev_r * $improvement_modifier); - if ($new_rank < 1) { - $new_rank = 1; - } - $hist->$r = $new_rank; + if (rand(1, 100) < $playFreq) { + $rankChange = rand(1, 4) === 1 + // Extreme improvement today + ? 1.5 + : array_rand_val($improvementSpeeds); } else { - $new_rank = round($hist->$prev_r * rand(998, 999) / 1000); - if ($new_rank < 1) { - $new_rank = 1; - } - $hist->$r = $new_rank; // Slight decay + // Slight decay + $rankChange = rand(998, 999) / 1000; } + $hist->$r = max(1, round($hist->$prevR * $rankChange)); } $u->rankHistories()->save($hist); } - // END RANK HISTORY - - // INFRINGEMENTS // silence - $u->accountHistories()->save(UserAccountHistory::factory()->silence()->make()); + UserAccountHistory::factory()->silence()->create(['user_id' => $u]); // note - $u->accountHistories()->save(UserAccountHistory::factory()->note()->make()); - }); // end each user + UserAccountHistory::factory()->note()->create(['user_id' => $u]); + } } } diff --git a/docker-compose.yml b/docker-compose.yml index cc8370b5087..6b87c2ace2f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.4' x-env: &x-env APP_KEY: "${APP_KEY}" - BEATMAPS_DIFFICULTY_CACHE_SERVER_URL: http://beatmap-difficulty-lookup-cache:5000 + BEATMAPS_DIFFICULTY_CACHE_SERVER_URL: http://beatmap-difficulty-lookup-cache BROADCAST_DRIVER: redis CACHE_DRIVER: redis DB_CONNECTION_STRING: Server=db;Database=osu;Uid=osuweb; @@ -160,6 +160,23 @@ services: <<: *x-env SCHEMA: "${SCHEMA:-1}" + score-indexer-test: + image: pppy/osu-elastic-indexer + command: ["queue", "watch", "--force-version"] + depends_on: + redis: + condition: service_healthy + db: + condition: service_healthy + elasticsearch: + condition: service_healthy + environment: + <<: *x-env + # match with .env.testing.example + DB_CONNECTION_STRING: Server=db;Database=osu_test;Uid=osuweb; + ES_INDEX_PREFIX: test_ + SCHEMA: test + volumes: database: elasticsearch: diff --git a/docker/development/entrypoint.sh b/docker/development/entrypoint.sh index 5578ba6e91f..1ea9f86b25f 100755 --- a/docker/development/entrypoint.sh +++ b/docker/development/entrypoint.sh @@ -62,12 +62,19 @@ _test() { fi case "$command" in - browser) _rexec php /app/artisan dusk --verbose "$@";; + browser) _test_browser "$@";; js) _rexec yarn karma start --single-run --browsers ChromeHeadless "$@";; phpunit) _rexec ./bin/phpunit.sh "$@";; esac } +_test_browser() { + export APP_ENV=dusk.local + export OCTANE_STATE_FILE=/app/storage/logs/octane-server-state-dusk.json + _rexec ./bin/run_dusk.sh "$@" +} + + _watch() { _run yarn --network-timeout 100000 _rexec yarn watch diff --git a/package.json b/package.json index 1f315cd8860..f69f4f855f0 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "scripts": { "dev": "yarn run development", "development": "cross-env NODE_ENV=development webpack --progress --hide-modules --config=webpack.config.js", - "lint": "eslint --cache 'resources/js/**/*.{js,ts,tsx}' 'tests/karma/**/*.ts' '*.js'", + "lint": "eslint --cache 'resources/js/**/*.{js,ts,tsx}' 'tests/karma/**/*.{ts,tsx}' '*.js'", "localize": "yarn run generate-localizations", "prod": "yarn run production", "production": "cross-env NODE_ENV=production webpack --hide-modules --config=webpack.config.js", @@ -78,8 +78,11 @@ "react-dom-factories": "^1.0.0", "react-markdown": "^8.0.5", "react-transition-group": "^4.4.2", + "rehype-truncate": "^1.2.2", + "remark-breaks": "^3.0.2", "remark-parse": "^10.0.1", - "shopify-buy": "^2.6.1", + "remark-wiki-link": "^1.0.4", + "shopify-buy": "^2.19.0", "slate": "^0.82.1", "slate-history": "^0.66.0", "slate-react": "^0.83.0", diff --git a/resources/css/base.less b/resources/css/base.less index 7c9585a3261..0837c7d632f 100644 --- a/resources/css/base.less +++ b/resources/css/base.less @@ -94,7 +94,6 @@ iframe { } label { - text-transform: uppercase; font-size: 12px; font-weight: 600; } diff --git a/resources/css/bbcode.less b/resources/css/bbcode.less index 009fc781e63..23efd5652ca 100644 --- a/resources/css/bbcode.less +++ b/resources/css/bbcode.less @@ -108,10 +108,6 @@ color: #000 !important; } - .size-50 { font-size: 50%; } - .size-85 { font-size: 85%; } - .size-150 { font-size: 150%; } - // overrides bootstrap styling for [notice] .well { margin: 0; @@ -131,17 +127,4 @@ &__video-box { max-width: 425px; } - - &__video { - position: relative; - padding-bottom: 75%; - - & iframe { - position: absolute; - left: 0; - right: 0; - width: 100%; - height: 100%; - } - } } diff --git a/resources/css/bem-index.less b/resources/css/bem-index.less index f6e71e98869..3a4014018ba 100644 --- a/resources/css/bem-index.less +++ b/resources/css/bem-index.less @@ -71,7 +71,7 @@ @import "bem/beatmapset-beatmap-picker"; @import "bem/beatmapset-cover"; @import "bem/beatmapset-cover-admin"; -@import "bem/beatmapset-discussion-message"; +@import "bem/beatmapset-discussion-image-link"; @import "bem/beatmapset-discussions-chart"; @import "bem/beatmapset-event"; @import "bem/beatmapset-events"; @@ -136,7 +136,6 @@ @import "bem/discrete-bar"; @import "bem/download-page"; @import "bem/download-page-header"; -@import "bem/download-page-video"; @import "bem/estimate-min-lines"; @import "bem/fake-bold"; @import "bem/fancy-graph"; @@ -188,6 +187,7 @@ @import "bem/header-v4"; @import "bem/icon-dropdown-menu"; @import "bem/icon-stack"; +@import "bem/imagemap"; @import "bem/input-container"; @import "bem/js-accordion"; @import "bem/js-flash-border"; @@ -201,6 +201,7 @@ @import "bem/landing-news"; @import "bem/landing-sitemap"; @import "bem/lazy-load"; +@import "bem/legacy-api-details"; @import "bem/line-chart"; @import "bem/link"; @import "bem/livestream-featured"; @@ -209,7 +210,7 @@ @import "bem/loading-overlay"; @import "bem/login-box"; @import "bem/logo"; -@import "bem/love-beatmap-modal"; +@import "bem/love-beatmap-dialog"; @import "bem/medals-group"; @import "bem/message-length-counter"; @import "bem/mobile-menu"; @@ -270,6 +271,7 @@ @import "bem/pagination-v2"; @import "bem/password-reset"; @import "bem/pill-badge"; +@import "bem/plain-text-preview"; @import "bem/play-button"; @import "bem/play-detail"; @import "bem/popup-menu"; diff --git a/resources/css/bem/bbcode-spoilerbox.less b/resources/css/bem/bbcode-spoilerbox.less index dd6b49ba7ba..93d7073c521 100644 --- a/resources/css/bem/bbcode-spoilerbox.less +++ b/resources/css/bem/bbcode-spoilerbox.less @@ -21,6 +21,8 @@ .link-default(); text-align: left; display: flex; + flex-wrap: wrap; + overflow-wrap: anywhere; font-weight: bold; &:hover { diff --git a/resources/css/bem/beatmap-discussion-nomination.less b/resources/css/bem/beatmap-discussion-nomination.less index 3beaf722e06..aa214fbf44d 100644 --- a/resources/css/bem/beatmap-discussion-nomination.less +++ b/resources/css/bem/beatmap-discussion-nomination.less @@ -14,6 +14,13 @@ --nomination-hybrid-bar-gap: 5px; display: flex; gap: var(--nomination-hybrid-bar-gap); + + margin: 5px 0; + min-height: 5px; + + &--hybrid { + min-height: 20px; + } } &__header { diff --git a/resources/css/bem/download-page-video.less b/resources/css/bem/beatmapset-discussion-image-link.less similarity index 51% rename from resources/css/bem/download-page-video.less rename to resources/css/bem/beatmapset-discussion-image-link.less index 3dc4deb63da..4cbec0b1cc9 100644 --- a/resources/css/bem/download-page-video.less +++ b/resources/css/bem/beatmapset-discussion-image-link.less @@ -1,13 +1,14 @@ // 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. -.download-page-video { - // this trick maintains the video's aspect ratio while resizing - // see: http://alistapart.com/article/creating-intrinsic-ratios-for-video - padding-bottom: 56.25%; /* 16:9 */ +.beatmapset-discussion-image-link { position: relative; + display: inline-flex; + min-width: 1em; + min-height: 1em; - iframe { + &__spinner { .full-size(); + .center-content(); } } diff --git a/resources/css/bem/beatmapset-event.less b/resources/css/bem/beatmapset-event.less index 5e4eee775bf..6a7db833035 100644 --- a/resources/css/bem/beatmapset-event.less +++ b/resources/css/bem/beatmapset-event.less @@ -43,6 +43,7 @@ --bg-nomination-reset-received: @red; --bg-nsfw-toggle: @blue; --bg-offset-edit: @blue; + --bg-tags-edit: @blue; --bg-qualify: @blue; --bg-rank: @green; --bg-remove-from-loved: @red; diff --git a/resources/css/bem/beatmapset-header.less b/resources/css/bem/beatmapset-header.less index ef74bc0d796..1e5879b930f 100644 --- a/resources/css/bem/beatmapset-header.less +++ b/resources/css/bem/beatmapset-header.less @@ -2,29 +2,30 @@ // See the LICENCE file in the repository root for full licence text. .beatmapset-header { - @header-height: 350px; - - .default-box-shadow(); + display: grid; + gap: 20px; + padding: 10px @gutter-beatmapset 0; + min-height: 350px; + position: relative; + + @media @desktop { + padding: 20px @gutter-beatmapset-desktop 0; + grid-template-columns: 1fr @beatmapset-float-box-width; + } &__box { display: flex; flex-direction: column; - padding: 20px 10px 25px; position: relative; &--main { - align-self: stretch; - min-width: 0; - @media @desktop { - flex: 1; + padding-bottom: 25px; } } &--stats { - padding-bottom: 0; - align-self: stretch; justify-content: space-between; } } @@ -39,20 +40,6 @@ margin-top: 10px; } - &__content { - display: flex; - flex-direction: column; - padding: 0 30px; - min-height: @header-height; - position: relative; - - @media @desktop { - align-items: flex-end; - flex-direction: row; - } - - } - &__availability-info { font-size: @font-size--title-small; padding: 10px; @@ -69,6 +56,7 @@ &__cover { --border-radius: 0; .light-header-overlay(); + display: contents; } &__details-text { @@ -110,6 +98,7 @@ &__diff-name { font-size: 17px; font-weight: 600; + overflow-wrap: anywhere; color: hsl(var(--hsl-c1)); diff --git a/resources/css/bem/beatmapset-info.less b/resources/css/bem/beatmapset-info.less index 37799c729c7..5a11b42a2be 100644 --- a/resources/css/bem/beatmapset-info.less +++ b/resources/css/bem/beatmapset-info.less @@ -3,75 +3,39 @@ .beatmapset-info { @_top: beatmapset-info; - @min-height: 220px; - @max-height: 320px; + @min-height: 200px; + @max-height: 300px; @box-margin--top: 15px; - @header-margin--top: 15px; - @header-margin--bottom: 5px; font-size: @font-size--normal; - color: white; - display: flex; - flex-direction: column; + display: grid; + gap: 20px; - padding: 0 30px 0; + padding: 10px @gutter-beatmapset 0; background-color: hsl(var(--hsl-b4)); + -webkit-overflow-scrolling: touch; @media @desktop { - flex-direction: row; - min-height: @min-height; - max-height: @max-height; + padding: 15px @gutter-beatmapset-desktop 0; + grid-template-columns: 1fr 175px @beatmapset-float-box-width; } &__box { - margin: @box-margin--top 10px 0; - flex: none; - - &--description { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - position: relative; - } - - &--meta { - position: relative; - - @media @desktop { - width: 175px; - overflow: hidden; - margin-bottom: 10px; - } - } - - &--success-rate { - @media @desktop { - width: @beatmapset-float-box-width; - } - } - } - - &__description { - -webkit-overflow-scrolling: touch; - overflow-y: auto; - padding-right: 10px; + min-width: 0; + display: flex; + flex-direction: column; + position: relative; + padding-top: 10px; - // to prevent overflow from extending container box @media @desktop { - .full-size(); - } - @media @mobile { + min-height: @min-height; max-height: @max-height; } - } - &__description-container { - min-height: 0; - max-height: @max-height; - flex: 1; - position: relative; + &--success-rate { + padding-top: 0; + } } &__edit-button { @@ -88,13 +52,25 @@ } } - &__half-box { + &__row { display: flex; + flex-direction: column; + padding-top: 5px; + padding-bottom: 10px; + + &--half { + flex-direction: row; + } + + &--value-overflow { + padding-bottom: 0; + min-height: 0; + } } &__half-entry { - flex: 1; - max-width: 50%; + flex: none; + width: 50%; } &__header { @@ -103,10 +79,19 @@ font-weight: bold; font-style: normal; padding: 0; - margin: @header-margin--top 0 @header-margin--bottom; + margin: 0 0 5px; } &__link { overflow-wrap: break-word; } + + &__value-overflow { + min-height: 0; + max-height: @max-height; + flex: 1; + + overflow-y: auto; + padding-bottom: 10px; + } } diff --git a/resources/css/bem/beatmapset-panel.less b/resources/css/bem/beatmapset-panel.less index a5accc5b8e0..844ab060bdf 100644 --- a/resources/css/bem/beatmapset-panel.less +++ b/resources/css/bem/beatmapset-panel.less @@ -55,6 +55,7 @@ border-radius: @border-radius--large; font-size: @font-size--normal; height: var(--panel-height); + min-width: 0; .own-layer(); @media @desktop { diff --git a/resources/css/bem/beatmapset-stats.less b/resources/css/bem/beatmapset-stats.less index 6cd05e5ef59..8d83729642c 100644 --- a/resources/css/bem/beatmapset-stats.less +++ b/resources/css/bem/beatmapset-stats.less @@ -2,13 +2,8 @@ // See the LICENCE file in the repository root for full licence text. .beatmapset-stats { - color: #fff; font-size: 12px; - @media @desktop { - width: @beatmapset-float-box-width; - } - &__elapsed-bar { position: absolute; bottom: 0; diff --git a/resources/css/bem/btn-circle.less b/resources/css/bem/btn-circle.less index e8f29076790..b199427554b 100644 --- a/resources/css/bem/btn-circle.less +++ b/resources/css/bem/btn-circle.less @@ -31,7 +31,6 @@ &--bbcode { .default-text-shadow(); - margin: 2px; font-size: 13px; box-shadow: none; background-color: transparent; diff --git a/resources/css/bem/btn-osu-big.less b/resources/css/bem/btn-osu-big.less index 41d55583599..51b87cbbcbd 100644 --- a/resources/css/bem/btn-osu-big.less +++ b/resources/css/bem/btn-osu-big.less @@ -300,7 +300,6 @@ } &--settings-oauth { - margin: 0 5px 5px 5px; min-width: 70px; } diff --git a/resources/css/bem/build.less b/resources/css/bem/build.less index e0a042aa2d7..9c7bdfb522a 100644 --- a/resources/css/bem/build.less +++ b/resources/css/bem/build.less @@ -47,4 +47,9 @@ opacity: 0.5; } } + + &__video { + margin-top: 20px; + max-width: 100%; + } } diff --git a/resources/css/bem/changelog-md.less b/resources/css/bem/changelog-md.less index e8b175a4e3f..08092e8adbe 100644 --- a/resources/css/bem/changelog-md.less +++ b/resources/css/bem/changelog-md.less @@ -29,4 +29,33 @@ &__list { padding-left: 20px; } + + &__table { + overflow-x: auto; + margin-bottom: 20px; + } + + &__table-data { + border-top: 1px solid hsl(var(--hsl-b3)); + padding: 5px; + min-width: 30px; + + &--center { + text-align: center; + } + + &--header { + border-top: none; + border-bottom: 2px solid hsl(var(--hsl-b2)); + font-weight: bold; + } + + &--left { + text-align: left; + } + + &--right { + text-align: right; + } + } } diff --git a/resources/css/bem/chat-message-item.less b/resources/css/bem/chat-message-item.less index 5be5106555e..cf46a967bae 100644 --- a/resources/css/bem/chat-message-item.less +++ b/resources/css/bem/chat-message-item.less @@ -8,23 +8,6 @@ opacity: 0.2; } - &__content { - font-family: @font-content; - overflow-wrap: anywhere; - - &--action { - color: hsl(var(--hsl-l1)); - font-style: italic; - - &::before { - content: '* '; - } - &::after { - content: ' *'; - } - } - } - &__entry { display: flex; justify-content: space-between; diff --git a/resources/css/bem/comments.less b/resources/css/bem/comments.less index cc5ee42522d..67083f49022 100644 --- a/resources/css/bem/comments.less +++ b/resources/css/bem/comments.less @@ -75,10 +75,6 @@ padding-bottom: @_header-spacing; } - &__text { - .default-gutter-v2(); - } - &__title { .default-gutter-v2(); padding-top: @_header-spacing; diff --git a/resources/css/bem/counter-box.less b/resources/css/bem/counter-box.less index dc0c426056a..a7bc80b2e14 100644 --- a/resources/css/bem/counter-box.less +++ b/resources/css/bem/counter-box.less @@ -55,6 +55,14 @@ flex-direction: column; } + &--ranking { + padding: 0; + background-color: transparent; + border: none; + box-shadow: none; + flex-direction: column; + } + &--deleted { .with-active(@color-deleted); } diff --git a/resources/css/bem/discrete-bar.less b/resources/css/bem/discrete-bar.less index 64aec462ede..562a2c5d9f5 100644 --- a/resources/css/bem/discrete-bar.less +++ b/resources/css/bem/discrete-bar.less @@ -8,16 +8,9 @@ min-height: @bar-size; &--beatmapset-nomination-hybrid { - min-height: 20px; - margin: 5px 0; gap: var(--nomination-hybrid-bar-gap); } - &--beatmapset-hype { - min-height: 5px; - margin: 5px 0; - } - &__item { .center-content(); background: hsl(var(--hsl-b6)); diff --git a/resources/css/bem/form-select.less b/resources/css/bem/form-select.less index ed4b70365f7..9707f24f967 100644 --- a/resources/css/bem/form-select.less +++ b/resources/css/bem/form-select.less @@ -11,6 +11,7 @@ font-weight: initial; line-height: normal; margin: 0; + min-width: 0; display: flex; align-items: baseline; @@ -50,5 +51,6 @@ flex: 1; background-color: inherit; border-radius: inherit; + max-width: 100%; } } diff --git a/resources/css/bem/forum-post.less b/resources/css/bem/forum-post.less index ada15490695..c7db39df2fa 100644 --- a/resources/css/bem/forum-post.less +++ b/resources/css/bem/forum-post.less @@ -62,6 +62,11 @@ padding-right: @gutter-v2-desktop; } + &--blocked { + padding-top: @_padding-content; + font-size: @font-size--title-small; + } + &--footer { color: @osu-colour-c2; } diff --git a/resources/css/bem/grid-items.less b/resources/css/bem/grid-items.less index 8589937fcea..a95141c1a2c 100644 --- a/resources/css/bem/grid-items.less +++ b/resources/css/bem/grid-items.less @@ -2,18 +2,13 @@ // See the LICENCE file in the repository root for full licence text. .grid-items { - --grid-items-gutter: 10px; display: flex; flex-wrap: wrap; - margin: calc(var(--grid-items-gutter) * -1); - - // simulating `gap` property - > * { - margin: var(--grid-items-gutter); - } + margin: 0; + gap: 20px; &--2 { - --grid-items-gutter: 1px; + gap: 2px; } &--fade-out { @@ -21,7 +16,38 @@ } &--notification-banner-buttons { - --grid-items-gutter: 5px; + gap: 10px; justify-content: center; } + + &--ranking-filter { + gap: 10px 20px; + flex-direction: column; + + @media @desktop { + align-items: center; + flex-direction: row; + justify-content: flex-end; + } + } + + &--ranking-info-bar { + width: 100%; + gap: 20px; + + @media @desktop { + align-items: flex-end; + } + } + + &__item { + &--spotlight-user-filter { + width: 100%; + + @media @desktop { + width: auto; + margin-left: auto; + } + } + } } diff --git a/resources/css/bem/imagemap.less b/resources/css/bem/imagemap.less new file mode 100644 index 00000000000..e810549cfde --- /dev/null +++ b/resources/css/bem/imagemap.less @@ -0,0 +1,20 @@ +// 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. + +.imagemap { + position: relative; + overflow: hidden; + max-width: max-content; + + &__image { + width: auto; + height: auto; + object-fit: contain; + max-width: 100%; + } + + &__link { + display: block; + position: absolute; + } +} diff --git a/resources/css/bem/legacy-api-details.less b/resources/css/bem/legacy-api-details.less new file mode 100644 index 00000000000..7e738170980 --- /dev/null +++ b/resources/css/bem/legacy-api-details.less @@ -0,0 +1,39 @@ +// 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. + +.legacy-api-details { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + + &__actions { + display: flex; + gap: 10px; + flex-direction: column; + + @media @desktop { + flex-direction: row; + align-items: baseline; + } + } + + &__content { + overflow-wrap: anywhere; + display: flex; + gap: 10px; + flex-direction: column; + } + + &__entry { + overflow-wrap: anywhere; + } + + &__label { + color: hsl(var(--hsl-c2)); + font-size: @font-size--normal; + } + + &__value { + font-size: @font-size--title-small; + } +} diff --git a/resources/css/bem/love-beatmap-modal.less b/resources/css/bem/love-beatmap-dialog.less similarity index 95% rename from resources/css/bem/love-beatmap-modal.less rename to resources/css/bem/love-beatmap-dialog.less index 2e64ecdb04d..bbd2efcc5d8 100644 --- a/resources/css/bem/love-beatmap-modal.less +++ b/resources/css/bem/love-beatmap-dialog.less @@ -1,7 +1,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. -.love-beatmap-modal { +.love-beatmap-dialog { .page-width-default(); .default-box-shadow(); padding-top: 20px; @@ -36,7 +36,6 @@ } &__diff-mode-title { - font-size: @font-size--title-small; padding-bottom: 5px; margin-bottom: 5px; border-bottom: 1px solid #fff; @@ -45,6 +44,10 @@ background-color: hsl(var(--hsl-b5)); } + &__diff-mode-title-label { + font-size: @font-size--title-small; + } + &__row { .default-gutter-v2(); @@ -82,5 +85,6 @@ font-size: inherit; font-weight: normal; cursor: pointer; + margin: 0; } } diff --git a/resources/css/bem/mp-history-game.less b/resources/css/bem/mp-history-game.less index baa07ba59f4..879754bae2c 100644 --- a/resources/css/bem/mp-history-game.less +++ b/resources/css/bem/mp-history-game.less @@ -203,21 +203,11 @@ } &__results-text { - font-size: 18px; - font-weight: 600; - color: @osu-colour-c1; - - text-align: center; - - padding-right: 5px; + font-size: @font-size--title-small-4; + color: hsl(var(--hsl-c1)); @media @desktop { - font-size: 20px; - } - - &--score { - font-weight: 400; - padding: 0; + font-size: @font-size--large; } } } diff --git a/resources/css/bem/mp-history-player-score.less b/resources/css/bem/mp-history-player-score.less index 14002bee350..7bffcaf8b37 100644 --- a/resources/css/bem/mp-history-player-score.less +++ b/resources/css/bem/mp-history-player-score.less @@ -109,12 +109,6 @@ font-weight: 700; padding-right: 20px; - - display: none; - - @media (min-width: @width) { - display: initial; - } } &__mods { diff --git a/resources/css/bem/news-post-preview.less b/resources/css/bem/news-post-preview.less index 51c4b7fe3fd..52189704bf6 100644 --- a/resources/css/bem/news-post-preview.less +++ b/resources/css/bem/news-post-preview.less @@ -30,7 +30,8 @@ } &__post-date { - flex: 0 0 60px; + flex: none; + width: 70px; border-right: 1px solid @osu-colour-l1; margin: 10px; display: flex; @@ -38,7 +39,6 @@ align-items: flex-end; color: @osu-colour-l1; padding-right: 10px; - overflow: hidden; text-align: right; .@{top}--collapsed & { @@ -46,7 +46,7 @@ justify-content: flex-end; align-items: baseline; margin: 0px 10px; - padding: 5px 10px; + padding: 5px 10px 5px 0; } } diff --git a/resources/css/bem/oauth-client-details.less b/resources/css/bem/oauth-client-details.less index 4f55aa510c7..f0aa95d0dc1 100644 --- a/resources/css/bem/oauth-client-details.less +++ b/resources/css/bem/oauth-client-details.less @@ -36,6 +36,7 @@ } &__group { + .fancy-scrollbar(); display: inline-flex; flex-direction: column; margin: 10px 0; @@ -50,14 +51,15 @@ .reset-input(); .default-border-radius(); - flex: 1; - align-self: stretch; background-color: @osu-colour-b3; border: 2px solid transparent; padding: 5px; + margin-top: 5px; + font-weight: normal; + font-size: @font-size--phone-input; - @media @mobile { - font-size: @font-size--phone-input; + @media @desktop { + font-size: @font-size--title-small; } &:focus { @@ -67,9 +69,17 @@ &--has-error { border-color: @osu-colour-red-1; } + + &--textarea { + min-height: 5em; + max-height: 50vh; + resize: none; + } } &__label { color: @osu-colour-f1; + font-weight: 600; + font-size: @font-size--normal; } } diff --git a/resources/css/bem/oauth-client.less b/resources/css/bem/oauth-client.less index 29989f09f7c..ef78fc8d74c 100644 --- a/resources/css/bem/oauth-client.less +++ b/resources/css/bem/oauth-client.less @@ -3,6 +3,7 @@ .oauth-client { display: flex; + gap: 10px; flex-direction: row; justify-content: space-between; font-size: @font-size--title-small; @@ -10,14 +11,20 @@ &__actions { flex: none; + gap: 5px 10px; + display: flex; + align-items: stretch; + flex-direction: column; - @media @mobile { - display: flex; - flex-direction: column; + @media @desktop { + align-items: baseline; + flex-direction: row; } } &__details { + overflow-wrap: anywhere; + &--button { .reset-input(); text-align: left; diff --git a/resources/css/bem/osu-md.less b/resources/css/bem/osu-md.less index 5841cdbb245..d2e9c5fa3dd 100644 --- a/resources/css/bem/osu-md.less +++ b/resources/css/bem/osu-md.less @@ -35,6 +35,12 @@ min-height: 1em; max-height: 6em; } + + .@{_top}--discussions& { + object-fit: contain; + min-height: 1em; + max-height: 480px; // most landscape images will be limited by width of the discussions area. + } } video { @@ -60,8 +66,6 @@ &--chat { --code-background-colour: hsl(var(--hsl-b3)); flex: 1; - // match the overflow-wrap on chat-message-item__content so that every markdown message - // doesn't need another wrapper container. overflow-wrap: anywhere; h1, h2, h3, h4, h5, h6 { @@ -70,6 +74,29 @@ } } + &--chat-action { + color: hsl(var(--hsl-l1)); + font-style: italic; + + &::before { + content: '* '; + } + &::after { + content: ' *'; + } + } + + &--chat-plain { + p { + display: inline; + } + } + + &--discussions { + font: inherit; + font-size: inherit; + } + &--group { --paragraph-space: 10px; } diff --git a/resources/css/bem/osu-page.less b/resources/css/bem/osu-page.less index a6e47fe9929..ceffd47457c 100644 --- a/resources/css/bem/osu-page.less +++ b/resources/css/bem/osu-page.less @@ -244,6 +244,16 @@ } } + &--ranking-info { + .default-gutter-v2(); + padding-top: 20px; + padding-bottom: 20px; + background-color: hsl(var(--hsl-d3)); + font-size: @font-size--title-small; + display: grid; + gap: 20px; + } + &--small { @media @desktop { .page-width-desktop(@gutter-small-desktop); diff --git a/resources/css/bem/page-mode.less b/resources/css/bem/page-mode.less index b8411f7a3d0..8131d14a013 100644 --- a/resources/css/bem/page-mode.less +++ b/resources/css/bem/page-mode.less @@ -37,13 +37,13 @@ &--profile-page-extra { .default-gutter-v2(); - padding-top: 0; - padding-bottom: 0; - height: 40px; + padding-top: 10px; + padding-bottom: $padding-top; display: flex; align-items: center; gap: 20px; font-size: @font-size--title-small; + overflow-x: auto; } &--ranking { diff --git a/resources/css/bem/beatmapset-discussion-message.less b/resources/css/bem/plain-text-preview.less similarity index 80% rename from resources/css/bem/beatmapset-discussion-message.less rename to resources/css/bem/plain-text-preview.less index 777e20ab80f..bc749923ae3 100644 --- a/resources/css/bem/beatmapset-discussion-message.less +++ b/resources/css/bem/plain-text-preview.less @@ -1,7 +1,6 @@ // 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. -.beatmapset-discussion-message { +.plain-text-preview { display: inline; - color: white; } diff --git a/resources/css/bem/play-detail.less b/resources/css/bem/play-detail.less index 2c01e83634c..08b3b60883e 100644 --- a/resources/css/bem/play-detail.less +++ b/resources/css/bem/play-detail.less @@ -65,11 +65,15 @@ } &__beatmap { + .u-ellipsis-overflow(); color: @yellow-dark; } &__beatmap-and-time { margin-top: 2px; + white-space: nowrap; + display: flex; + gap: 15px; } &__detail { @@ -217,7 +221,7 @@ &__time { color: @osu-colour-f1; - margin-left: 15px; + flex: none; } &__title { diff --git a/resources/css/bem/post-box.less b/resources/css/bem/post-box.less index a54de6da347..a698fb55e48 100644 --- a/resources/css/bem/post-box.less +++ b/resources/css/bem/post-box.less @@ -5,7 +5,7 @@ .post-box-toolbar { display: flex; flex-wrap: wrap; - margin: -2px; + gap: 2px; justify-content: center; &--disabled { @@ -15,4 +15,10 @@ @media @desktop { justify-content: flex-start; } + + &__help { + font-size: @font-size--title-small; + align-self: center; + margin: 0 10px; + } } diff --git a/resources/css/bem/profile-info.less b/resources/css/bem/profile-info.less index 8bcd2677885..17abcfaa181 100644 --- a/resources/css/bem/profile-info.less +++ b/resources/css/bem/profile-info.less @@ -113,7 +113,7 @@ @media @desktop { --icon-height: var(--icon-height-desktop); - margin-top: 0; + margin-top: 5px; } } @@ -216,7 +216,6 @@ &__title { .link-white(); line-height: normal; - margin: 0 0 5px; font-size: @font-size--title-small; @media @desktop { diff --git a/resources/css/bem/ranking-filter.less b/resources/css/bem/ranking-filter.less index b9bba8dd631..e7b6b2aa51f 100644 --- a/resources/css/bem/ranking-filter.less +++ b/resources/css/bem/ranking-filter.less @@ -2,48 +2,34 @@ // See the LICENCE file in the repository root for full licence text. .ranking-filter { - @top: ranking-filter; - --gutter: 10px; + flex: 1; display: flex; - flex-direction: column; - margin: calc(var(--gutter) / 2 * -1) calc(var(--gutter) * -1); + align-items: center; + --title-margin-bottom-mobile: 0; @media @desktop { - flex-direction: row; - align-items: center; + flex: none; + display: block; } - &__item { - padding: calc(var(--gutter) / 2) var(--gutter); - flex: 1; - display: flex; - align-items: center; + &--full { + display: block; + --title-margin-bottom-mobile: 10px; @media @desktop { - flex: none; - display: block; - } - - &--title { - font-weight: bold; - text-transform: uppercase; - font-size: @font-size--normal; - margin-bottom: 10px; - margin-right: 15px; - - .@{top}__item:not(.@{top}__item--full) & { - @media @mobile { - margin-bottom: 0; - } - } + flex: 1; } + } - &--full { - display: block; + &__title { + font-weight: bold; + text-transform: uppercase; + font-size: @font-size--normal; + margin-bottom: 10px; + margin-right: 15px; - @media @desktop { - flex: 1; - } + @media @mobile { + margin-bottom: var(--title-margin-bottom-mobile); } } } diff --git a/resources/css/bem/search-entry.less b/resources/css/bem/search-entry.less index 86369d43071..b5ab6e4f0fa 100644 --- a/resources/css/bem/search-entry.less +++ b/resources/css/bem/search-entry.less @@ -10,6 +10,7 @@ padding: 10px 10px 15px; position: relative; // for entries which link is full-sized child element box-shadow: inset 0 0 0 var(--border-size) var(--border-colour); + min-width: 0; --border-colour: hsl(var(--hsl-l1)); --border-size: 0; diff --git a/resources/css/bem/simple-form.less b/resources/css/bem/simple-form.less index 86bb4f48939..aceb5bb342d 100644 --- a/resources/css/bem/simple-form.less +++ b/resources/css/bem/simple-form.less @@ -204,7 +204,8 @@ } &__row { - flex: 1 0 100%; + flex: 1 0 auto; + width: 100%; display: flex; margin-bottom: 10px; text-transform: none; @@ -225,7 +226,7 @@ } &--half { - flex-basis: 50%; + flex: 50%; } &--title { diff --git a/resources/css/bem/supporter-quote.less b/resources/css/bem/supporter-quote.less index 64329a5aa0f..25757ec9a72 100644 --- a/resources/css/bem/supporter-quote.less +++ b/resources/css/bem/supporter-quote.less @@ -21,6 +21,8 @@ margin: 0; padding: 0; border: none; + // avoid right quote mark from overlapping the content in rare case + position: relative; } &__quote-mark { diff --git a/resources/css/utilities.less b/resources/css/utilities.less index 658c0e47894..e4c641be451 100644 --- a/resources/css/utilities.less +++ b/resources/css/utilities.less @@ -24,6 +24,10 @@ } } +.u-contents { + display: contents; +} + .u-ellipsis-overflow { white-space: nowrap !important; text-overflow: ellipsis !important; @@ -42,6 +46,17 @@ overflow: hidden !important; } +.u-embed-wide { + // this trick maintains the video's aspect ratio while resizing + // see: http://alistapart.com/article/creating-intrinsic-ratios-for-video + padding-bottom: 56.25%; /* 16:9 */ + position: relative; + + > iframe { + .full-size(); + } +} + .u-fancy-scrollbar { .fancy-scrollbar(); } @@ -83,3 +98,7 @@ .u-relative { position: relative; } + +.u-uppercase { + text-transform: uppercase; +} diff --git a/resources/js/artist-tracks-index/main.tsx b/resources/js/artist-tracks-index/main.tsx index 97f0024263d..779c7835145 100644 --- a/resources/js/artist-tracks-index/main.tsx +++ b/resources/js/artist-tracks-index/main.tsx @@ -6,12 +6,11 @@ import ShowMoreLink from 'components/show-more-link'; import TracklistTrack from 'components/tracklist-track'; import { ArtistTrackWithArtistJson } from 'interfaces/artist-track-json'; import { route } from 'laroute'; -import { action, makeObservable, observable, runInAction } from 'mobx'; -import { observer } from 'mobx-react'; +import { action, makeObservable, observable, reaction, runInAction } from 'mobx'; +import { disposeOnUnmount, observer } from 'mobx-react'; import * as React from 'react'; import { onError } from 'utils/ajax'; import { classWithModifiers } from 'utils/css'; -import { jsonClone } from 'utils/json'; import { trans } from 'utils/lang'; import { navigate } from 'utils/turbolinks'; import SearchForm, { ArtistTrackSearch } from './search-form'; @@ -24,9 +23,12 @@ export interface ArtistTracksIndex { } interface Props { - availableGenres: string[]; container: HTMLElement; - data: ArtistTracksIndex; +} + +interface Data { + availableGenres: string[]; + index: ArtistTracksIndex; } const headerLinks = [ @@ -43,7 +45,7 @@ const headerLinks = [ @observer export default class Main extends React.Component { - @observable private data = jsonClone(this.props.data); + @observable private data = JSON.parse(this.props.container.dataset.data ?? '') as Data; @observable private isNavigating = false; @observable private loadingXhr?: JQuery.jqXHR | null = null; @@ -51,6 +53,13 @@ export default class Main extends React.Component { super(props); makeObservable(this); + + disposeOnUnmount(this, reaction( + () => JSON.stringify(this.data), + (newDataString) => { + this.props.container.dataset.data = newDataString; + }, + )); } componentWillUnmount() { @@ -64,14 +73,14 @@ export default class Main extends React.Component {
      - {this.data.artist_tracks.length === 0 ? ( + {this.data.index.artist_tracks.length === 0 ? (
      {trans('artist.tracks.index.form.empty')}
      @@ -79,17 +88,17 @@ export default class Main extends React.Component { <>
      - {this.data.artist_tracks.map((t) => ( + {this.data.index.artist_tracks.map((t) => ( ))} @@ -103,13 +112,11 @@ export default class Main extends React.Component { @action private readonly handleShowMore = () => { - this.loadingXhr = $.getJSON(route('artists.tracks.index'), { ...this.data.search, cursor_string: this.data.cursor_string }); + this.loadingXhr = $.getJSON(route('artists.tracks.index'), { ...this.data.index.search, cursor_string: this.data.index.cursor_string }); - this.loadingXhr.done((newData) => runInAction(() => { - const { container, ...prevProps } = this.props; - newData.artist_tracks = this.data.artist_tracks.concat(newData.artist_tracks); - this.data = newData; - this.props.container.dataset.props = JSON.stringify({ ...prevProps, data: this.data }); + this.loadingXhr.done((newIndex) => runInAction(() => { + newIndex.artist_tracks = this.data.index.artist_tracks.concat(newIndex.artist_tracks); + this.data.index = newIndex; })).fail(onError).always(action(() => { this.loadingXhr = null; })); diff --git a/resources/js/beatmap-discussions-history/main.coffee b/resources/js/beatmap-discussions-history/main.coffee index 8ac0e285cef..240d14d0c93 100644 --- a/resources/js/beatmap-discussions-history/main.coffee +++ b/resources/js/beatmap-discussions-history/main.coffee @@ -33,55 +33,10 @@ export class Main extends React.PureComponent relatedDiscussions: props.relatedDiscussions - componentDidMount: => - $.subscribe "beatmapsetDiscussions:update.#{@eventId}", @discussionUpdate - $(document).on "ajax:success.#{@eventId}", '.js-beatmapset-discussion-update', @ujsDiscussionUpdate - - componentWillUnmount: => - $.unsubscribe ".#{@eventId}" - $(window).off ".#{@eventId}" - $(window).stop() - discussionUpdate: (_e, options) => - {beatmapset} = options - return unless beatmapset? - - discussions = [@state.discussions...] - users = [@state.users...] - relatedDiscussions = [@state.relatedDiscussions...] - - discussionIds = _.map discussions, 'id' - userIds = _.map users, 'id' - - # Due to the entire hierarchy of discussions being sent back when a post is updated (instead of just the modified post), - # we need to iterate over each discussion and their posts to extract the updates we want. - _.each beatmapset.discussions, (newDiscussion) -> - if discussionIds.includes(newDiscussion.id) - discussion = _.find discussions, id: newDiscussion.id - discussions = _.reject discussions, id: newDiscussion.id - newDiscussion = _.merge(discussion, newDiscussion) - # The discussion list shows discussions started by the current user, so it can be assumed that the first post is theirs - newDiscussion.starting_post = newDiscussion.posts[0] - discussions.push(newDiscussion) - else - relatedDiscussions.push(newDiscussion) - - _.each beatmapset.related_users, (newUser) -> - if userIds.includes(newUser.id) - users = _.reject users, id: newUser.id - - users.push(newUser) - - @cache.users = @cache.discussions = @cache.beatmaps = @cache.beatmapsets = @state.relatedDiscussions = null - @setState - discussions: _.reverse(_.sortBy(discussions, (d) -> Date.parse(d.starting_post.created_at))) - users: users - relatedDiscussions: relatedDiscussions - - discussions: => # skipped discussions # - not privileged (deleted discussion) @@ -136,7 +91,7 @@ export class Main extends React.PureComponent currentUser: currentUser beatmapset: beatmapsets[discussion.beatmapset_id] isTimelineVisible: false - visible: false + readonly: true showDeleted: true preview: true @@ -147,8 +102,3 @@ export class Main extends React.PureComponent @cache.users[null] = @cache.users[undefined] = deletedUser.toJson() @cache.users - - - ujsDiscussionUpdate: (_e, data) => - # to allow ajax:complete to be run - Timeout.set 0, => @discussionUpdate(null, beatmapset: data) diff --git a/resources/js/beatmap-discussions/beatmap-owner-editor.tsx b/resources/js/beatmap-discussions/beatmap-owner-editor.tsx index a1884019ae0..e8a9c0a3dae 100644 --- a/resources/js/beatmap-discussions/beatmap-owner-editor.tsx +++ b/resources/js/beatmap-discussions/beatmap-owner-editor.tsx @@ -3,7 +3,7 @@ import { Spinner } from 'components/spinner'; import UserAvatar from 'components/user-avatar'; -import { UserLink } from 'components/user-link'; +import UserLink from 'components/user-link'; import BeatmapJson from 'interfaces/beatmap-json'; import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; import UserJson from 'interfaces/user-json'; @@ -20,8 +20,8 @@ import { trans } from 'utils/lang'; type BeatmapsetWithDiscussionJson = BeatmapsetExtendedJson; interface XhrCollection { - updateOwner?: JQuery.jqXHR; - userLookup?: JQuery.jqXHR; + updateOwner: JQuery.jqXHR; + userLookup: JQuery.jqXHR; } interface Props { @@ -40,7 +40,7 @@ export default class BeatmapOwnerEditor extends React.Component { private shouldFocusInputOnNextRender = false; @observable private updatingOwner = false; private userLookupTimeout?: number; - private xhr: XhrCollection = {}; + private xhr: Partial = {}; @computed private get inputUser() { diff --git a/resources/js/beatmap-discussions/discussion-message.tsx b/resources/js/beatmap-discussions/discussion-message.tsx new file mode 100644 index 00000000000..2a00b4b9aef --- /dev/null +++ b/resources/js/beatmap-discussions/discussion-message.tsx @@ -0,0 +1,40 @@ +// 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. + +import React from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkBreaks from 'remark-breaks'; +import autolink from 'remark-plugins/autolink'; +import disableConstructs, { DisabledType } from 'remark-plugins/disable-constructs'; +import ImageLink from './image-link'; +import { createRenderer, linkRenderer, transformLinkUri } from './renderers'; + +const components = Object.freeze({ + a: linkRenderer, + em: createRenderer('em'), + img: ImageLink, + li: createRenderer('li'), + p: createRenderer('p'), + strong: createRenderer('strong'), +}); + +interface Props { + markdown: string; + type?: DisabledType; +} + +export default class DiscussionMessage extends React.Component { + render() { + return ( + + {this.props.markdown} + + ); + } +} diff --git a/resources/js/beatmap-discussions/discussion.tsx b/resources/js/beatmap-discussions/discussion.tsx index 34e5b186dda..06a17b49d19 100644 --- a/resources/js/beatmap-discussions/discussion.tsx +++ b/resources/js/beatmap-discussions/discussion.tsx @@ -31,6 +31,7 @@ interface PropsBase { currentBeatmap: BeatmapExtendedJson | null; isTimelineVisible: boolean; parentDiscussion?: BeatmapsetDiscussionJson | null; + readonly: boolean; readPostIds?: Set; showDeleted: boolean; users: Partial>; @@ -66,6 +67,7 @@ export class Discussion extends React.Component { static contextType = DiscussionsStateContext; static defaultProps = { preview: false, + readonly: false, }; declare context: React.ContextType; @@ -227,6 +229,7 @@ export class Discussion extends React.Component { discussion={this.props.discussion} post={post} read={this.isRead(post)} + readonly={this.props.readonly} resolvedSystemPostId={this.resolvedSystemPostId} type={type} user={user} diff --git a/resources/js/beatmap-discussions/discussions.tsx b/resources/js/beatmap-discussions/discussions.tsx index 5f55b92db1a..edf76ce5dad 100644 --- a/resources/js/beatmap-discussions/discussions.tsx +++ b/resources/js/beatmap-discussions/discussions.tsx @@ -5,7 +5,7 @@ import IconExpand from 'components/icon-expand'; import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; import BeatmapsetDiscussionJson, { BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; -import { BeatmapsetWithDiscussionsJson } from 'interfaces/beatmapset-json'; +import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import UserJson from 'interfaces/user-json'; import { size } from 'lodash'; import { action, computed, makeObservable, observable } from 'mobx'; diff --git a/resources/js/beatmap-discussions/editor-discussion-component.tsx b/resources/js/beatmap-discussions/editor-discussion-component.tsx index 412f00df1ca..5ea14c126db 100644 --- a/resources/js/beatmap-discussions/editor-discussion-component.tsx +++ b/resources/js/beatmap-discussions/editor-discussion-component.tsx @@ -18,8 +18,11 @@ import { linkHtml } from 'utils/url'; import { DraftsContext } from './drafts-context'; import EditorBeatmapSelector from './editor-beatmap-selector'; import EditorIssueTypeSelector from './editor-issue-type-selector'; +import { postEmbedModifiers } from './review-post-embed'; import { SlateContext } from './slate-context'; +const bn = 'beatmap-discussion-review-post-embed-preview'; + interface Cache { nearbyDiscussions?: { beatmap_id: number; @@ -40,7 +43,6 @@ interface Props extends RenderElementProps { export default class EditorDiscussionComponent extends React.Component { static contextType = SlateContext; - bn = 'beatmap-discussion-review-post-embed-preview'; cache: Cache = {}; declare context: React.ContextType; tooltipContent = React.createRef(); @@ -235,7 +237,7 @@ export default class EditorDiscussionComponent extends React.Component { return (
      { const classMods = canEdit ? [] : ['read-only']; const timestampTooltipType = this.props.element.beatmapId != null ? 'diff' : 'all-diff'; - const timestampTooltip = trans(`beatmaps.discussions.review.embed.timestamp.${timestampTooltipType}`, { + // TODO: remove after translations are updated without the key type: trans(`beatmaps.discussions.message_type.${this.discussionType()}`), }); const deleteButton = ( - )} - - {this.deleteModel.deleted_at == null && canDelete && ( - - {trans('beatmaps.discussions.delete')} - - )} - - {this.deleteModel.deleted_at != null && canModerate && ( - - {trans('beatmaps.discussions.restore')} - - )} - {this.props.type === 'discussion' && this.props.discussion.current_user_attributes?.can_moderate_kudosu && ( - this.props.discussion.can_grant_kudosu - ? this.renderKudosuAction('deny') - : this.props.discussion.kudosu_denied && this.renderKudosuAction('allow') + {!this.props.readonly && ( + <> + {this.canEdit && ( + + )} + + {this.deleteModel.deleted_at == null && this.canDelete && ( + + {trans('beatmaps.discussions.delete')} + + )} + + {this.deleteModel.deleted_at != null && this.canModerate && ( + + {trans('beatmaps.discussions.restore')} + + )} + + {this.props.type === 'discussion' && this.props.discussion.current_user_attributes?.can_moderate_kudosu && ( + this.props.discussion.can_grant_kudosu + ? this.renderKudosuAction('deny') + : this.props.discussion.kudosu_denied && this.renderKudosuAction('allow') + )} + )} {this.canReport && ( diff --git a/resources/js/beatmap-discussions/renderers.tsx b/resources/js/beatmap-discussions/renderers.tsx index b8afb18feec..4265bf39c9d 100644 --- a/resources/js/beatmap-discussions/renderers.tsx +++ b/resources/js/beatmap-discussions/renderers.tsx @@ -3,32 +3,31 @@ import * as React from 'react'; import { uriTransformer } from 'react-markdown'; -import { ReactMarkdownProps } from 'react-markdown/lib/complex-types'; -import { propsFromHref, timestampRegex } from 'utils/beatmapset-discussion-helper'; -import { openBeatmapEditor } from 'utils/url'; +import { propsFromHref, timestampRegexGlobal } from 'utils/beatmapset-discussion-helper'; +import { openBeatmapEditor, safeReactMarkdownUrl } from 'utils/url'; -export const timestampRegexGlobal = new RegExp(timestampRegex, 'g'); +export const LinkContext = React.createContext({ inLink: false }); -export function emphasisRenderer(astProps: ReactMarkdownProps & React.DetailedHTMLProps, HTMLElement>) { - return {astProps.children.map(timestampDecorator)}; +export function createRenderer(ElementType: React.ElementType) { + return function defaultRenderer(astProps: { children: React.ReactNode }) { + return {timestampDecorator(astProps.children)}; + }; } -export function linkRenderer(astProps: ReactMarkdownProps & React.DetailedHTMLProps, HTMLAnchorElement>) { - // TODO: handle extra nodes in astProps.children - const props = propsFromHref(astProps.href ?? ''); - - return ; -} - -export function paragraphRenderer(astProps: ReactMarkdownProps & React.DetailedHTMLProps, HTMLParagraphElement>) { - return
      {astProps.children.map(timestampDecorator)}
      ; -} - -export function strongRenderer(astProps: ReactMarkdownProps & React.DetailedHTMLProps, HTMLElement>) { - return {astProps.children.map(timestampDecorator)}; +export function linkRenderer(astProps: JSX.IntrinsicElements['a']) { + const props = propsFromHref(astProps.href); + const href = safeReactMarkdownUrl(props.href); + + return ( + <> + +
      {props.children ?? astProps.children} + + + ); } -function timestampDecorator(reactNode: React.ReactNode) { +export function timestampDecorator(reactNode: React.ReactNode): React.ReactNode { if (typeof reactNode === 'string') { const matches = [...reactNode.matchAll(timestampRegexGlobal)]; @@ -58,6 +57,8 @@ function timestampDecorator(reactNode: React.ReactNode) { return nodes; } + } else if (Array.isArray(reactNode)) { + return reactNode.map(timestampDecorator); } return reactNode; diff --git a/resources/js/beatmap-discussions/review-document.ts b/resources/js/beatmap-discussions/review-document.ts index d2563157baa..dd1a9a2ecd8 100644 --- a/resources/js/beatmap-discussions/review-document.ts +++ b/resources/js/beatmap-discussions/review-document.ts @@ -43,7 +43,7 @@ export function parseFromJson(json: string, discussions: Partial { @@ -66,10 +66,21 @@ export function parseFromJson(json: string, discussions: Partial 0) { + doc.push({ + children: squashed, + type: 'paragraph', + }); + } else { + doc.push({ + children: [{ + text: '', + }], + type: 'paragraph', + }); + } } break; } diff --git a/resources/js/beatmap-discussions/review-post-embed.tsx b/resources/js/beatmap-discussions/review-post-embed.tsx index 745723fddd2..fd298fdb443 100644 --- a/resources/js/beatmap-discussions/review-post-embed.tsx +++ b/resources/js/beatmap-discussions/review-post-embed.tsx @@ -3,11 +3,13 @@ import { discussionTypeIcons } from 'beatmap-discussions/discussion-type'; import { BeatmapIcon } from 'components/beatmap-icon'; +import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; import * as React from 'react'; -import { format, formatTimestamp, makeUrl, startingPost } from 'utils/beatmapset-discussion-helper'; +import { formatTimestamp, makeUrl, startingPost } from 'utils/beatmapset-discussion-helper'; import { classWithModifiers } from 'utils/css'; import { trans } from 'utils/lang'; import { BeatmapsContext } from './beatmaps-context'; +import DiscussionMessage from './discussion-message'; import { DiscussionsContext } from './discussions-context'; interface Props { @@ -16,6 +18,15 @@ interface Props { }; } +export function postEmbedModifiers(discussion: BeatmapsetDiscussionJson) { + return { + deleted: discussion.deleted_at != null, + 'general-all': discussion.beatmap_id == null, + praise: discussion.message_type === 'praise', + resolved: discussion.resolved && discussion.message_type !== 'praise', + }; +} + export const ReviewPostEmbed = ({ data }: Props) => { const bn = 'beatmap-discussion-review-post-embed-preview'; const discussions = React.useContext(DiscussionsContext); @@ -39,22 +50,6 @@ export const ReviewPostEmbed = ({ data }: Props) => { const beatmap = discussion.beatmap_id == null ? undefined : beatmaps[discussion.beatmap_id]; - const additionalClasses = ['lighter']; - if (discussion.message_type === 'praise') { - additionalClasses.push('praise'); - } else if (discussion.resolved) { - additionalClasses.push('resolved'); - } - - const hasBeatmap = discussion.beatmap_id !== null; - if (!hasBeatmap) { - additionalClasses.push('general-all'); - } - - if (discussion.deleted_at) { - additionalClasses.push('deleted'); - } - const messageTypeIcon = () => { const type = discussion.message_type; @@ -98,7 +93,7 @@ export const ReviewPostEmbed = ({ data }: Props) => { }; return ( -
      +
      @@ -118,7 +113,9 @@ export const ReviewPostEmbed = ({ data }: Props) => {
      -
      +
      + +
      {parentLink()}
      diff --git a/resources/js/beatmap-discussions/review-post.tsx b/resources/js/beatmap-discussions/review-post.tsx index 9458c942234..5335fd94ffa 100644 --- a/resources/js/beatmap-discussions/review-post.tsx +++ b/resources/js/beatmap-discussions/review-post.tsx @@ -2,69 +2,38 @@ // See the LICENCE file in the repository root for full licence text. import { PersistedBeatmapDiscussionReview } from 'interfaces/beatmap-discussion-review'; +import { BeatmapsetDiscussionMessagePostJson } from 'interfaces/beatmapset-discussion-post-json'; import * as React from 'react'; -import ReactMarkdown from 'react-markdown'; -import autolink from 'remark-plugins/autolink'; -import disableConstructs from 'remark-plugins/disable-constructs'; -import { uuid } from 'utils/seq'; -import { emphasisRenderer, linkRenderer, paragraphRenderer, strongRenderer, transformLinkUri } from './renderers'; +import DiscussionMessage from './discussion-message'; import { ReviewPostEmbed } from './review-post-embed'; interface Props { - message: string; + post: BeatmapsetDiscussionMessagePostJson; } export class ReviewPost extends React.Component { - embed(id: number) { - return ( -
      - -
      - ); - } - - paragraph(source: string) { - return ( - - {source} - - ); - } - render() { const docBlocks: JSX.Element[] = []; try { - const doc = JSON.parse(this.props.message) as PersistedBeatmapDiscussionReview; + const doc = JSON.parse(this.props.post.message) as PersistedBeatmapDiscussionReview; - doc.forEach((block) => { + doc.forEach((block, index) => { switch (block.type) { case 'paragraph': { const content = block.text.trim() === '' ? ' ' : block.text; - docBlocks.push(this.paragraph(content)); + docBlocks.push(); break; } case 'embed': if (block.discussion_id) { - docBlocks.push(this.embed(block.discussion_id)); + docBlocks.push(); } break; } }); } catch (e) { - docBlocks.push(
      [error parsing review]
      ); + docBlocks.push(
      [error parsing review]
      ); } return ( diff --git a/resources/js/beatmap-discussions/subscribe.coffee b/resources/js/beatmap-discussions/subscribe.coffee deleted file mode 100644 index ad170a02f19..00000000000 --- a/resources/js/beatmap-discussions/subscribe.coffee +++ /dev/null @@ -1,50 +0,0 @@ -# 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. - -import BigButton from 'components/big-button' -import { route } from 'laroute' -import * as React from 'react' -import { a, button, div, h1, h2, p } from 'react-dom-factories' -import { emitError } from 'utils/ajax' -import { trans } from 'utils/lang' - -el = React.createElement - -export class Subscribe extends React.PureComponent - constructor: (props) -> - super props - - @state = loading: false - - - componentWillUnmount: => - @xhr?.abort() - - - render: => - el BigButton, - disabled: @state.loading - icon: if @isWatching() then 'fas fa-eye-slash' else 'fas fa-eye' - isBusy: @state.loading - modifiers: 'full' - props: - onClick: @toggleWatch - text: trans "common.buttons.watch.to_#{+!@isWatching()}" - - - isWatching: => - @props.beatmapset.current_user_attributes?.is_watching - - - toggleWatch: => - @setState loading: true - - @xhr = $.ajax route('beatmapsets.watches.update', watch: @props.beatmapset.id), - type: if @isWatching() then 'DELETE' else 'PUT' - dataType: 'json' - .done (data) => - $.publish 'beatmapsetDiscussions:update', watching: !@isWatching() - .fail (xhr) => - emitError() xhr - .always => - @setState loading: false diff --git a/resources/js/beatmap-discussions/subscribe.tsx b/resources/js/beatmap-discussions/subscribe.tsx new file mode 100644 index 00000000000..e0ebdc6050e --- /dev/null +++ b/resources/js/beatmap-discussions/subscribe.tsx @@ -0,0 +1,68 @@ +// 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. + +import BigButton from 'components/big-button'; +import BeatmapsetJson from 'interfaces/beatmapset-json'; +import { route } from 'laroute'; +import { action, makeObservable, observable } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { onError } from 'utils/ajax'; +import { trans } from 'utils/lang'; + +interface Props { + beatmapset: BeatmapsetJson; +} + +@observer +export class Subscribe extends React.Component { + @observable private xhr: JQuery.jqXHR | null = null; + + private get busy() { + return this.xhr != null; + } + + private get isWatching() { + return this.props.beatmapset.current_user_attributes?.is_watching ?? false; + } + + constructor(props: Props) { + super(props); + + makeObservable(this); + } + + componentWillUnmount() { + this.xhr?.abort(); + } + + render() { + return ( + + ); + } + + @action + private readonly toggleWatch = () => { + if (this.busy) return; + + this.xhr = $.ajax(route('beatmapsets.watches.update', { watch: this.props.beatmapset.id }), { + dataType: 'json', + type: this.isWatching ? 'DELETE' : 'PUT', + }); + + this.xhr.done(() => { + $.publish('beatmapsetDiscussions:update', { watching: !this.isWatching }); + }) + .fail(onError) + .always(action(() => this.xhr = null)); + }; +} diff --git a/resources/js/beatmap-discussions/user-filter.coffee b/resources/js/beatmap-discussions/user-filter.coffee deleted file mode 100644 index 145b05a647a..00000000000 --- a/resources/js/beatmap-discussions/user-filter.coffee +++ /dev/null @@ -1,64 +0,0 @@ -# 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. - -import mapperGroup from 'beatmap-discussions/mapper-group' -import SelectOptions from 'components/select-options' -import * as React from 'react' -import { a } from 'react-dom-factories' -import { makeUrl, parseUrl } from 'utils/beatmapset-discussion-helper' -import { groupColour } from 'utils/css' -import { trans } from 'utils/lang' - -el = React.createElement - -allUsers = - id: null, - text: trans('beatmap_discussions.user_filter.everyone') - -export class UserFilter extends React.PureComponent - mapUserProperties: (user) -> - groups: user.groups - id: user.id - text: user.username - - - handleChange: (option) => - $.publish 'beatmapsetDiscussions:update', selectedUserId: option.id - - - isOwner: (user) => - user? && user.id == @props.ownerId - - - render: => - options = for own _id, user of @props.users when user.id? - @mapUserProperties(user) - options.unshift(allUsers) - - selected = if @props.selectedUser? - @mapUserProperties(@props.selectedUser) - else - id: null, text: trans('beatmap_discussions.user_filter.label') - - el SelectOptions, - modifiers: 'beatmap-discussions-user-filter' - renderOption: @renderOption - onChange: @handleChange - options: options - selected: selected - - - renderOption: ({ cssClasses, children, onClick, option }) => - group = if @isOwner(option) then mapperGroup else option.groups?[0] - style = groupColour(group) - - urlOptions = parseUrl(null) - urlOptions.user = option?.id - - a - className: cssClasses - href: makeUrl(urlOptions) - key: option?.id - onClick: onClick - style: style - children diff --git a/resources/js/beatmap-discussions/user-filter.tsx b/resources/js/beatmap-discussions/user-filter.tsx new file mode 100644 index 00000000000..8922d35b5ca --- /dev/null +++ b/resources/js/beatmap-discussions/user-filter.tsx @@ -0,0 +1,95 @@ +// 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. + +import mapperGroup from 'beatmap-discussions/mapper-group'; +import SelectOptions, { OptionRenderProps } from 'components/select-options'; +import UserJson from 'interfaces/user-json'; +import * as React from 'react'; +import { makeUrl, parseUrl } from 'utils/beatmapset-discussion-helper'; +import { groupColour } from 'utils/css'; +import { trans } from 'utils/lang'; + +const allUsers = Object.freeze({ + id: null, + text: trans('beatmap_discussions.user_filter.everyone'), +}); + +const noSelection = Object.freeze({ + id: null, + text: trans('beatmap_discussions.user_filter.label'), +}); + +interface Option { + groups: UserJson['groups']; + id: UserJson['id']; + text: UserJson['username']; +} + +interface Props { + ownerId: number; + selectedUser?: UserJson | null; + users: UserJson[]; +} + +function mapUserProperties(user: UserJson): Option { + return { + groups: user.groups, + id: user.id, + text: user.username, + }; +} + +export class UserFilter extends React.Component { + private get selected() { + return this.props.selectedUser != null + ? mapUserProperties(this.props.selectedUser) + : noSelection; + } + + private get options() { + return [allUsers, ...this.props.users.map(mapUserProperties)]; + } + + render() { + return ( + + ); + } + + private readonly handleChange = (option: Option) => { + $.publish('beatmapsetDiscussions:update', { selectedUserId: option.id }); + }; + + private isOwner(user?: Option) { + return user != null && user.id === this.props.ownerId; + } + + private readonly renderOption = ({ cssClasses, children, onClick, option }: OptionRenderProps
      ); diff --git a/resources/js/beatmapsets-show/header.tsx b/resources/js/beatmapsets-show/header.tsx index 55b43f39a21..52720af94bb 100644 --- a/resources/js/beatmapsets-show/header.tsx +++ b/resources/js/beatmapsets-show/header.tsx @@ -6,7 +6,7 @@ import BeatmapsetCover from 'components/beatmapset-cover'; import BeatmapsetMapping from 'components/beatmapset-mapping'; import BigButton from 'components/big-button'; import StringWithComponent from 'components/string-with-component'; -import { UserLink } from 'components/user-link'; +import UserLink from 'components/user-link'; import UserListPopup, { createTooltip } from 'components/user-list-popup'; import { route } from 'laroute'; import { action, autorun, computed, makeObservable, observable } from 'mobx'; @@ -92,117 +92,115 @@ export default class Header extends React.Component { return (
      -
      -
      - -
      +
      + +
      -
      -
      - +
      +
      + - {this.renderBeatmapVersion()} + {this.renderBeatmapVersion()} -
      - - - {formatNumber(this.controller.beatmapset.play_count)} - +
      + + + {formatNumber(this.controller.beatmapset.play_count)} + - {this.controller.beatmapset.status === 'pending' && - - - - {formatNumber(this.controller.beatmapset.nominations_summary.current)} - - - } - - 0 })} - onMouseOver={this.onEnterFavouriteIcon} - onTouchStart={this.onEnterFavouriteIcon} - > - - - + {this.controller.beatmapset.status === 'pending' && + + - {formatNumber(this.controller.beatmapset.favourite_count)} + {formatNumber(this.controller.beatmapset.nominations_summary.current)} -
      -
      + } - - 0 })} + onMouseOver={this.onEnterFavouriteIcon} + onTouchStart={this.onEnterFavouriteIcon} > - {getTitle(this.controller.beatmapset)} - - - - + + + + + {formatNumber(this.controller.beatmapset.favourite_count)} + + +
      +
      - - - {getArtist(this.controller.beatmapset)} - - + + {getTitle(this.controller.beatmapset)} + + + + + + + + {getArtist(this.controller.beatmapset)} + + + + + + + {this.renderAvailabilityInfo()} + +
      + {core.currentUser != null && + - - - - - {this.renderAvailabilityInfo()} - -
      - {core.currentUser != null && - - } + } - {this.renderDownloadButtons()} - {this.renderLoginButton()} + {this.renderDownloadButtons()} + {this.renderLoginButton()} - {!this.controller.beatmapset.is_scoreable && core.currentUser != null && core.currentUser.id !== this.controller.beatmapset.user_id && -
      -
      - -
      + {!this.controller.beatmapset.is_scoreable && core.currentUser != null && core.currentUser.id !== this.controller.beatmapset.user_id && +
      +
      +
      - } -
      +
      + }
      +
      -
      - {this.renderStatusBar()} +
      + {this.renderStatusBar()} - -
      +
      ); diff --git a/resources/js/beatmapsets-show/info.tsx b/resources/js/beatmapsets-show/info.tsx index b696662e66f..331e98c31a1 100644 --- a/resources/js/beatmapsets-show/info.tsx +++ b/resources/js/beatmapsets-show/info.tsx @@ -4,7 +4,7 @@ import Bar from 'components/bar'; import BbcodeEditor, { OnChangeProps } from 'components/bbcode-editor'; import Modal from 'components/modal'; -import { UserLink } from 'components/user-link'; +import UserLink from 'components/user-link'; import { BeatmapsetJsonForShow } from 'interfaces/beatmapset-extended-json'; import UserJson from 'interfaces/user-json'; import { route } from 'laroute'; @@ -25,6 +25,7 @@ interface Props { @observer export default class Info extends React.Component { + private descriptionEditorRef = React.createRef(); @observable private isEditingDescription = false; @observable private isEditingMetadata = false; @observable private saveDescriptionXhr: JQuery.jqXHR | null = null; @@ -85,23 +86,18 @@ export default class Info extends React.Component { render() { const tags = this.controller.beatmapset.tags .split(' ') - .filter(present) - .slice(0, 21); - - const tagsOverload = tags.length === 21; - - if (tagsOverload) { - tags.pop(); - } + .filter(present); return ( -
      +
      {this.isEditingDescription &&
      { } -
      +
      {this.withEditDescription && this.renderEditDescriptionButton()} -

      - {trans('beatmapsets.show.info.description')} -

      +
      +

      + {trans('beatmapsets.show.info.description')} +

      -
      {
      -
      +
      {this.withEditMetadata && this.renderEditMetadataButton()} {this.nominators.length > 0 && - <> +

      {trans('beatmapsets.show.info.nominators')}

      @@ -152,11 +148,11 @@ export default class Info extends React.Component { ))}
      - +
      } {present(this.controller.beatmapset.source) && - <> +

      {trans('beatmapsets.show.info.source')}

      @@ -166,10 +162,10 @@ export default class Info extends React.Component { > {this.controller.beatmapset.source} - +
      } -
      +

      {trans('beatmapsets.show.info.genre')} @@ -196,11 +192,11 @@ export default class Info extends React.Component {

      {tags.length > 0 && - <> +

      {trans('beatmapsets.show.info.tags')}

      - }
      @@ -227,7 +222,11 @@ export default class Info extends React.Component { @action private readonly handleCloseDescriptionEditor = () => { - this.isEditingDescription = false; + if (this.descriptionEditorRef.current == null) { + this.isEditingDescription = false; + } else { + this.descriptionEditorRef.current.cancel(); + } }; @action diff --git a/resources/js/beatmapsets-show/main.tsx b/resources/js/beatmapsets-show/main.tsx index 9c1c7f87fcf..cda384079e0 100644 --- a/resources/js/beatmapsets-show/main.tsx +++ b/resources/js/beatmapsets-show/main.tsx @@ -1,7 +1,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. -import { Comments } from 'components/comments'; +import Comments from 'components/comments'; import { CommentsManager } from 'components/comments-manager'; import HeaderV4 from 'components/header-v4'; import NotificationBanner from 'components/notification-banner'; diff --git a/resources/js/beatmapsets-show/metadata-editor.tsx b/resources/js/beatmapsets-show/metadata-editor.tsx index 943f480f692..cd4312fe8d7 100644 --- a/resources/js/beatmapsets-show/metadata-editor.tsx +++ b/resources/js/beatmapsets-show/metadata-editor.tsx @@ -35,6 +35,7 @@ export default class MetadataEditor extends React.Component { @observable private languageId: number; @observable private nsfw: boolean; @observable private offset: string; + @observable private tags: string; @observable private xhr: JQuery.jqXHR | null = null; private get controller() { @@ -45,6 +46,10 @@ export default class MetadataEditor extends React.Component { return this.controller.beatmapset.current_user_attributes.can_edit_offset; } + private get canEditTags() { + return this.controller.beatmapset.current_user_attributes.can_edit_tags; + } + constructor(props: Props) { super(props); @@ -53,12 +58,14 @@ export default class MetadataEditor extends React.Component { languageId: this.controller.beatmapset.language.id ?? 0, nsfw: this.controller.beatmapset.nsfw ?? false, offset: this.controller.beatmapset.offset.toString(), + tags: this.controller.beatmapset.tags, })); this.genreId = initialState.genreId; this.languageId = initialState.languageId; this.nsfw = initialState.nsfw; this.offset = initialState.offset; + this.tags = initialState.tags; makeObservable(this); } @@ -114,6 +121,22 @@ export default class MetadataEditor extends React.Component {
      + {this.canEditTags && +