From 85120e68244a3938542a2292b38f2f4b06eb6e29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Parient=C3=A9?= <41431456+arthurpar06@users.noreply.github.com> Date: Fri, 22 Dec 2023 21:49:11 +0100 Subject: [PATCH 01/14] Login with discord (#1717) * Add UserOAuthToken * Move DISCORD_OAUTH_ENABLED to config * Fix UserOAuthTokens migration * Update OAuthController and oauth routes * Frontend updates * Update oauth routes * Prevent from unlinking only login method * Apply fixes from StyleCI --------- Co-authored-by: StyleCI Bot --- ..._154815_create_user_oauth_tokens_table.php | 36 ++++ app/Http/Controllers/Auth/OAuthController.php | 156 +++++++++++++----- app/Models/User.php | 6 + app/Models/UserOAuthToken.php | 48 ++++++ app/Providers/RouteServiceProvider.php | 13 +- config/services.php | 3 +- resources/lang/de/auth.php | 1 + resources/lang/en/auth.php | 1 + resources/lang/es-es/auth.php | 1 + resources/lang/fr/auth.php | 1 + resources/lang/it/auth.php | 1 + resources/lang/pt-br/auth.php | 1 + .../layouts/default/auth/login.blade.php | 5 + .../layouts/default/auth/register.blade.php | 6 + .../layouts/default/profile/index.blade.php | 8 +- 15 files changed, 236 insertions(+), 51 deletions(-) create mode 100644 app/Database/migrations/2023_12_15_154815_create_user_oauth_tokens_table.php create mode 100644 app/Models/UserOAuthToken.php diff --git a/app/Database/migrations/2023_12_15_154815_create_user_oauth_tokens_table.php b/app/Database/migrations/2023_12_15_154815_create_user_oauth_tokens_table.php new file mode 100644 index 000000000..436759ace --- /dev/null +++ b/app/Database/migrations/2023_12_15_154815_create_user_oauth_tokens_table.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedInteger('user_id'); + $table->string('provider'); + $table->string('token'); + $table->string('refresh_token'); + $table->dateTime('last_refreshed_at')->nullable(); + $table->timestamps(); + + $table->foreign('user_id') + ->references('id') + ->on('users') + ->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_oauth_tokens'); + } +}; diff --git a/app/Http/Controllers/Auth/OAuthController.php b/app/Http/Controllers/Auth/OAuthController.php index 3f61a0b05..0b3b78fb5 100644 --- a/app/Http/Controllers/Auth/OAuthController.php +++ b/app/Http/Controllers/Auth/OAuthController.php @@ -3,65 +3,143 @@ namespace App\Http\Controllers\Auth; use App\Contracts\Controller; -use GuzzleHttp\Client; +use App\Models\Airline; +use App\Models\Airport; +use App\Models\User; +use App\Models\UserOAuthToken; +use App\Services\UserService; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Log; use Laravel\Socialite\Facades\Socialite; class OAuthController extends Controller { - public function redirectToDiscordProvider(): RedirectResponse - { - return Socialite::driver('discord')->scopes(['identify'])->redirect(); + public function __construct( + private readonly UserService $userSvc + ) { } - public function handleDiscordProviderCallback(): RedirectResponse + public function redirectToProvider(string $provider): RedirectResponse { - $user = Socialite::driver('discord')->user(); - - if ($user->getId()) { - // Let's retrieve the private_channel_id - if (!is_null(config('services.discord.token'))) { - try { - $httpClient = new Client(); - $response = $httpClient->request('POST', 'https://discord.com/api/users/@me/channels', [ - 'headers' => [ - 'Authorization' => 'Bot '.config('services.discord.token'), - ], - 'json' => [ - 'recipient_id' => $user->getId(), - ], - ]); - - $privateChannel = json_decode($response->getBody()->getContents(), true)['id']; - } catch (\Exception $e) { - Log::error('Discord OAuth Error: '.$e->getMessage()); - $privateChannel = null; + if (!config('services.'.$provider.'.enabled', false)) { + abort(404); + } + + // Using a switch statement since we might need different scopes according to the provider + switch ($provider) { + case 'discord': + if (!config('services.discord.enabled')) { + abort(404); } - } + return Socialite::driver('discord')->scopes(['identify'])->redirect(); + default: + abort(404); + } + } + + public function handleProviderCallback(string $provider): RedirectResponse + { + $providerUser = null; + + if (!config('services.'.$provider.'.enabled', false)) { + abort(404); + } + + switch ($provider) { + case 'discord': + $providerUser = Socialite::driver('discord')->user(); + break; + default: + abort(404); + } + + if (!$providerUser) { + flash()->error('Provider '.$provider.' not found'); + return redirect(url('/login')); + } + + // If a user is logged in we want to link the account + if (Auth::check()) { + $user = Auth::user(); + + $user->update([ + $provider.'_id' => $providerUser->getId(), + ]); + + $tokens = UserOAuthToken::updateOrCreate([ + 'user_id' => $user->id, + 'provider' => $provider, + ], [ + 'token' => $providerUser->token, + 'refresh_token' => $providerUser->refreshToken, + 'last_refreshed_at' => now(), + ]); + + flash()->success(ucfirst($provider).' account linked!'); + + return redirect(route('frontend.profile.index')); + } + + $user = User::where($provider.'_id', $providerUser->getId())->first(); - Auth::user()?->update([ - 'discord_id' => $user->getId(), - 'discord_private_channel_id' => $privateChannel ?? null, + if ($user) { + $tokens = UserOAuthToken::updateOrCreate([ + 'user_id' => $user->id, + 'provider' => $provider, + ], [ + 'token' => $providerUser->token, + 'refresh_token' => $providerUser->refreshToken, + 'last_refreshed_at' => now(), ]); - flash()->success('Discord account linked!'); - } else { - flash()->error('Unable to link Discord account!'); + Auth::login($user); + + return redirect(route('frontend.dashboard.index')); } - return redirect()->route('frontend.profile.index'); + $attrs = [ + 'name' => $providerUser->getName(), + 'email' => $providerUser->getEmail(), + 'avatar' => $providerUser->getAvatar(), + 'airline_id' => Airline::select('id')->first()->id, + 'home_airport_id' => Airport::select('id')->where('hub', true)->first()->id, + $provider.'_id' => $providerUser->getId(), + ]; + + $user = $this->userSvc->createUser($attrs); + + UserOAuthToken::create([ + 'user_id' => $user->id, + 'provider' => $provider, + 'token' => $providerUser->token, + 'refresh_token' => $providerUser->refreshToken, + 'last_refreshed_at' => now(), + ]); + + Auth::login($user); + + return redirect(route('frontend.profile.edit', ['profile' => $user->id])); } - public function logoutDiscordProvider(): RedirectResponse + public function logoutProvider(string $provider): RedirectResponse { - Auth::user()?->update([ - 'discord_id' => null, - 'discord_private_channel_id' => null, + if (!config('services.'.$provider.'.enabled', false)) { + abort(404); + } + + $user = Auth::user(); + $otherProviders = UserOAuthToken::where('user_id', $user->id)->where('provider', '!=', $provider)->count(); + + if (empty($user->password) && $otherProviders === 0) { + flash()->error('You cannot unlink your only login method!'); + return redirect()->route('frontend.profile.index'); + } + + $user->update([ + $provider.'_id' => null, ]); - flash()->success('Discord account unlinked!'); + flash()->success(ucfirst($provider).' account unlinked!'); return redirect()->route('frontend.profile.index'); } diff --git a/app/Models/User.php b/app/Models/User.php index 87c1f0245..6995ed2de 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -53,6 +53,7 @@ * @property string last_pirep_id * @property Pirep last_pirep * @property UserFieldValue[] fields + * @property UserOAuthToken[] oauth_tokens * @property Role[] roles * @property Subfleet[] subfleets * @property TypeRating[] typeratings @@ -342,6 +343,11 @@ public function fields(): HasMany return $this->hasMany(UserFieldValue::class, 'user_id'); } + public function oauth_tokens(): HasMany + { + return $this->hasMany(UserOAuthToken::class, 'user_id'); + } + public function pireps(): HasMany { return $this->hasMany(Pirep::class, 'user_id'); diff --git a/app/Models/UserOAuthToken.php b/app/Models/UserOAuthToken.php new file mode 100644 index 000000000..0e5ab56ea --- /dev/null +++ b/app/Models/UserOAuthToken.php @@ -0,0 +1,48 @@ + 'integer', + 'provider' => 'string', + 'token' => 'string', + 'refresh_token' => 'string', + 'last_refreshed_at' => 'datetime', + ]; + + public static $rules = [ + 'user_id' => 'required|integer', + 'provider' => 'required|string', + 'token' => 'required|string', + 'refresh_token' => 'required|string', + 'last_refreshed_at' => 'nullable|datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 49023def6..3ac7f7c47 100755 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -172,14 +172,13 @@ private function mapWebRoutes() }); Route::group([ - 'namespace' => 'Auth', - 'prefix' => 'auth', - 'as' => 'auth.', - 'middleware' => 'auth', + 'namespace' => 'Auth', + 'prefix' => 'oauth', + 'as' => 'oauth.', ], function () { - Route::get('discord/redirect', 'OAuthController@redirectToDiscordProvider')->name('discord.redirect'); - Route::get('discord/callback', 'OAuthController@handleDiscordProviderCallback')->name('discord.callback'); - Route::get('discord/logout', 'OAuthController@logoutDiscordProvider')->name('discord.logout'); + Route::get('{provider}/redirect', 'OAuthController@redirectToProvider')->name('redirect'); + Route::get('{provider}/callback', 'OAuthController@handleProviderCallback')->name('callback'); + Route::get('{provider}/logout', 'OAuthController@logoutProvider')->name('logout')->middleware('auth'); }); Route::get('/logout', 'Auth\LoginController@logout')->name('auth.logout'); diff --git a/config/services.php b/config/services.php index 15d8458ae..562a99a1e 100755 --- a/config/services.php +++ b/config/services.php @@ -30,9 +30,10 @@ ], 'discord' => [ + 'enabled' => env('DISCORD_OAUTH_ENABLED', false), 'client_id' => env('DISCORD_CLIENT_ID'), 'client_secret' => env('DISCORD_CLIENT_SECRET'), - 'redirect' => '/auth/discord/callback', + 'redirect' => '/oauth/discord/callback', // optional 'token' => env('DISCORD_BOT_TOKEN', null), diff --git a/resources/lang/de/auth.php b/resources/lang/de/auth.php index 9e96c65bc..1f46b07c4 100644 --- a/resources/lang/de/auth.php +++ b/resources/lang/de/auth.php @@ -21,4 +21,5 @@ 'accountsuspended' => 'Account gesperrt', 'suspendedmessage' => 'Dein Konto wurde gesperrt. Bitte kontaktiere einen Administrator.', 'transferhours' => 'Transferstunden', + 'loginwith' => 'Einloggen mit :provider', ]; diff --git a/resources/lang/en/auth.php b/resources/lang/en/auth.php index 71d9cf9f6..f52d1e6dc 100755 --- a/resources/lang/en/auth.php +++ b/resources/lang/en/auth.php @@ -21,4 +21,5 @@ 'accountsuspended' => 'Account Suspended', 'suspendedmessage' => 'Your account has been suspended. Please contact an administrator.', 'transferhours' => 'Transfer Hours', + 'loginwith' => 'Login With :provider', ]; diff --git a/resources/lang/es-es/auth.php b/resources/lang/es-es/auth.php index 1c7cd5914..e9661a3a0 100644 --- a/resources/lang/es-es/auth.php +++ b/resources/lang/es-es/auth.php @@ -33,5 +33,6 @@ 'accountsuspended' => 'Cuenta suspendida', 'suspendedmessage' => 'Tu cuenta ha sido suspendida. Por favor, contacta con un administrador.', 'transferhours' => 'Transferir horas', + 'loginwith' => 'Iniciar sesión con :provider', ]; diff --git a/resources/lang/fr/auth.php b/resources/lang/fr/auth.php index 8d18037e7..63db964ff 100644 --- a/resources/lang/fr/auth.php +++ b/resources/lang/fr/auth.php @@ -21,4 +21,5 @@ 'accountsuspended' => 'Compte suspendu', 'suspendedmessage' => 'Votre compte a été suspendu. Veuillez contacter un administrateur.', 'transferhours' => 'Heures transférées', + 'loginwith' => 'Se connecter avec :provider', ]; diff --git a/resources/lang/it/auth.php b/resources/lang/it/auth.php index 1799fccc3..cf44a4f75 100644 --- a/resources/lang/it/auth.php +++ b/resources/lang/it/auth.php @@ -21,4 +21,5 @@ 'accountsuspended' => 'Account Sospeso', 'suspendedmessage' => 'Il tuo account è stato sospeso. Contatta un amministratore per favore.', 'transferhours' => 'Ore di trasferimento', + 'loginwith' => 'Accesso con :provider', ]; diff --git a/resources/lang/pt-br/auth.php b/resources/lang/pt-br/auth.php index 8e0192ca4..0c7cf5d70 100755 --- a/resources/lang/pt-br/auth.php +++ b/resources/lang/pt-br/auth.php @@ -21,4 +21,5 @@ 'accountsuspended' => 'Conta Suspensa', 'suspendedmessage' => 'A sua conta foi suspensa. Entre em contato com um administrador.', 'transferhours' => 'Horas Transferidas', + 'loginwith' => 'Entrar com :provider', ]; diff --git a/resources/views/layouts/default/auth/login.blade.php b/resources/views/layouts/default/auth/login.blade.php index 634129770..7918c9398 100644 --- a/resources/views/layouts/default/auth/login.blade.php +++ b/resources/views/layouts/default/auth/login.blade.php @@ -56,6 +56,11 @@
diff --git a/resources/views/layouts/default/auth/register.blade.php b/resources/views/layouts/default/auth/register.blade.php index 2b95cace8..5567741bd 100644 --- a/resources/views/layouts/default/auth/register.blade.php +++ b/resources/views/layouts/default/auth/register.blade.php @@ -139,6 +139,12 @@
+ @if(config('services.discord.enabled')) + + @lang('auth.loginwith', ['provider' => 'Discord']) + + @endif + {{ Form::submit(__('auth.register'), [ 'id' => 'register_button', 'class' => 'btn btn-primary', diff --git a/resources/views/layouts/default/profile/index.blade.php b/resources/views/layouts/default/profile/index.blade.php index fd5ec094c..e480650d7 100644 --- a/resources/views/layouts/default/profile/index.blade.php +++ b/resources/views/layouts/default/profile/index.blade.php @@ -140,10 +140,10 @@   @endif - @if(env('DISCORD_OAUTH_ENABLED', false) && !$user->discord_id) - Link Discord Account - @elseif(env('DISCORD_OAUTH_ENABLED', false)) - Unlink Discord Account + @if(config('services.discord.enabled') && !$user->discord_id) + Link Discord Account + @elseif(config('services.discord.enabled')) + Unlink Discord Account @endif Date: Sat, 23 Dec 2023 14:45:17 +0100 Subject: [PATCH 02/14] Remove DB contraints in Discord OAuth (#1732) --- .../2023_12_15_154815_create_user_oauth_tokens_table.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/Database/migrations/2023_12_15_154815_create_user_oauth_tokens_table.php b/app/Database/migrations/2023_12_15_154815_create_user_oauth_tokens_table.php index 436759ace..251c5e6da 100644 --- a/app/Database/migrations/2023_12_15_154815_create_user_oauth_tokens_table.php +++ b/app/Database/migrations/2023_12_15_154815_create_user_oauth_tokens_table.php @@ -18,11 +18,6 @@ public function up(): void $table->string('refresh_token'); $table->dateTime('last_refreshed_at')->nullable(); $table->timestamps(); - - $table->foreign('user_id') - ->references('id') - ->on('users') - ->cascadeOnDelete(); }); } From cb0a1d8395506190bd348d23c5efca3b9c509982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Parient=C3=A9?= <41431456+arthurpar06@users.noreply.github.com> Date: Sat, 23 Dec 2023 20:11:04 +0100 Subject: [PATCH 03/14] Disable installer when phpvms is installed (#1733) --- app/Http/Middleware/InstalledCheck.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/Http/Middleware/InstalledCheck.php b/app/Http/Middleware/InstalledCheck.php index 4bc920ad4..45faa762f 100644 --- a/app/Http/Middleware/InstalledCheck.php +++ b/app/Http/Middleware/InstalledCheck.php @@ -27,6 +27,12 @@ public function handle(Request $request, Closure $next) return response(view('system.errors.not_installed')); } + if (!empty($key) && $key !== 'base64:zdgcDqu9PM8uGWCtMxd74ZqdGJIrnw812oRMmwDF6KY=' + && $request->is(['install', 'install/*']) + ) { + return response(view('system.installer.errors.already-installed')); + } + return $next($request); } } From 6b9658a4e37e193e00e5b1f4b1084130a64aaf1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Parient=C3=A9?= <41431456+arthurpar06@users.noreply.github.com> Date: Sun, 24 Dec 2023 15:12:52 +0100 Subject: [PATCH 04/14] Add test for PirepDiversionHandler (#1736) * Add test for PirepDiversionHandler * Apply fixes from StyleCI --------- Co-authored-by: StyleCI Bot --- app/Services/PirepService.php | 2 +- tests/PIREPTest.php | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/app/Services/PirepService.php b/app/Services/PirepService.php index 672d39242..972ed78fd 100644 --- a/app/Services/PirepService.php +++ b/app/Services/PirepService.php @@ -805,7 +805,7 @@ public function handleDiversion(Pirep $pirep): void } if (setting('notifications.discord_pirep_diverted', false)) { - Notification::send([$user->id], new PirepDiverted($pirep)); + Notification::send([$pirep], new PirepDiverted($pirep)); } // Update aircraft position diff --git a/tests/PIREPTest.php b/tests/PIREPTest.php index f134f47d5..856bc6f65 100644 --- a/tests/PIREPTest.php +++ b/tests/PIREPTest.php @@ -4,8 +4,10 @@ use App\Models\Acars; use App\Models\Aircraft; +use App\Models\Airport; use App\Models\Bid; use App\Models\Enums\AcarsType; +use App\Models\Enums\PirepFieldSource; use App\Models\Enums\PirepState; use App\Models\Enums\UserState; use App\Models\Flight; @@ -13,6 +15,7 @@ use App\Models\Pirep; use App\Models\Rank; use App\Models\User; +use App\Notifications\Messages\Broadcast\PirepDiverted; use App\Notifications\Messages\Broadcast\PirepPrefiled; use App\Notifications\Messages\Broadcast\PirepStatusChanged; use App\Notifications\Messages\PirepAccepted; @@ -648,4 +651,54 @@ public function testNotificationFormatting() $this->assertEquals('1h 0m', $fields['Flight Time']); $this->assertEquals('185.2 km', $fields['Distance']); } + + public function testDiversionHandler() + { + $this->updateSetting('pireps.handle_diversion', true); + $this->updateSetting('notifications.discord_pirep_diverted', true); + + /** @var User $user */ + $user = User::factory()->create(); + + /** @var Airport $originalArrivalAirport */ + $originalArrivalAirport = Airport::factory()->create(); + + /** @var Airport $diversionAirport */ + $diversionAirport = Airport::factory()->create(); + + /** @var Aircraft $aircraft */ + $aircraft = Aircraft::factory()->create(); + + /** @var Pirep $pirep */ + $pirep = Pirep::factory()->create([ + 'user_id' => $user->id, + 'aircraft_id' => $aircraft->id, + 'arr_airport_id' => $originalArrivalAirport->id, + ]); + + $this->pirepSvc->create($pirep, [ + [ + 'name' => 'Diversion Airport', + 'value' => $diversionAirport->id, + 'source' => PirepFieldSource::ACARS, + ], + ]); + + $this->pirepSvc->submit($pirep); + + $pirep = Pirep::find($pirep->id); + $this->assertEquals($diversionAirport->id, $pirep->arr_airport_id); + $this->assertEquals($originalArrivalAirport->id, $pirep->alt_airport_id); + $this->assertStringContainsString('DIVERTED FROM '.$originalArrivalAirport->id.' TO '.$diversionAirport->id, $pirep->notes); + $this->assertNull($pirep->flight_id); + $this->assertNull($pirep->route_leg); + + $user->refresh(); + $aircraft->refresh(); + + $this->assertEquals($diversionAirport->id, $user->curr_airport_id); + $this->assertEquals($diversionAirport->id, $aircraft->airport_id); + + Notification::assertSentTo([$pirep], PirepDiverted::class); + } } From ceb0bf2b34a88e3aec2b1dedc18de004fd72f2bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Parient=C3=A9?= <41431456+arthurpar06@users.noreply.github.com> Date: Thu, 11 Jan 2024 01:13:41 +0100 Subject: [PATCH 05/14] Fix InstalledCheck Middleware (hotfix) (#1737) * Fix InstalledCheck Middleware * Apply fixes from StyleCI * Fix typo * Apply fixes from StyleCI * Update already installed view --------- Co-authored-by: StyleCI Bot --- app/Http/Controllers/Frontend/PirepController.php | 2 +- app/Http/Middleware/InstalledCheck.php | 6 +++--- .../system/installer/errors/already-installed.blade.php | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/Frontend/PirepController.php b/app/Http/Controllers/Frontend/PirepController.php index 80020e109..804e1c827 100644 --- a/app/Http/Controllers/Frontend/PirepController.php +++ b/app/Http/Controllers/Frontend/PirepController.php @@ -317,7 +317,7 @@ public function create(Request $request): View $aircraft->subfleet->fares = collect($fares); } - // TODO: Set more fields from the Simbrief to the PIREP form + // TODO: Set more fields from the Simbrief to the PIREP form } else { $aircraft_list = $this->aircraftList(true); } diff --git a/app/Http/Middleware/InstalledCheck.php b/app/Http/Middleware/InstalledCheck.php index 45faa762f..62b4b1a71 100644 --- a/app/Http/Middleware/InstalledCheck.php +++ b/app/Http/Middleware/InstalledCheck.php @@ -6,8 +6,10 @@ namespace App\Http\Middleware; use App\Contracts\Middleware; +use App\Models\User; use Closure; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Schema; /** * Check the app.key to see whether we're installed or not @@ -27,9 +29,7 @@ public function handle(Request $request, Closure $next) return response(view('system.errors.not_installed')); } - if (!empty($key) && $key !== 'base64:zdgcDqu9PM8uGWCtMxd74ZqdGJIrnw812oRMmwDF6KY=' - && $request->is(['install', 'install/*']) - ) { + if (!empty($key) && $key !== 'base64:zdgcDqu9PM8uGWCtMxd74ZqdGJIrnw812oRMmwDF6KY=' && $request->is(['install', 'install/*']) && Schema::hasTable('users') && User::count() > 0) { return response(view('system.installer.errors.already-installed')); } diff --git a/resources/views/system/installer/errors/already-installed.blade.php b/resources/views/system/installer/errors/already-installed.blade.php index 6bfb57f8b..268056340 100644 --- a/resources/views/system/installer/errors/already-installed.blade.php +++ b/resources/views/system/installer/errors/already-installed.blade.php @@ -2,7 +2,7 @@ @section('content')

phpVMS already installed!

-

phpVMS has already been installed! Please remove the modules/Installer folder.

+

phpVMS has already been installed! You can use it right now.

{{ Form::open(['url' => '/', 'method' => 'get']) }}

{{ Form::submit('Go to your site >>', ['class' => 'btn btn-success']) }} From 3ca4664d84be9a6aea918c983b222f5d03640304 Mon Sep 17 00:00:00 2001 From: "B.Fatih KOZ" Date: Thu, 11 Jan 2024 03:43:19 +0300 Subject: [PATCH 06/14] Unset image if empty (#1747) Co-authored-by: Nabeel S --- app/Notifications/Channels/Discord/DiscordMessage.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/Notifications/Channels/Discord/DiscordMessage.php b/app/Notifications/Channels/Discord/DiscordMessage.php index 18c24440e..dc824b236 100644 --- a/app/Notifications/Channels/Discord/DiscordMessage.php +++ b/app/Notifications/Channels/Discord/DiscordMessage.php @@ -149,6 +149,10 @@ public function toArray(): array 'timestamp' => Carbon::now('UTC'), ]; + if (empty($embeds['image'])) { + unset($embeds['image']); + } + if (!empty($this->fields)) { $embeds['fields'] = $this->fields; } From ec870d854e1d150e9fe487cb72a8a8111421f81f Mon Sep 17 00:00:00 2001 From: "B.Fatih KOZ" Date: Thu, 11 Jan 2024 18:03:48 +0300 Subject: [PATCH 07/14] Disable toDiscordChannel() (#1742) * Update NewsAdded.php * StyleCI Fix * Remove DiscordWebhook class Still testing * Another StlyCI Fix * Remove discord stuff --- app/Notifications/Messages/NewsAdded.php | 27 +----------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/app/Notifications/Messages/NewsAdded.php b/app/Notifications/Messages/NewsAdded.php index 62716930b..c5a5cef75 100644 --- a/app/Notifications/Messages/NewsAdded.php +++ b/app/Notifications/Messages/NewsAdded.php @@ -4,8 +4,6 @@ use App\Contracts\Notification; use App\Models\News; -use App\Notifications\Channels\Discord\DiscordMessage; -use App\Notifications\Channels\Discord\DiscordWebhook; use App\Notifications\Channels\MailChannel; use Illuminate\Contracts\Queue\ShouldQueue; @@ -29,30 +27,7 @@ public function __construct( public function via($notifiable) { - return ['mail', DiscordWebhook::class]; - } - - /** - * @param News $news - * - * @return DiscordMessage|null - */ - public function toDiscordChannel($news): ?DiscordMessage - { - if (empty(setting('notifications.discord_public_webhook_url'))) { - return null; - } - - $dm = new DiscordMessage(); - return $dm->webhook(setting('notifications.discord_public_webhook_url')) - ->success() - ->title('News: '.$news->subject) - ->author([ - 'name' => $news->user->ident.' - '.$news->user->name_private, - 'url' => '', - 'icon_url' => $news->user->resolveAvatarUrl(), - ]) - ->description($news->body); + return ['mail']; } /** From cc620811c076af8dd33554dc7f868f7331d0520a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Parient=C3=A9?= <41431456+arthurpar06@users.noreply.github.com> Date: Tue, 23 Jan 2024 23:39:00 +0100 Subject: [PATCH 08/14] Fix backups scheduler (#1751) * Apply fixes from StyleCI * Fix Backups Scheduler --------- Co-authored-by: StyleCI Bot --- app/Console/Cron.php | 6 +++++ app/Console/Cron/Backups/BackupClean.php | 28 ++++++++++++++++++++++ app/Console/Cron/Backups/BackupMonitor.php | 28 ++++++++++++++++++++++ app/Console/Cron/Backups/BackupRun.php | 28 ++++++++++++++++++++++ app/Console/Kernel.php | 9 ++++--- 5 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 app/Console/Cron/Backups/BackupClean.php create mode 100644 app/Console/Cron/Backups/BackupMonitor.php create mode 100644 app/Console/Cron/Backups/BackupRun.php diff --git a/app/Console/Cron.php b/app/Console/Cron.php index 5808ba963..069eb754c 100644 --- a/app/Console/Cron.php +++ b/app/Console/Cron.php @@ -5,6 +5,9 @@ namespace App\Console; +use App\Console\Cron\Backups\BackupClean; +use App\Console\Cron\Backups\BackupMonitor; +use App\Console\Cron\Backups\BackupRun; use App\Console\Cron\FifteenMinute; use App\Console\Cron\FiveMinute; use App\Console\Cron\Hourly; @@ -33,6 +36,9 @@ class Cron Nightly::class, Weekly::class, Monthly::class, + BackupRun::class, + BackupClean::class, + BackupMonitor::class, ]; /** diff --git a/app/Console/Cron/Backups/BackupClean.php b/app/Console/Cron/Backups/BackupClean.php new file mode 100644 index 000000000..8223f2052 --- /dev/null +++ b/app/Console/Cron/Backups/BackupClean.php @@ -0,0 +1,28 @@ +callEvent(); + } + + public function callEvent(): void + { + Artisan::call('backup:clean'); + + $output = trim(Artisan::output()); + if (!empty($output)) { + Log::info($output); + } + } +} diff --git a/app/Console/Cron/Backups/BackupMonitor.php b/app/Console/Cron/Backups/BackupMonitor.php new file mode 100644 index 000000000..b0acb3478 --- /dev/null +++ b/app/Console/Cron/Backups/BackupMonitor.php @@ -0,0 +1,28 @@ +callEvent(); + } + + public function callEvent(): void + { + Artisan::call('backup:monitor'); + + $output = trim(Artisan::output()); + if (!empty($output)) { + Log::info($output); + } + } +} diff --git a/app/Console/Cron/Backups/BackupRun.php b/app/Console/Cron/Backups/BackupRun.php new file mode 100644 index 000000000..8fc818d8d --- /dev/null +++ b/app/Console/Cron/Backups/BackupRun.php @@ -0,0 +1,28 @@ +callEvent(); + } + + public function callEvent(): void + { + Artisan::call('backup:run'); + + $output = trim(Artisan::output()); + if (!empty($output)) { + Log::info($output); + } + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index edf27dc56..6b9d6a259 100755 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -2,6 +2,9 @@ namespace App\Console; +use App\Console\Cron\Backups\BackupClean; +use App\Console\Cron\Backups\BackupMonitor; +use App\Console\Cron\Backups\BackupRun; use App\Console\Cron\FifteenMinute; use App\Console\Cron\FiveMinute; use App\Console\Cron\Hourly; @@ -52,9 +55,9 @@ protected function schedule(Schedule $schedule): void // When spatie-backups runs if (config('backup.backup.enabled', false) === true) { - $schedule->command('backup:run')->daily()->at('01:00'); - $schedule->command('backup:clean')->daily()->at('01:20'); - $schedule->command('backup:monitor')->daily()->at('01:30'); + $schedule->command(BackupRun::class)->daily()->at('01:10'); + $schedule->command(BackupClean::class)->daily()->at('01:20'); + $schedule->command(BackupMonitor::class)->daily()->at('01:30'); } // Update the last time the cron was run From 799850e12fc5b7d228f3793b137e7ba7984e5ad4 Mon Sep 17 00:00:00 2001 From: "B.Fatih KOZ" Date: Wed, 24 Jan 2024 01:42:51 +0300 Subject: [PATCH 09/14] Fix User Removal (#1749) Co-authored-by: Nabeel S --- app/Services/UserService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Services/UserService.php b/app/Services/UserService.php index 947fcb030..06e66e83f 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -148,7 +148,7 @@ public function removeUser(User $user) $user->state = UserState::DELETED; $user->save(); } else { - $user->delete(); + $user->forceDelete(); } } From af80e76365005ba38aa9d4cc22ae3fec84d0bc1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Parient=C3=A9?= <41431456+arthurpar06@users.noreply.github.com> Date: Tue, 23 Jan 2024 23:44:20 +0100 Subject: [PATCH 10/14] Use Markdown in discord notifications (#1743) * Add league/html-to-markdown * Convert HTML to Markdown in NewsAdded notification * Apply fixes from StyleCI --------- Co-authored-by: StyleCI Bot Co-authored-by: Nabeel S --- .../Messages/Broadcast/NewsAdded.php | 5 +- composer.json | 3 +- composer.lock | 93 ++++++++++++++++++- 3 files changed, 97 insertions(+), 4 deletions(-) diff --git a/app/Notifications/Messages/Broadcast/NewsAdded.php b/app/Notifications/Messages/Broadcast/NewsAdded.php index 1f96bfe28..021ffaa7c 100644 --- a/app/Notifications/Messages/Broadcast/NewsAdded.php +++ b/app/Notifications/Messages/Broadcast/NewsAdded.php @@ -6,6 +6,7 @@ use App\Models\News; use App\Notifications\Channels\Discord\DiscordMessage; use Illuminate\Contracts\Queue\ShouldQueue; +use League\HTMLToMarkdown\HtmlConverter; class NewsAdded extends Notification implements ShouldQueue { @@ -31,6 +32,8 @@ public function via($notifiable) public function toDiscordChannel($news): ?DiscordMessage { $dm = new DiscordMessage(); + $markdown = (new HtmlConverter(['header_style' => 'atx']))->convert($news->body); + return $dm->webhook(setting('notifications.discord_public_webhook_url')) ->success() ->title('News: '.$news->subject) @@ -39,7 +42,7 @@ public function toDiscordChannel($news): ?DiscordMessage 'url' => '', 'icon_url' => $news->user->resolveAvatarUrl(), ]) - ->description($news->body); + ->description($markdown); } /** diff --git a/composer.json b/composer.json index 48fdb4c11..a823d70d2 100644 --- a/composer.json +++ b/composer.json @@ -85,7 +85,8 @@ "spatie/laravel-backup": "*", "laravel/socialite": "^5.11", "socialiteproviders/discord": "^4.2", - "symfony/postmark-mailer": "^6.0" + "symfony/postmark-mailer": "^6.0", + "league/html-to-markdown": "^5.1" }, "require-dev": { "barryvdh/laravel-debugbar": "^3.8.1", diff --git a/composer.lock b/composer.lock index 1e00d5d7f..76b74bf9c 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": "b4044ac9103746eae3b32bd6a15560ed", + "content-hash": "dfdeb674b83929dc487d333bb5856a2d", "packages": [ { "name": "akaunting/laravel-money", @@ -4597,6 +4597,95 @@ }, "time": "2022-10-26T19:43:53+00:00" }, + { + "name": "league/html-to-markdown", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/html-to-markdown.git", + "reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/0b4066eede55c48f38bcee4fb8f0aa85654390fd", + "reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xml": "*", + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "mikehaertl/php-shellcommand": "^1.1.0", + "phpstan/phpstan": "^1.8.8", + "phpunit/phpunit": "^8.5 || ^9.2", + "scrutinizer/ocular": "^1.6", + "unleashedtech/php-coding-standard": "^2.7 || ^3.0", + "vimeo/psalm": "^4.22 || ^5.0" + }, + "bin": [ + "bin/html-to-markdown" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\HTMLToMarkdown\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + }, + { + "name": "Nick Cernis", + "email": "nick@cern.is", + "homepage": "http://modernnerd.net", + "role": "Original Author" + } + ], + "description": "An HTML-to-markdown conversion helper for PHP", + "homepage": "https://github.com/thephpleague/html-to-markdown", + "keywords": [ + "html", + "markdown" + ], + "support": { + "issues": "https://github.com/thephpleague/html-to-markdown/issues", + "source": "https://github.com/thephpleague/html-to-markdown/tree/5.1.1" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/html-to-markdown", + "type": "tidelift" + } + ], + "time": "2023-07-12T21:21:09+00:00" + }, { "name": "league/iso3166", "version": "4.2.1", @@ -14498,5 +14587,5 @@ "ext-zip": "*" }, "platform-dev": [], - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } From 6d0b46896f5338cf0dfdf24d4bce7932d6765435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Parient=C3=A9?= <41431456+arthurpar06@users.noreply.github.com> Date: Tue, 23 Jan 2024 23:44:52 +0100 Subject: [PATCH 11/14] Update backups clean up config (#1744) * Update backups cleanup config * Apply fixes from StyleCI --------- Co-authored-by: StyleCI Bot Co-authored-by: Nabeel S --- config/backup.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config/backup.php b/config/backup.php index 81fa96cf4..c57f5b3a9 100644 --- a/config/backup.php +++ b/config/backup.php @@ -288,39 +288,39 @@ /* * The number of days for which backups must be kept. */ - 'keep_all_backups_for_days' => 7, + 'keep_all_backups_for_days' => env('BACKUP_MAX_DAYS', 7), /* * After the "keep_all_backups_for_days" period is over, the most recent backup * of that day will be kept. Older backups within the same day will be removed. * If you create backups only once a day, no backups will be removed yet. */ - 'keep_daily_backups_for_days' => 16, + 'keep_daily_backups_for_days' => env('BACKUP_DAILY_MAX_DAYS', 0), /* * After the "keep_daily_backups_for_days" period is over, the most recent backup * of that week will be kept. Older backups within the same week will be removed. * If you create backups only once a week, no backups will be removed yet. */ - 'keep_weekly_backups_for_weeks' => 8, + 'keep_weekly_backups_for_weeks' => env('BACKUP_WEEKLY_MAX_WEEKS', 0), /* * After the "keep_weekly_backups_for_weeks" period is over, the most recent backup * of that month will be kept. Older backups within the same month will be removed. */ - 'keep_monthly_backups_for_months' => 4, + 'keep_monthly_backups_for_months' => env('BACKUP_MONTHLY_MAX_MONTHS', 0), /* * After the "keep_monthly_backups_for_months" period is over, the most recent backup * of that year will be kept. Older backups within the same year will be removed. */ - 'keep_yearly_backups_for_years' => 2, + 'keep_yearly_backups_for_years' => env('BACKUP_YEARLY_MAX_YEARS', 0), /* * After cleaning up the backups remove the oldest backup until * this amount of megabytes has been reached. */ - 'delete_oldest_backups_when_using_more_megabytes_than' => 5000, + 'delete_oldest_backups_when_using_more_megabytes_than' => env('BACKUP_MAX_SIZE', 5000), ], /** From 34170360c985ade6c9621c968b3c9973affbf47a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Parient=C3=A9?= <41431456+arthurpar06@users.noreply.github.com> Date: Tue, 23 Jan 2024 23:45:35 +0100 Subject: [PATCH 12/14] Fix CSS in Admin Pireps Table (#1738) * Fix CSS in Admin Pireps Table * Apply fixes from StyleCI --------- Co-authored-by: StyleCI Bot Co-authored-by: Nabeel S --- resources/views/admin/pireps/actions.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/admin/pireps/actions.blade.php b/resources/views/admin/pireps/actions.blade.php index 1b0997854..6c3b22c49 100644 --- a/resources/views/admin/pireps/actions.blade.php +++ b/resources/views/admin/pireps/actions.blade.php @@ -1,4 +1,4 @@ - +
@if ($pirep->state === PirepState::PENDING || $pirep->state === PirepState::REJECTED)
From 80edbe8f38089a2d6330d851679fe4887052c5de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Parient=C3=A9?= <41431456+arthurpar06@users.noreply.github.com> Date: Tue, 23 Jan 2024 23:47:51 +0100 Subject: [PATCH 13/14] OAuth improvements (#1735) * Do not create account if email already exists in OAuth * Remove DB constraints in Discord OAuth * Apply fixes from StyleCI * Add flash to login_layout * Fix logoutProvider for tests * Update OAuth Callback * Remove register with discord * Remove DISCORD_BOT_TOKEN * Add OAuthTest * Apply fixes from StyleCI * Update avatar in OAuthTest * Debug test * Revert "Debug test" This reverts commit ddef2f64b3f5c6e999202353fd6fcafc37091240. * Debug test * Apply fixes from StyleCI * Still trying to debug tests * Add avatar to UserFactory * Remove debug stuff * Update OAuthTest * Return discord_id in API * Check for UserState in OAuthController * Update OAuthTest * Apply fixes from StyleCI * Retrieve discord_private_channel_id * Apply fixes from StyleCI --------- Co-authored-by: StyleCI Bot Co-authored-by: Nabeel S --- app/Database/factories/UserFactory.php | 1 + ...30_drop_user_oauth_tokens_foreign_keys.php | 26 ++ app/Http/Controllers/Auth/OAuthController.php | 88 +++--- app/Http/Resources/User.php | 1 + app/Models/User.php | 1 - app/Services/UserService.php | 31 +++ config/services.php | 2 +- .../default/auth/login_layout.blade.php | 1 + .../layouts/default/auth/register.blade.php | 6 - tests/OAuthTest.php | 253 ++++++++++++++++++ 10 files changed, 370 insertions(+), 40 deletions(-) create mode 100644 app/Database/migrations/2023_12_24_091030_drop_user_oauth_tokens_foreign_keys.php create mode 100644 tests/OAuthTest.php diff --git a/app/Database/factories/UserFactory.php b/app/Database/factories/UserFactory.php index 33639a349..6abed468d 100644 --- a/app/Database/factories/UserFactory.php +++ b/app/Database/factories/UserFactory.php @@ -50,6 +50,7 @@ public function definition(): array 'state' => UserState::ACTIVE, 'remember_token' => $this->faker->unique()->text(5), 'email_verified_at' => now(), + 'avatar' => '', ]; } } diff --git a/app/Database/migrations/2023_12_24_091030_drop_user_oauth_tokens_foreign_keys.php b/app/Database/migrations/2023_12_24_091030_drop_user_oauth_tokens_foreign_keys.php new file mode 100644 index 000000000..97008e341 --- /dev/null +++ b/app/Database/migrations/2023_12_24_091030_drop_user_oauth_tokens_foreign_keys.php @@ -0,0 +1,26 @@ +dropForeign(['user_id']); + break; + } + } + }); + } + + public function down(): void + { + // + } +}; diff --git a/app/Http/Controllers/Auth/OAuthController.php b/app/Http/Controllers/Auth/OAuthController.php index 0b3b78fb5..2d8b6b159 100644 --- a/app/Http/Controllers/Auth/OAuthController.php +++ b/app/Http/Controllers/Auth/OAuthController.php @@ -3,13 +3,15 @@ namespace App\Http\Controllers\Auth; use App\Contracts\Controller; -use App\Models\Airline; -use App\Models\Airport; +use App\Models\Enums\UserState; use App\Models\User; use App\Models\UserOAuthToken; use App\Services\UserService; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; +use Illuminate\View\View; use Laravel\Socialite\Facades\Socialite; class OAuthController extends Controller @@ -37,7 +39,7 @@ public function redirectToProvider(string $provider): RedirectResponse } } - public function handleProviderCallback(string $provider): RedirectResponse + public function handleProviderCallback(string $provider, Request $request): View|RedirectResponse { $providerUser = null; @@ -75,14 +77,51 @@ public function handleProviderCallback(string $provider): RedirectResponse 'last_refreshed_at' => now(), ]); + if ($provider === 'discord') { + $this->userSvc->retrieveDiscordPrivateChannelId($user); + } + flash()->success(ucfirst($provider).' account linked!'); return redirect(route('frontend.profile.index')); } - $user = User::where($provider.'_id', $providerUser->getId())->first(); + $user = User::where($provider.'_id', $providerUser->getId())->orWhere('email', $providerUser->getEmail())->first(); if ($user) { + $user->update([ + $provider.'_id' => $providerUser->getId(), + 'lastlogin_at' => now(), + ]); + + if (setting('general.record_user_ip', true)) { + $user->update([ + 'last_ip' => $request->ip(), + ]); + } + + // We don't want to log in a non-active user + if ($user->state !== UserState::ACTIVE && $user->state !== UserState::ON_LEAVE) { + Log::info('Trying to login '.$user->ident.', state '.UserState::label($user->state)); + + // Log them out + Auth::logout(); + $request->session()->invalidate(); + + // Redirect to one of the error pages + if ($user->state === UserState::PENDING) { + return view('auth.pending'); + } + + if ($user->state === UserState::REJECTED) { + return view('auth.rejected'); + } + + if ($user->state === UserState::SUSPENDED) { + return view('auth.suspended'); + } + } + $tokens = UserOAuthToken::updateOrCreate([ 'user_id' => $user->id, 'provider' => $provider, @@ -94,31 +133,15 @@ public function handleProviderCallback(string $provider): RedirectResponse Auth::login($user); + if ($provider === 'discord') { + $this->userSvc->retrieveDiscordPrivateChannelId($user); + } + return redirect(route('frontend.dashboard.index')); } - $attrs = [ - 'name' => $providerUser->getName(), - 'email' => $providerUser->getEmail(), - 'avatar' => $providerUser->getAvatar(), - 'airline_id' => Airline::select('id')->first()->id, - 'home_airport_id' => Airport::select('id')->where('hub', true)->first()->id, - $provider.'_id' => $providerUser->getId(), - ]; - - $user = $this->userSvc->createUser($attrs); - - UserOAuthToken::create([ - 'user_id' => $user->id, - 'provider' => $provider, - 'token' => $providerUser->token, - 'refresh_token' => $providerUser->refreshToken, - 'last_refreshed_at' => now(), - ]); - - Auth::login($user); - - return redirect(route('frontend.profile.edit', ['profile' => $user->id])); + flash()->error('No user linked to this account found. Please register first.'); + return redirect(url('/login')); } public function logoutProvider(string $provider): RedirectResponse @@ -130,15 +153,16 @@ public function logoutProvider(string $provider): RedirectResponse $user = Auth::user(); $otherProviders = UserOAuthToken::where('user_id', $user->id)->where('provider', '!=', $provider)->count(); - if (empty($user->password) && $otherProviders === 0) { - flash()->error('You cannot unlink your only login method!'); - return redirect()->route('frontend.profile.index'); - } - $user->update([ - $provider.'_id' => null, + $provider.'_id' => '', ]); + if ($provider === 'discord' && $user->discord_private_channel_id) { + $user->update([ + 'discord_private_channel_id' => '', + ]); + } + flash()->success(ucfirst($provider).' account unlinked!'); return redirect()->route('frontend.profile.index'); diff --git a/app/Http/Resources/User.php b/app/Http/Resources/User.php index 21f752115..e7b3b0c9f 100644 --- a/app/Http/Resources/User.php +++ b/app/Http/Resources/User.php @@ -18,6 +18,7 @@ public function toArray($request) 'name' => $this->name_private, 'name_private' => $this->name_private, 'avatar' => $this->resolveAvatarUrl(), + 'discord_id' => $this->discord_id, 'rank_id' => $this->rank_id, 'home_airport' => $this->home_airport_id, 'curr_airport' => $this->curr_airport_id, diff --git a/app/Models/User.php b/app/Models/User.php index 6995ed2de..59200e009 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -123,7 +123,6 @@ class User extends Authenticatable implements LaratrustUser, MustVerifyEmail 'api_key', 'email', 'name', - 'discord_id', 'discord_private_channel_id', 'password', 'last_ip', diff --git a/app/Services/UserService.php b/app/Services/UserService.php index 06e66e83f..1b9611baa 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -24,6 +24,8 @@ use App\Support\Units\Time; use App\Support\Utils; use Carbon\Carbon; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; use Illuminate\Auth\Events\Registered; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Hash; @@ -602,4 +604,33 @@ public function removeUserFromTypeRating(User $user, Typerating $typerating) return $user; } + + public function retrieveDiscordPrivateChannelId(User $user): void + { + if (is_null(config('services.discord.bot_token'))) { + return; + } + + try { + $httpClient = new Client(); + + $response = $httpClient->post('https://discord.com/api/users/@me/channels', [ + 'headers' => [ + 'Authorization' => 'Bot '.config('services.discord.bot_token'), + ], + 'json' => [ + 'recipient_id' => $user->discord_id, + ], + ]); + + $privateChannel = json_decode($response->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR)['id']; + $user->update([ + 'discord_private_channel_id' => $privateChannel, + ]); + } catch (\Exception $e) { + Log::error('Discord OAuth Error: '.$e->getMessage()); + } catch (GuzzleException $e) { + Log::error('Discord OAuth Error: '.$e->getMessage()); + } + } } diff --git a/config/services.php b/config/services.php index 562a99a1e..07124bff0 100755 --- a/config/services.php +++ b/config/services.php @@ -36,7 +36,7 @@ 'redirect' => '/oauth/discord/callback', // optional - 'token' => env('DISCORD_BOT_TOKEN', null), + 'bot_token' => env('DISCORD_BOT_TOKEN', null), 'allow_gif_avatars' => (bool) env('DISCORD_AVATAR_GIF', true), 'avatar_default_extension' => env('DISCORD_EXTENSION_DEFAULT', 'png'), // only pick from jpg, png, webp ], diff --git a/resources/views/layouts/default/auth/login_layout.blade.php b/resources/views/layouts/default/auth/login_layout.blade.php index 1786b82f5..9d19e7f76 100644 --- a/resources/views/layouts/default/auth/login_layout.blade.php +++ b/resources/views/layouts/default/auth/login_layout.blade.php @@ -22,6 +22,7 @@

- @if(config('services.discord.enabled')) - - @lang('auth.loginwith', ['provider' => 'Discord']) - - @endif - {{ Form::submit(__('auth.register'), [ 'id' => 'register_button', 'class' => 'btn btn-primary', diff --git a/tests/OAuthTest.php b/tests/OAuthTest.php new file mode 100644 index 000000000..17b5e56c6 --- /dev/null +++ b/tests/OAuthTest.php @@ -0,0 +1,253 @@ +drivers as $driver) { + Config::set('services.'.$driver.'.enabled', true); + } + } + + /** + * Simulate what would be returned by the OAuth provider + * + * @return LegacyMockInterface|MockInterface + */ + protected function getMockedProvider(): LegacyMockInterface|MockInterface + { + $abstractUser = \Mockery::mock('Laravel\Socialite\Two\User') + ->allows([ + 'getId' => 123456789, + 'getName' => 'OAuth user', + 'getEmail' => 'oauth.user@phpvms.net', + 'getAvatar' => 'https://en.gravatar.com/userimage/12856995/aa6c0527a723abfd5fb9e246f0ff8af4.png', + ]); + + $abstractUser->token = 'token'; + $abstractUser->refreshToken = 'refresh_token'; + + return \Mockery::mock('Laravel\Socialite\Contracts\Provider') + ->allows([ + 'user' => $abstractUser, + ]); + } + + /** + * Try to link a logged-in user to an OAuth account from profile + * + * @return void + */ + public function testLinkAccountFromProfile(): void + { + $user = User::factory()->create([ + 'name' => 'OAuth user', + 'email' => 'oauth.user@phpvms.net', + ]); + Auth::login($user); + + foreach ($this->drivers as $driver) { + Socialite::shouldReceive('driver')->with($driver)->andReturn($this->getMockedProvider()); + + $this->get(route('oauth.callback', ['provider' => $driver])) + ->assertRedirect(route('frontend.profile.index')); + + $user->refresh(); + $this->assertEquals(123456789, $user->{$driver.'_id'}); + + $tokens = $user->oauth_tokens()->where('provider', $driver)->first(); + + $this->assertNotNull($tokens); + $this->assertEquals('token', $tokens->token); + $this->assertEquals('refresh_token', $tokens->refresh_token); + $this->assertTrue($tokens->last_refreshed_at->diffInSeconds(now()) <= 2); + } + } + + /** + * Try to link a non-logged-in user from the login page using its email + * + * @return void + */ + public function testLinkAccountFromLogin(): void + { + $user = User::factory()->create([ + 'name' => 'OAuth user', + 'email' => 'oauth.user@phpvms.net', + ]); + + foreach ($this->drivers as $driver) { + Socialite::shouldReceive('driver')->with($driver)->andReturn($this->getMockedProvider()); + + $this->get(route('oauth.callback', ['provider' => $driver])) + ->assertRedirect(route('frontend.dashboard.index')); + + $user->refresh(); + $this->assertEquals(123456789, $user->{$driver.'_id'}); + $this->assertTrue($user->lastlogin_at->diffInSeconds(now()) <= 2); + + $tokens = $user->oauth_tokens()->where('provider', $driver)->first(); + + $this->assertNotNull($tokens); + $this->assertEquals('token', $tokens->token); + $this->assertEquals('refresh_token', $tokens->refresh_token); + $this->assertTrue($tokens->last_refreshed_at->diffInSeconds(now()) <= 2); + } + } + + /** + * Try to log in an already linked user + * + * @return void + */ + public function testLoginWithLinkedAccount(): void + { + $user = User::factory()->create([ + 'name' => 'OAuth user', + 'email' => 'oauth.user@phpvms.net', + 'discord_id' => 123456789, + ]); + + foreach ($this->drivers as $driver) { + UserOAuthToken::create([ + 'user_id' => $user->id, + 'provider' => $driver, + 'token' => 'token', + 'refresh_token' => 'refresh_token', + 'last_refreshed_at' => now(), + ]); + + Socialite::shouldReceive('driver')->with($driver)->andReturn($this->getMockedProvider()); + + $this->get(route('oauth.callback', ['provider' => $driver])) + ->assertRedirect(route('frontend.dashboard.index')); + + $user->refresh(); + $this->assertEquals(123456789, $user->{$driver.'_id'}); + $this->assertTrue($user->lastlogin_at->diffInSeconds(now()) <= 2); + + $tokens = $user->oauth_tokens()->where('provider', $driver)->first(); + + $this->assertNotNull($tokens); + $this->assertEquals('token', $tokens->token); + $this->assertEquals('refresh_token', $tokens->refresh_token); + $this->assertTrue($tokens->last_refreshed_at->diffInSeconds(now()) <= 2); + } + } + + /** + * Try to log in a user with a pending account + * + * @return void + */ + public function testLoginWithPendingAccount(): void + { + $user = User::factory()->create([ + 'name' => 'OAuth user', + 'email' => 'oauth.user@phpvms.net', + 'state' => UserState::PENDING, + ]); + + foreach ($this->drivers as $driver) { + Socialite::shouldReceive('driver')->with($driver)->andReturn($this->getMockedProvider()); + + $this->get(route('oauth.callback', ['provider' => $driver])) + ->assertViewIs('auth.pending'); + } + } + + /** + * Try to log in someone not in DB + * + * @return void + */ + public function testNoAccountFound() + { + foreach ($this->drivers as $driver) { + Socialite::shouldReceive('driver')->with($driver)->andReturn($this->getMockedProvider()); + + $this->get(route('oauth.callback', ['provider' => $driver])) + ->assertRedirect(url('/login')); + } + } + + /** + * Try to unlink an account from profile + * + * @return void + */ + public function testUnlinkAccount(): void + { + $user = User::factory()->create([ + 'name' => 'OAuth user', + 'email' => 'oauth.user@phpvms.net', + ]); + + foreach ($this->drivers as $driver) { + $user->update([ + $driver.'_id' => 123456789, + ]); + + Auth::login($user); + + $this->get(route('oauth.logout', ['provider' => $driver])) + ->assertRedirect(route('frontend.profile.index')); + + $user->refresh(); + $this->assertEmpty($user->{$driver.'_id'}); + } + } + + /** + * Try to access a non-existing provider callback + * + * @return void + */ + public function testNonExistingProvider(): void + { + $this->expectException(NotFoundHttpException::class); + + $this->get(route('oauth.redirect', ['provider' => 'aze'])) + ->assertStatus(404); + + $this->get(route('oauth.callback', ['provider' => 'aze'])) + ->assertStatus(404); + } + + /** + * Try to access a disabled provider callback + * + * @return void + */ + public function testDisabledProvider(): void + { + $originalConfigValue = config('services.discord.enabled'); + Config::set('services.discord.enabled', false); + + $this->expectException(NotFoundHttpException::class); + + $this->get(route('oauth.redirect', ['provider' => 'discord'])) + ->assertStatus(404); + $this->get(route('oauth.callback', ['provider' => 'discord'])) + ->assertStatus(404); + + Config::set('services.discord.enabled', $originalConfigValue); + } +} From 225c4377be5cc2968bb13c84047d1ad6565b98bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Parient=C3=A9?= <41431456+arthurpar06@users.noreply.github.com> Date: Wed, 24 Jan 2024 05:37:40 +0100 Subject: [PATCH 14/14] Add settings to disable registration or make it invite-only (#1739) * Update settings * Add invites table * Create User from admin * Add invites to admin * Update settings names * Registration changes * ClearExpiredInvites * Fix RegisterController * Add some tests * Apply fixes from StyleCI * Add test for wrong email * Apply fixes from StyleCI * Add test for standard registration * Apply fixes from StyleCI * Use string for token instead of longText * Allow to add users from admin regardless of registration settings --------- Co-authored-by: StyleCI Bot Co-authored-by: Nabeel S --- app/Cron/Hourly/ClearExpiredInvites.php | 33 ++++ ...2023_12_27_112456_create_invites_table.php | 25 +++ app/Database/seeds/settings.yml | 14 ++ .../Controllers/Admin/InviteController.php | 84 ++++++++++ app/Http/Controllers/Admin/UserController.php | 14 +- .../Controllers/Auth/RegisterController.php | 60 ++++++- app/Http/Requests/CreateInviteRequest.php | 22 +++ app/Http/Requests/CreateUserRequest.php | 11 +- app/Models/Invite.php | 49 ++++++ app/Notifications/Messages/InviteLink.php | 48 ++++++ app/Providers/CronServiceProvider.php | 2 + app/Providers/RouteServiceProvider.php | 7 + app/Services/UserService.php | 13 +- .../views/admin/invites/create.blade.php | 11 ++ .../views/admin/invites/fields.blade.php | 49 ++++++ resources/views/admin/invites/index.blade.php | 15 ++ resources/views/admin/invites/table.blade.php | 36 ++++ resources/views/admin/users/create.blade.php | 13 +- .../views/admin/users/custom_fields.blade.php | 22 +++ resources/views/admin/users/details.blade.php | 47 ++++++ resources/views/admin/users/edit.blade.php | 24 +++ resources/views/admin/users/fields.blade.php | 95 +---------- resources/views/admin/users/index.blade.php | 8 + .../layouts/default/auth/register.blade.php | 5 + .../notifications/mail/user/invite.blade.php | 12 ++ tests/RegistrationTest.php | 158 ++++++++++++++++++ 26 files changed, 760 insertions(+), 117 deletions(-) create mode 100644 app/Cron/Hourly/ClearExpiredInvites.php create mode 100644 app/Database/migrations/2023_12_27_112456_create_invites_table.php create mode 100644 app/Http/Controllers/Admin/InviteController.php create mode 100644 app/Http/Requests/CreateInviteRequest.php create mode 100644 app/Models/Invite.php create mode 100644 app/Notifications/Messages/InviteLink.php create mode 100644 resources/views/admin/invites/create.blade.php create mode 100644 resources/views/admin/invites/fields.blade.php create mode 100644 resources/views/admin/invites/index.blade.php create mode 100644 resources/views/admin/invites/table.blade.php create mode 100644 resources/views/admin/users/custom_fields.blade.php create mode 100644 resources/views/admin/users/details.blade.php create mode 100644 resources/views/notifications/mail/user/invite.blade.php diff --git a/app/Cron/Hourly/ClearExpiredInvites.php b/app/Cron/Hourly/ClearExpiredInvites.php new file mode 100644 index 000000000..9fcfbf011 --- /dev/null +++ b/app/Cron/Hourly/ClearExpiredInvites.php @@ -0,0 +1,33 @@ +expires_at && $invite->expires_at->isPast()) { + $invite->delete(); + } + + if ($invite->usage_limit && $invite->usage_count >= $invite->usage_limit) { + $invite->delete(); + } + } + } +} diff --git a/app/Database/migrations/2023_12_27_112456_create_invites_table.php b/app/Database/migrations/2023_12_27_112456_create_invites_table.php new file mode 100644 index 000000000..fc85b235d --- /dev/null +++ b/app/Database/migrations/2023_12_27_112456_create_invites_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('email')->nullable(); + $table->string('token'); + $table->integer('usage_count')->default(0); + $table->integer('usage_limit')->nullable(); + $table->dateTime('expires_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('invites'); + } +}; diff --git a/app/Database/seeds/settings.yml b/app/Database/seeds/settings.yml index c17caa240..98377e266 100644 --- a/app/Database/seeds/settings.yml +++ b/app/Database/seeds/settings.yml @@ -61,6 +61,20 @@ options: '' type: boolean description: Record the user's IP address on register/login +- key: general.invite_only_registrations + name: 'Invite Only Registrations' + group: general + value: false + options: '' + type: boolean + description: If checked, only users with an invite can register +- key: general.disable_registrations + name: 'Disable registrations' + group: general + value: false + options: '' + type: boolean + description: If checked, registrations will be disabled and only admins can add pilots - key: captcha.enabled name: 'hCaptcha Enabled' group: captcha diff --git a/app/Http/Controllers/Admin/InviteController.php b/app/Http/Controllers/Admin/InviteController.php new file mode 100644 index 000000000..40e2f49bf --- /dev/null +++ b/app/Http/Controllers/Admin/InviteController.php @@ -0,0 +1,84 @@ + $invites, + ]); + } + + public function create(): RedirectResponse|View + { + if (!setting('general.invite_only_registrations', false)) { + Flash::error('Registration is not on invite only'); + return redirect(route('admin.users.index')); + } + + return view('admin.invites.create'); + } + + public function store(CreateInviteRequest $request): RedirectResponse + { + if (!setting('general.invite_only_registrations', false)) { + Flash::error('Registration is not on invite only'); + return redirect(route('admin.users.index')); + } + + $invite = Invite::create([ + 'email' => $request->get('email'), + 'token' => sha1(hrtime(true).str_random()), + 'usage_count' => 0, + 'usage_limit' => !is_null($request->get('email')) ? 1 : $request->get('usage_limit'), + 'expires_at' => $request->get('expires_at'), + ]); + + if (!is_null($request->get('email')) && get_truth_state($request->get('email_link'))) { + Notification::route('mail', $request->get('email')) + ->notify(new InviteLink($invite)); + } + + Flash::success('Invite created successfully. The link is: '.$invite->link); + + return redirect(route('admin.invites.index')); + } + + public function destroy(int $id): RedirectResponse + { + if (!setting('general.invite_only_registrations', false)) { + Flash::error('Registration is not on invite only'); + return redirect(route('admin.users.index')); + } + + $invite = Invite::find($id); + + if (!$invite) { + Flash::error('Invite not found'); + return redirect(route('admin.invites.index')); + } + + $invite->delete(); + + Flash::success('Invite deleted successfully'); + return redirect(route('admin.invites.index')); + } +} diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index 55ce590d9..99bec20f1 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -107,11 +107,17 @@ public function create(): View */ public function store(CreateUserRequest $request): RedirectResponse { - $input = $request->all(); - $user = $this->userRepo->create($input); + $opts = $request->all(); + $opts['password'] = Hash::make($opts['password']); - Flash::success('User saved successfully.'); - return redirect(route('admin.users.index')); + if (isset($opts['transfer_time'])) { + $opts['transfer_time'] *= 60; + } + + $user = $this->userSvc->createUser($opts, $opts['roles'] ?? [], $opts['state'] ?? null); + + Flash::success('User created successfully.'); + return redirect(route('admin.users.edit', [$user->id])); } /** diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index ee8001b56..6275e50f7 100755 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -4,6 +4,7 @@ use App\Contracts\Controller; use App\Models\Enums\UserState; +use App\Models\Invite; use App\Models\User; use App\Models\UserField; use App\Models\UserFieldValue; @@ -53,12 +54,35 @@ public function __construct( } /** - * @throws \Exception + * @param Request $request * * @return View */ - public function showRegistrationForm(): View + public function showRegistrationForm(Request $request): View { + if (setting('general.disable_registrations', false)) { + abort(403, 'Registrations are disabled'); + } + + if (setting('general.invite_only_registrations', false)) { + if (!$request->has('invite') && !$request->has('token')) { + abort(403, 'Registrations are invite only'); + } + + $invite = Invite::find($request->get('invite')); + if (!$invite || $invite->token !== $request->get('token')) { + abort(403, 'Invalid invite'); + } + + if ($invite->usage_limit && $invite->usage_count >= $invite->usage_limit) { + abort(403, 'Invite has been used too many times'); + } + + if ($invite->expires_at && $invite->expires_at->isPast()) { + abort(403, 'Invite has expired'); + } + } + $airlines = $this->airlineRepo->selectBoxList(); $userFields = UserField::where(['show_on_registration' => true, 'active' => true])->get(); @@ -69,6 +93,7 @@ public function showRegistrationForm(): View 'timezones' => Timezonelist::toArray(), 'userFields' => $userFields, 'hubs_only' => setting('pilots.home_hubs_only'), + 'invite' => $invite ?? null, 'captcha' => [ 'enabled' => setting('captcha.enabled', env('CAPTCHA_ENABLED', false)), 'site_key' => setting('captcha.site_key', env('CAPTCHA_SITE_KEY')), @@ -142,6 +167,37 @@ function ($attribute, $value, $fail) { */ protected function create(Request $request): User { + if (setting('general.disable_registrations', false)) { + abort(403, 'Registrations are disabled'); + } + + if (setting('general.invite_only_registrations', false)) { + if (!$request->has('invite') && !$request->has('invite_token')) { + abort(403, 'Registrations are invite only'); + } + + $invite = Invite::find($request->get('invite')); + if (!$invite || $invite->token !== base64_decode($request->get('invite_token'))) { + abort(403, 'Invalid invite'); + } + + if ($invite->usage_limit && $invite->usage_count >= $invite->usage_limit) { + abort(403, 'Invite has been used too many times'); + } + + if ($invite->expires_at && $invite->expires_at->isPast()) { + abort(403, 'Invite has expired'); + } + + if ($invite->email && $invite->email !== $request->get('email')) { + abort(403, 'Invite is for a different email address'); + } + + $invite->update([ + 'usage_count' => $invite->usage_count + 1, + ]); + } + // Default options $opts = $request->all(); $opts['password'] = Hash::make($opts['password']); diff --git a/app/Http/Requests/CreateInviteRequest.php b/app/Http/Requests/CreateInviteRequest.php new file mode 100644 index 000000000..d4b0e8a6c --- /dev/null +++ b/app/Http/Requests/CreateInviteRequest.php @@ -0,0 +1,22 @@ + 'nullable|string', + 'usage_limit' => 'nullable|integer', + 'expires_at' => 'nullable|date|after:today', + ]; + } +} diff --git a/app/Http/Requests/CreateUserRequest.php b/app/Http/Requests/CreateUserRequest.php index c0d4b7a1d..ccd03162f 100644 --- a/app/Http/Requests/CreateUserRequest.php +++ b/app/Http/Requests/CreateUserRequest.php @@ -13,22 +13,15 @@ class CreateUserRequest extends FormRequest */ public function rules(): array { - $rules = [ + return [ 'name' => 'required', 'email' => 'required|email|unique:users,email', 'airline_id' => 'required', 'home_airport_id' => 'required', - 'password' => 'required|confirmed', + 'password' => 'required', 'timezone' => 'required', 'country' => 'required', 'transfer_time' => 'sometimes|integer|min:0', - 'toc_accepted' => 'accepted', ]; - - if (config('captcha.enabled')) { - $rules['g-recaptcha-response'] = 'required|captcha'; - } - - return $rules; } } diff --git a/app/Models/Invite.php b/app/Models/Invite.php new file mode 100644 index 000000000..4a7cffca2 --- /dev/null +++ b/app/Models/Invite.php @@ -0,0 +1,49 @@ + 'string', + 'token' => 'string', + 'usage_count' => 'integer', + 'usage_limit' => 'integer', + 'expires_at' => 'datetime', + ]; + + public static array $rules = [ + 'email' => 'nullable|string', + 'token' => 'required|string', + 'usage_count' => 'integer', + 'usage_limit' => 'nullable|integer', + 'expires_at' => 'nullable|datetime', + ]; + + public function link(): Attribute + { + return Attribute::make( + get: fn ($value, $attrs) => url('/register?invite='.$attrs['id'].'&token='.$attrs['token']) + ); + } +} diff --git a/app/Notifications/Messages/InviteLink.php b/app/Notifications/Messages/InviteLink.php new file mode 100644 index 000000000..cb9f2ef95 --- /dev/null +++ b/app/Notifications/Messages/InviteLink.php @@ -0,0 +1,48 @@ +setMailable( + 'You have been invited to join '.config('app.name'), + 'notifications.mail.user.invite', + ['invite' => $invite] + ); + } + + public function via($notifiable): array + { + return ['mail']; + } + + /** + * Get the array representation of the notification. + * + * @param $notifiable + * + * @return array + */ + public function toArray($notifiable): array + { + return [ + 'invite_id' => $this->invite->id, + ]; + } +} diff --git a/app/Providers/CronServiceProvider.php b/app/Providers/CronServiceProvider.php index 6fc65ad18..77b42f09f 100644 --- a/app/Providers/CronServiceProvider.php +++ b/app/Providers/CronServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Cron\Hourly\ClearExpiredInvites; use App\Cron\Hourly\ClearExpiredSimbrief; use App\Cron\Hourly\DeletePireps; use App\Cron\Hourly\RemoveExpiredBids; @@ -35,6 +36,7 @@ class CronServiceProvider extends ServiceProvider RemoveExpiredBids::class, RemoveExpiredLiveFlights::class, ClearExpiredSimbrief::class, + ClearExpiredInvites::class, ], CronNightly::class => [ ApplyExpenses::class, diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 3ac7f7c47..81155594e 100755 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -487,6 +487,13 @@ private function mapAdminRoutes() Route::resource('users', 'UserController')->middleware('ability:admin,users'); + Route::resource('invites', 'InviteController')->middleware('ability:admin,users') + ->except([ + 'show', + 'edit', + 'update', + ]); + Route::match([ 'get', 'post', diff --git a/app/Services/UserService.php b/app/Services/UserService.php index 1b9611baa..188f8443b 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -84,23 +84,22 @@ public function getUser($user_id): ?User * Register a pilot. Also attaches the initial roles * required, and then triggers the UserRegistered event * - * @param array $attrs Array with the user data - * @param array $roles List of "display_name" of groups to assign - * - * @throws \Exception + * @param array $attrs Array with the user data + * @param array $roles List of "display_name" of groups to assign + * @param int|null $state * * @return User */ - public function createUser(array $attrs, array $roles = []): User + public function createUser(array $attrs, array $roles = [], ?int $state = null): User { $user = User::create($attrs); $user->api_key = Utils::generateApiKey(); $user->curr_airport_id = $user->home_airport_id; // Determine if we want to auto accept - if (setting('pilots.auto_accept') === true) { + if ($state === null && setting('pilots.auto_accept') === true) { $user->state = UserState::ACTIVE; - } else { + } elseif ($state === null) { $user->state = UserState::PENDING; } diff --git a/resources/views/admin/invites/create.blade.php b/resources/views/admin/invites/create.blade.php new file mode 100644 index 000000000..5f26ea9db --- /dev/null +++ b/resources/views/admin/invites/create.blade.php @@ -0,0 +1,11 @@ +@extends('admin.app') +@section('title', 'Add Invite') +@section('content') +
+
+ {{ Form::open(['route' => 'admin.invites.store', 'autofill' => false]) }} + @include('admin.invites.fields') + {{ Form::close() }} +
+
+@endsection diff --git a/resources/views/admin/invites/fields.blade.php b/resources/views/admin/invites/fields.blade.php new file mode 100644 index 000000000..c89486cdd --- /dev/null +++ b/resources/views/admin/invites/fields.blade.php @@ -0,0 +1,49 @@ +
+
+ {{ Form::label('email', 'Email:') }} + {{ Form::email('email', null, ['class' => 'form-control']) }} +

{{ $errors->first('email') }}

+ @component('admin.components.info') + If empty all emails will be allowed to register using the link. + @endcomponent +
+ +
+ {{ Form::label('usage_limit', 'Usage limit:') }} + {{ Form::number('usage_limit', null, ['class' => 'form-control']) }} +

{{ $errors->first('usage_limit') }}

+ @component('admin.components.info') + If empty there will be no limit on the number of times the link can be used. + If an email is provided the limit will be automatically set to 1. + @endcomponent +
+ +
+ {{ Form::label('expires_at', 'Expiration Date:') }} + +

{{ $errors->first('expires_at') }}

+ @component('admin.components.info') + If empty the link will not expire. + @endcomponent +
+
+ +
+
+ {{ Form::label('email_link', 'Email Invite Link:') }} + + @component('admin.components.info') + If checked and an email is provided, the invite will be sent via email. + @endcomponent +
+ + +
+
+ {{ Form::button('Save', ['type' => 'submit', 'class' => 'btn btn-success']) }} + Cancel +
+
diff --git a/resources/views/admin/invites/index.blade.php b/resources/views/admin/invites/index.blade.php new file mode 100644 index 000000000..d3c8d4343 --- /dev/null +++ b/resources/views/admin/invites/index.blade.php @@ -0,0 +1,15 @@ +@extends('admin.app') +@section('title', 'Invites') + +@section('actions') +
  • Add Invite
  • +@endsection + +@section('content') +
    +
    + @include('admin.invites.table') +
    +
    +@endsection + diff --git a/resources/views/admin/invites/table.blade.php b/resources/views/admin/invites/table.blade.php new file mode 100644 index 000000000..9a78017c5 --- /dev/null +++ b/resources/views/admin/invites/table.blade.php @@ -0,0 +1,36 @@ +
    + + + + + + + + + + + @foreach($invites as $invite) + + + + + + + + @endforeach + +
    Invite TypeInvited Email/Invite LinkUsage CountUsage LimitExpires In
    {{ is_null($invite->email) ? 'Link' : 'Email' }} + @if(is_null($invite->email)) + {{ $invite->link }} + @else + {{ $invite->email }} + @endif + {{ $invite->usage_count }}{{ $invite->usage_limit ?? 'No limit' }}{{ $invite->expires_at?->diffForHumans() ?? 'Never' }} + + {{ Form::open(['route' => ['admin.invites.destroy', $invite->id], 'method' => 'delete']) }} + {{ Form::button('', + ['type' => 'submit', 'class' => 'btn btn-sm btn-danger btn-icon', + 'onclick' => "return confirm('Are you sure?')"]) }} + {{ Form::close() }} +
    +
    diff --git a/resources/views/admin/users/create.blade.php b/resources/views/admin/users/create.blade.php index 46c0901f5..4c5855f1b 100644 --- a/resources/views/admin/users/create.blade.php +++ b/resources/views/admin/users/create.blade.php @@ -3,9 +3,18 @@ @section('content')
    - {{ Form::open(['route' => 'admin.airlines.store', 'autocomplete' => false]) }} - @include('admin.airlines.fields') + {{ Form::open(['route' => 'admin.users.store', 'autocomplete' => false]) }} + @include('admin.users.fields') + +
    +
    + {{ Form::button('Save', ['type' => 'submit', 'class' => 'btn btn-success']) }} + Cancel +
    +
    {{ Form::close() }}
    @endsection + +@include('admin.users.script') diff --git a/resources/views/admin/users/custom_fields.blade.php b/resources/views/admin/users/custom_fields.blade.php new file mode 100644 index 000000000..992984d24 --- /dev/null +++ b/resources/views/admin/users/custom_fields.blade.php @@ -0,0 +1,22 @@ +@if($user->fields) + + + + + {{-- Custom Fields --}} + @foreach($user->fields as $field) + + + + + @endforeach +
    Custom Fields
    {{ $field->field->name }} + @if(in_array($field->name, ['IVAO', 'IVAO ID'])) + {{ $field->value }} + @elseif(in_array($field->name, ['VATSIM', 'VATSIM CID', 'VATSIM ID'])) + {{ $field->value }} + @else + {{ $field->value }} + @endif +
    +@endif diff --git a/resources/views/admin/users/details.blade.php b/resources/views/admin/users/details.blade.php new file mode 100644 index 000000000..a4a4e1a72 --- /dev/null +++ b/resources/views/admin/users/details.blade.php @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    User Details
    Total Flights{{ $user->flights }}
    Flight Time@minutestotime($user->flight_time)
    Registered On{{ show_datetime($user->created_at) }}
    E-Mail Verified On + @if(filled($user->email_verified_at)) + {{ show_datetime($user->email_verified_at) }} + @else + USER E-MAIL NOT VERIFIED !!! + @endif +
    Last Login + @if(filled($user->lastlogin_at)) + {{ show_datetime($user->lastlogin_at) }} + @endif +
    IP Address{{ $user->last_ip ?? '-' }}
    @lang('toc.title'){{ $user->toc_accepted ? __('common.yes') : __('common.no') }}
    @lang('profile.opt-in'){{ $user->opt_in ? __('common.yes') : __('common.no') }}
    diff --git a/resources/views/admin/users/edit.blade.php b/resources/views/admin/users/edit.blade.php index 7ed01809e..5f7037bd8 100644 --- a/resources/views/admin/users/edit.blade.php +++ b/resources/views/admin/users/edit.blade.php @@ -5,7 +5,31 @@
    {{ Form::model($user, ['route' => ['admin.users.update', $user->id], 'method' => 'patch', 'autocomplete' => false]) }} @include('admin.users.fields') + +
    +
    + {{-- New API Key --}} +   + @if (!$user->email_verified_at) + Verify email manually + @else + Request new email verification + @endif + + {{ Form::button('Save', ['type' => 'submit', 'class' => 'btn btn-success']) }} + Cancel +
    +
    {{ Form::close() }} + +
    +
    + @include('admin.users.custom_fields') +
    +
    + @include('admin.users.details') +
    +
    diff --git a/resources/views/admin/users/fields.blade.php b/resources/views/admin/users/fields.blade.php index 7b2ad9d89..eade1c9f2 100644 --- a/resources/views/admin/users/fields.blade.php +++ b/resources/views/admin/users/fields.blade.php @@ -43,7 +43,7 @@
    {{ Form::label('transfer_time', 'Transfer Hours:') }} - {{ Form::text('transfer_time', \App\Support\Units\Time::minutesToHours($user->transfer_time), ['class' => 'form-control']) }} + {{ Form::text('transfer_time', \App\Support\Units\Time::minutesToHours($user?->transfer_time), ['class' => 'form-control']) }}

    {{ $errors->first('transfer_time') }}

    @@ -74,7 +74,7 @@
    @ability('admin', 'admin-user') {{ Form::label('roles', 'Roles:') }} - {{ Form::select('roles[]', $roles, $user->roles->pluck('id'), ['class' => 'form-control select2', 'placeholder' => 'Select Roles', 'multiple']) }} + {{ Form::select('roles[]', $roles, $user?->roles->pluck('id') ?? collect(), ['class' => 'form-control select2', 'placeholder' => 'Select Roles', 'multiple']) }} @endability
    @@ -85,94 +85,3 @@ {{ Form::textarea('notes', null, ['class' => 'form-control', 'rows' => 4, 'autocomplete' => 'off']) }}
    - -
    -
    - {{-- New API Key --}} -   - @if (!$user->email_verified_at) - Verify email manually - @else - Request new email verification - @endif - - {{ Form::button('Save', ['type' => 'submit', 'class' => 'btn btn-success']) }} - Cancel -
    -
    - -
    -
    - @if($user->fields) - - - - - {{-- Custom Fields --}} - @foreach($user->fields as $field) - - - - - @endforeach -
    Custom Fields
    {{ $field->field->name }} - @if(in_array($field->name, ['IVAO', 'IVAO ID'])) - {{ $field->value }} - @elseif(in_array($field->name, ['VATSIM', 'VATSIM CID', 'VATSIM ID'])) - {{ $field->value }} - @else - {{ $field->value }} - @endif -
    - @endif -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    User Details
    Total Flights{{ $user->flights }}
    Flight Time@minutestotime($user->flight_time)
    Registered On{{ show_datetime($user->created_at) }}
    E-Mail Verified On - @if(filled($user->email_verified_at)) - {{ show_datetime($user->email_verified_at) }} - @else - USER E-MAIL NOT VERIFIED !!! - @endif -
    Last Login - @if(filled($user->lastlogin_at)) - {{ show_datetime($user->lastlogin_at) }} - @endif -
    IP Address{{ $user->last_ip ?? '-' }}
    @lang('toc.title'){{ $user->toc_accepted ? __('common.yes') : __('common.no') }}
    @lang('profile.opt-in'){{ $user->opt_in ? __('common.yes') : __('common.no') }}
    -
    -
    diff --git a/resources/views/admin/users/index.blade.php b/resources/views/admin/users/index.blade.php index f8a39e32a..8d21267ee 100644 --- a/resources/views/admin/users/index.blade.php +++ b/resources/views/admin/users/index.blade.php @@ -5,6 +5,14 @@
  • Profile Fields
  • +
  • + Add User +
  • + @if(setting('general.invite_only_registrations', false)) +
  • + Invites +
  • + @endif
  • @lang(UserState::label(UserState::PENDING))
  • diff --git a/resources/views/layouts/default/auth/register.blade.php b/resources/views/layouts/default/auth/register.blade.php index 2b95cace8..6e6fdf0be 100644 --- a/resources/views/layouts/default/auth/register.blade.php +++ b/resources/views/layouts/default/auth/register.blade.php @@ -105,6 +105,11 @@ @endif @endif + @if($invite) + {{ Form::hidden('invite', $invite->id) }} + {{ Form::hidden('invite_token', base64_encode($invite->token)) }} + @endif +
    @include('auth.toc')
    diff --git a/resources/views/notifications/mail/user/invite.blade.php b/resources/views/notifications/mail/user/invite.blade.php new file mode 100644 index 000000000..c38d21c4b --- /dev/null +++ b/resources/views/notifications/mail/user/invite.blade.php @@ -0,0 +1,12 @@ +@component('mail::message') + # You have been invited to join {{ config('app.name') }}! + + You can use the link below to register an account with this email address. + + @component('mail::button', ['url' => $invite->link]) + Register now + @endcomponent + + Thanks,
    + Management, {{ config('app.name') }} +@endcomponent diff --git a/tests/RegistrationTest.php b/tests/RegistrationTest.php index fe01a8222..13b8263bf 100644 --- a/tests/RegistrationTest.php +++ b/tests/RegistrationTest.php @@ -2,13 +2,17 @@ namespace Tests; +use App\Models\Airline; +use App\Models\Airport; use App\Models\Enums\UserState; +use App\Models\Invite; use App\Models\User; use App\Notifications\Messages\AdminUserRegistered; use App\Services\UserService; use Illuminate\Auth\Events\Verified; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Notification; +use Symfony\Component\HttpKernel\Exception\HttpException; class RegistrationTest extends TestCase { @@ -42,4 +46,158 @@ public function testRegistration() Notification::assertSentTo([$admin], AdminUserRegistered::class); Notification::assertNotSentTo([$user], AdminUserRegistered::class); } + + protected function getUserData(): array + { + $airline = Airline::factory()->create(); + $home = Airport::factory()->create(['hub' => true]); + + return [ + 'name' => 'Test User', + 'email' => 'test@phpvms.net', + 'airline_id' => $airline->id, + 'home_airport_id' => $home->id, + 'password' => 'secret', + 'password_confirmation' => 'secret', + 'toc_accepted' => true, + ]; + } + + public function testAccessToRegistrationWhenRegistrationEnabled(): void + { + $this->updateSetting('general.disable_registrations', false); + $this->updateSetting('general.invite_only_registrations', false); + + $this->get('/register') + ->assertOk(); + + $this->post('/register', $this->getUserData()) + ->assertRedirect('/dashboard'); + } + + public function testAccessToRegistrationWhenRegistrationDisabled(): void + { + $this->updateSetting('general.disable_registrations', true); + + $this->expectException(HttpException::class); + + $this->get('/register') + ->assertForbidden(); + + $this->post('/register', $this->getUserData()) + ->assertForbidden(); + } + + public function testAccessWithoutInvite(): void + { + $this->updateSetting('general.disable_registrations', false); + $this->updateSetting('general.invite_only_registrations', true); + + $this->expectException(HttpException::class); + + $this->get('/register') + ->assertForbidden(); + + $this->post('/register', $this->getUserData()) + ->assertForbidden(); + } + + public function testAccessWithValidInvite(): void + { + $this->updateSetting('general.disable_registrations', false); + $this->updateSetting('general.invite_only_registrations', true); + + $invite = Invite::create([ + 'token' => 'test', + ]); + + $this->get($invite->link) + ->assertOk(); + + $userData = array_merge($this->getUserData(), [ + 'invite' => $invite->id, + 'invite_token' => base64_encode($invite->token), + ]); + + $this->post('/register', $userData) + ->assertRedirect('/dashboard'); + } + + public function testAccessWithInvalidInvite(): void + { + $this->updateSetting('general.disable_registrations', false); + $this->updateSetting('general.invite_only_registrations', true); + + $this->expectException(HttpException::class); + + // Expired invite + $expiredInvite = Invite::create([ + 'token' => 'test', + 'expires_at' => now()->subDay(), + ]); + + $expiredUserData = array_merge($this->getUserData(), [ + 'invite' => $expiredInvite->id, + 'invite_token' => base64_encode($expiredInvite->token), + ]); + + $this->get($expiredInvite->link) + ->assertForbidden(); + + $this->post('/register', $expiredUserData) + ->assertForbidden(); + + // Invalid token + $invalidUserData = array_merge($this->getUserData(), [ + 'invite' => 1, + 'invite_token' => 'invalid', + ]); + + $this->get('/register?invite=1&invite_token=invalid') + ->assertForbidden(); + + $this->post('/register', $invalidUserData) + ->assertForbidden(); + + // Invite used too many times + $tooUsedInvite = Invite::create([ + 'token' => 'test', + 'usage_count' => 1, + 'usage_limit' => 1, + ]); + + $tooUsedUserData = array_merge($this->getUserData(), [ + 'invite' => $tooUsedInvite->id, + 'invite_token' => base64_encode($tooUsedInvite->token), + ]); + + $this->get($tooUsedInvite->link) + ->assertForbidden(); + + $this->post('/register', $tooUsedUserData) + ->assertForbidden(); + } + + public function testWithInvalidEmail() + { + $this->updateSetting('general.disable_registrations', false); + $this->updateSetting('general.invite_only_registrations', true); + + $this->expectException(HttpException::class); + $invite = Invite::create([ + 'email' => 'invited_email@phpvms.net', + 'token' => 'test', + ]); + + $userData = array_merge($this->getUserData(), [ + 'invite' => $invite->id, + 'invite_token' => base64_encode($invite->token), + ]); + + $this->get($invite->link) + ->assertOk(); + + $this->post('/register', $userData) + ->assertForbidden(); + } }