diff --git a/app/Http/Controllers/TeamsController.php b/app/Http/Controllers/TeamsController.php index 92b6de5f8f8..597b88af59e 100644 --- a/app/Http/Controllers/TeamsController.php +++ b/app/Http/Controllers/TeamsController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers; +use App\Exceptions\ModelNotSavedException; use App\Models\Team; use App\Transformers\UserCompactTransformer; use Symfony\Component\HttpFoundation\Response; @@ -19,6 +20,19 @@ public function __construct() $this->middleware('auth', ['only' => ['part']]); } + public function create(): Response + { + $currentUser = \Auth::user(); + $teamId = $currentUser?->team?->getKey() ?? $currentUser?->teamApplication?->team_id; + if ($teamId !== null) { + return ujs_redirect(route('teams.show', $teamId)); + } + + return ext_view('teams.create', [ + 'team' => new Team(), + ]); + } + public function destroy(string $id): Response { $team = Team::findOrFail($id); @@ -60,6 +74,27 @@ public function show(string $id): Response return ext_view('teams.show', compact('team')); } + public function store(): Reponse + { + priv_check('TeamStore')->ensureCan(); + + $params = get_params(\Request::all(), 'team', [ + 'name', + 'short_name', + ]); + + $team = new Team([...$params, 'leader_id' => \Auth::user()->getKey()]); + try { + $team->saveOrExplode(); + } catch (ModelNotSavedException) { + return ext_view('teams.create', compact('team'), status: 422); + } + + \Session::flash('popup', osu_trans('teams.store.saved')); + + return ujs_redirect(route('teams.show', $team)); + } + public function update(string $id): Response { $team = Team::findOrFail($id); diff --git a/app/Libraries/UsernameValidation.php b/app/Libraries/UsernameValidation.php index 9321bb01114..fc6a7099537 100644 --- a/app/Libraries/UsernameValidation.php +++ b/app/Libraries/UsernameValidation.php @@ -17,6 +17,17 @@ class UsernameValidation { + public static function allowedName(string $username): bool + { + foreach (model_pluck(DB::table('phpbb_disallow'), 'disallow_username') as $check) { + if (preg_match('#^'.str_replace('%', '.*?', preg_quote($check, '#')).'$#i', $username)) { + return false; + } + } + + return true; + } + public static function validateAvailability(string $username): ValidationErrors { $errors = new ValidationErrors('user'); @@ -72,11 +83,8 @@ public static function validateUsername($username) $errors->add('username', '.username_no_space_userscore_mix'); } - foreach (model_pluck(DB::table('phpbb_disallow'), 'disallow_username') as $check) { - if (preg_match('#^'.str_replace('%', '.*?', preg_quote($check, '#')).'$#i', $username)) { - $errors->add('username', '.username_not_allowed'); - break; - } + if (!static::allowedName($username)) { + $errors->add('username', '.username_not_allowed'); } return $errors; diff --git a/app/Models/Team.php b/app/Models/Team.php index 33437a2774f..44e73ee86ee 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -9,6 +9,7 @@ use App\Libraries\BBCodeForDB; use App\Libraries\Uploader; +use App\Libraries\UsernameValidation; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -106,6 +107,20 @@ public function isValid(): bool { $this->validationErrors()->reset(); + $wordFilters = app('chat-filters'); + foreach (['name', 'short_name'] as $field) { + $value = presence($this->$field); + if ($value === null) { + $this->validationErrors()->add($field, 'required'); + } elseif ($this->isDirty($field)) { + if (!$wordFilters->isClean($value) || !UsernameValidation::allowedName($value)) { + $this->validationErrors()->add($field, '.word_not_allowed'); + } elseif (static::whereNot('id', $this->getKey())->where($field, $value)->exists()) { + $this->validationErrors()->add($field, '.used'); + } + } + } + if ($this->isDirty('url')) { $url = $this->url; if ($url !== null && !is_http($url)) { @@ -131,4 +146,18 @@ public function logo(): Uploader ['image' => ['maxDimensions' => [512, 256]]], ); } + + public function save(array $options = []) + { + if (!$this->isValid()) { + return false; + } + + return parent::save($options); + } + + public function validationErrorsTranslationPrefix(): string + { + return 'team'; + } } diff --git a/app/Singletons/ChatFilters.php b/app/Singletons/ChatFilters.php index f6501df61c9..415d4e645ee 100644 --- a/app/Singletons/ChatFilters.php +++ b/app/Singletons/ChatFilters.php @@ -29,6 +29,30 @@ private static function combinedFilterRegex($filters): string return "/{$regex}/iu"; } + public function isClean(string $text): bool + { + $filters = $this->filterRegexps(); + + foreach ($filters['non_whitespace_delimited_replaces'] as $search => $_replacement) { + if (stripos($text, $search) !== false) { + return false; + } + } + + $patterns = [ + $filters['block_regex'] ?? null, + ...array_keys($filters['whitespace_delimited_replaces']), + ]; + + foreach ($patterns as $pattern) { + if ($pattern !== null && preg_match($pattern, $text)) { + return false; + } + } + + return true; + } + /** * Applies all active chat filters to the provided text. * @param string $text The text to filter. @@ -38,7 +62,27 @@ private static function combinedFilterRegex($filters): string */ public function filter(string $text): string { - $filters = $this->memoize(__FUNCTION__, function () { + $filters = $this->filterRegexps(); + + if (isset($filters['block_regex']) && preg_match($filters['block_regex'], $text)) { + throw new ContentModerationException(); + } + + $text = str_ireplace( + array_keys($filters['non_whitespace_delimited_replaces']), + array_values($filters['non_whitespace_delimited_replaces']), + $text + ); + return preg_replace( + array_keys($filters['whitespace_delimited_replaces']), + array_values($filters['whitespace_delimited_replaces']), + $text + ); + } + + private function filterRegexps(): array + { + return $this->memoize(__FUNCTION__, function () { $ret = []; $allFilters = ChatFilter::all(); @@ -63,20 +107,5 @@ public function filter(string $text): string return $ret; }); - - if (isset($filters['block_regex']) && preg_match($filters['block_regex'], $text)) { - throw new ContentModerationException(); - } - - $text = str_ireplace( - array_keys($filters['non_whitespace_delimited_replaces']), - array_values($filters['non_whitespace_delimited_replaces']), - $text - ); - return preg_replace( - array_keys($filters['whitespace_delimited_replaces']), - array_values($filters['whitespace_delimited_replaces']), - $text - ); } } diff --git a/app/Singletons/OsuAuthorize.php b/app/Singletons/OsuAuthorize.php index de5e189746b..84bac5d3874 100644 --- a/app/Singletons/OsuAuthorize.php +++ b/app/Singletons/OsuAuthorize.php @@ -1921,6 +1921,23 @@ public function checkTeamPart(?User $user, Team $team): ?string return 'ok'; } + public function checkTeamStore(?User $user): ?string + { + $this->ensureLoggedIn($user); + $this->ensureCleanRecord($user); + $this->ensureHasPlayed($user); + + if ($user->team !== null) { + return 'team.store.in_team'; + } + + if ($user->teamApplication !== null) { + return 'team.store.applying'; + } + + return 'ok'; + } + public function checkTeamUpdate(?User $user, Team $team): ?string { $this->ensureLoggedIn($user); diff --git a/resources/css/bem/team-settings.less b/resources/css/bem/team-settings.less index 72f29b9edaa..6bb8f1c5be1 100644 --- a/resources/css/bem/team-settings.less +++ b/resources/css/bem/team-settings.less @@ -21,6 +21,12 @@ border-radius: @border-radius-large; } + &__errors { + color: hsl(var(--hsl-red-1)); + margin-top: 1em; + padding: 0 0 0 2em; + } + &__help { color: hsl(var(--hsl-c2)); font-size: @font-size--normal; @@ -34,6 +40,7 @@ } &__item { + --align-items-desktop: start; --grid-rows: none; --grid-rows-desktop: none; --grid-columns: 1fr; @@ -46,6 +53,7 @@ @media @desktop { --grid-columns: var(--grid-columns-desktop); --grid-rows: var(--grid-rows-desktop); + align-items: var(--align-items-desktop); } &--buttons { @@ -53,6 +61,7 @@ } &--description { + --align-items-desktop: stretch; --grid-columns-desktop: 1fr 1fr; --grid-rows: calc(var(--vh, 1vh) * 70) auto; } diff --git a/resources/lang/en/model_validation.php b/resources/lang/en/model_validation.php index 44fdaa89a2c..f43f402b0f0 100644 --- a/resources/lang/en/model_validation.php +++ b/resources/lang/en/model_validation.php @@ -131,6 +131,16 @@ ], ], + 'team' => [ + 'used' => 'This :attribute choice is already used.', + 'word_not_allowed' => 'This :attribute choice is not allowed.', + + 'attributes' => [ + 'name' => 'name', + 'short_name' => 'short name', + ], + ], + 'user' => [ 'contains_username' => 'Password may not contain username.', 'email_already_used' => 'Email address already used.', diff --git a/resources/lang/en/page_title.php b/resources/lang/en/page_title.php index 7533545d6c5..fa1304f4eaf 100644 --- a/resources/lang/en/page_title.php +++ b/resources/lang/en/page_title.php @@ -109,6 +109,7 @@ ], 'teams_controller' => [ '_' => 'teams', + 'create' => 'create team', 'show' => 'team info', ], 'tournaments_controller' => [ diff --git a/resources/lang/en/teams.php b/resources/lang/en/teams.php index a3984e14dff..4999ec1bf57 100644 --- a/resources/lang/en/teams.php +++ b/resources/lang/en/teams.php @@ -4,6 +4,23 @@ // See the LICENCE file in the repository root for full licence text. return [ + 'create' => [ + 'submit' => 'Create Team', + + 'form' => [ + 'name' => 'Team Name', + 'name_help' => 'Your team name. No changey', + 'short_name' => 'Short Name', + 'short_name_help' => 'What', + 'title' => "Let's set up a new team", + ], + + 'intro' => [ + 'description' => "Play together with friends; existing or new. You're not currently in a team. Join an existing team by visiting their team page or create your own team from this page.", + 'title' => 'Team!', + ], + ], + 'destroy' => [ 'ok' => 'Team removed', ], diff --git a/resources/views/teams/create.blade.php b/resources/views/teams/create.blade.php new file mode 100644 index 00000000000..7c3797f5025 --- /dev/null +++ b/resources/views/teams/create.blade.php @@ -0,0 +1,110 @@ +{{-- + 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. +--}} +@php + $errors = $team->validationErrors()->all(); + + $autofocus = count($errors) === 0 + ? 'name' + : (isset($errors['name']) + ? 'name' + : 'short_name' + ); +@endphp +@extends('master') + +@section('content') + @include('layout._page_header_v4') + +
+ +
+@endsection diff --git a/routes/web.php b/routes/web.php index 4e86b47085f..44cca9dc928 100644 --- a/routes/web.php +++ b/routes/web.php @@ -300,7 +300,7 @@ Route::post('part', 'TeamsController@part')->name('part'); Route::resource('members', 'Teams\MembersController', ['only' => ['destroy', 'index']]); }); - Route::resource('teams', 'TeamsController', ['only' => ['destroy', 'edit', 'show', 'update']]); + Route::resource('teams', 'TeamsController', ['only' => ['create', 'destroy', 'edit', 'store', 'show', 'update']]); Route::post('users/check-username-availability', 'UsersController@checkUsernameAvailability')->name('users.check-username-availability'); Route::get('users/lookup', 'Users\LookupController@index')->name('users.lookup'); diff --git a/tests/Models/TeamTest.php b/tests/Models/TeamTest.php index f15d0ef6999..a90935f6ac2 100644 --- a/tests/Models/TeamTest.php +++ b/tests/Models/TeamTest.php @@ -14,6 +14,14 @@ class TeamTest extends TestCase { + public static function dataProviderForTestUniquenessValidation(): array + { + return [ + ['name'], + ['short_name'], + ]; + } + public function testDelete(): void { $team = Team::factory()->create(); @@ -29,4 +37,18 @@ public function testDelete(): void $this->assertNotNull($otherTeam->fresh()); } + + /** + * @dataProvider dataProviderForTestUniquenessValidation + */ + public function testUniquenessValidation(string $field): void + { + $existingTeam = Team::factory()->create(); + + $this->expectCountChange(fn () => Team::count(), 0); + + $team = Team::factory()->make([$field => $existingTeam->$field]); + $team->save(); + $this->assertFalse($team->validationErrors()->isEmpty()); + } }