From 396351124372fac209a24d05e5d117808e42127d Mon Sep 17 00:00:00 2001 From: Ilya Stepenko <38462532+VampireAotD@users.noreply.github.com> Date: Tue, 27 Aug 2024 16:46:31 +0300 Subject: [PATCH] Refactored code --- .github/workflows/frontend-tests.yml | 2 +- .github/workflows/lint.yml | 2 +- src/.env.example | 1 + src/.env.testing | 1 + ...elegramUserDTO.php => TelegramUserDTO.php} | 20 +++++- .../Telegram/TelegramUserException.php | 15 +++++ .../Telegram/TelegramController.php | 29 ++------- .../Telegram/ValidateSignatureMiddleware.php | 22 +++---- .../Http/Requests/Telegram/AssignRequest.php | 2 +- .../Jobs/Telegram/RegisterTelegramUserJob.php | 9 ++- src/app/Models/User.php | 12 ++++ src/app/Services/TelegramUserService.php | 58 +++++++++++++----- src/app/Telegram/Commands/StartCommand.php | 6 +- src/config/mail.php | 9 +++ .../integration-list/IntegrationList.vue | 23 +++---- .../Concerns/Fake/CanCreateFakeUsers.php | 10 +++ .../Telegram/TelegramControllerTest.php | 40 +++--------- .../Services/TelegramUserServiceTest.php | 61 +++++++++++++++++++ 18 files changed, 214 insertions(+), 108 deletions(-) rename src/app/DTO/Service/Telegram/User/{RegisterTelegramUserDTO.php => TelegramUserDTO.php} (54%) create mode 100644 src/app/Exceptions/Service/Telegram/TelegramUserException.php create mode 100644 src/tests/Feature/Services/TelegramUserServiceTest.php diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 62d959f9..771d9ba2 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: node-version: [ 20.x ] - pnpm-version: [ 9.3 ] + pnpm-version: [ 9.7 ] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6e25594b..c2f82016 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: node-version: [ 20.x ] - pnpm-version: [ 9.3 ] + pnpm-version: [ 9.7 ] steps: - uses: actions/checkout@v4 diff --git a/src/.env.example b/src/.env.example index 5c7aa159..b7ae80e8 100644 --- a/src/.env.example +++ b/src/.env.example @@ -59,6 +59,7 @@ MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS=example@gmail.com MAIL_FROM_NAME="${APP_NAME}" +MAIL_TEMPORARY_DOMAIN=anilibrary-temporary.mail AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= diff --git a/src/.env.testing b/src/.env.testing index 765410f0..50763f0a 100644 --- a/src/.env.testing +++ b/src/.env.testing @@ -59,6 +59,7 @@ MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS=testing@gmail.com MAIL_FROM_NAME="${APP_NAME}" +MAIL_TEMPORARY_DOMAIN=anilibrary-testing-temporary.mail AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= diff --git a/src/app/DTO/Service/Telegram/User/RegisterTelegramUserDTO.php b/src/app/DTO/Service/Telegram/User/TelegramUserDTO.php similarity index 54% rename from src/app/DTO/Service/Telegram/User/RegisterTelegramUserDTO.php rename to src/app/DTO/Service/Telegram/User/TelegramUserDTO.php index 04faa589..946b47c8 100644 --- a/src/app/DTO/Service/Telegram/User/RegisterTelegramUserDTO.php +++ b/src/app/DTO/Service/Telegram/User/TelegramUserDTO.php @@ -4,21 +4,35 @@ namespace App\DTO\Service\Telegram\User; +use App\DTO\Contracts\FromArray; use Illuminate\Contracts\Support\Arrayable; /** * @template-implements Arrayable */ -final readonly class RegisterTelegramUserDTO implements Arrayable +final readonly class TelegramUserDTO implements FromArray, Arrayable { public function __construct( public int $telegramId, public ?string $firstName = null, public ?string $lastName = null, - public ?string $userName = null + public ?string $username = null ) { } + /** + * @param array{id: int, first_name?: string, last_name?: string, username?: string} $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['id'], + $data['first_name'] ?? null, + $data['last_name'] ?? null, + $data['username'] ?? null + ); + } + /** * Get the instance as an array. * @@ -30,7 +44,7 @@ public function toArray(): array 'telegram_id' => $this->telegramId, 'first_name' => $this->firstName, 'last_name' => $this->lastName, - 'username' => $this->userName, + 'username' => $this->username, ]; } } diff --git a/src/app/Exceptions/Service/Telegram/TelegramUserException.php b/src/app/Exceptions/Service/Telegram/TelegramUserException.php new file mode 100644 index 00000000..15cb37db --- /dev/null +++ b/src/app/Exceptions/Service/Telegram/TelegramUserException.php @@ -0,0 +1,15 @@ +get('id'), - $request->get('first_name'), - $request->get('last_name'), - $request->get('username'), - ); - try { - $this->telegramUserService->createAndAttach($request->user(), $dto); + $this->telegramUserService->assign($request->user(), TelegramUserDTO::fromArray($request->validated())); + + return back(); } catch (Throwable $e) { Log::error('Failed to assign telegram user', [ 'exception_trace' => $e->getTraceAsString(), @@ -41,20 +35,5 @@ public function assign(AssignRequest $request): RedirectResponse return back()->withErrors(['message' => $e->getMessage()]); } - - return back(); - } - - /** - * @param Request $request - * @return RedirectResponse - */ - public function detach(Request $request): RedirectResponse - { - if (!$request->user()?->telegramUser()?->delete()) { - return back()->withErrors(['message' => 'Could not revoke Telegram account']); - } - - return back(); } } diff --git a/src/app/Http/Middleware/Telegram/ValidateSignatureMiddleware.php b/src/app/Http/Middleware/Telegram/ValidateSignatureMiddleware.php index 13554a10..bab858c9 100644 --- a/src/app/Http/Middleware/Telegram/ValidateSignatureMiddleware.php +++ b/src/app/Http/Middleware/Telegram/ValidateSignatureMiddleware.php @@ -4,12 +4,17 @@ namespace App\Http\Middleware\Telegram; +use App\Services\TelegramUserService; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; -class ValidateSignatureMiddleware +readonly class ValidateSignatureMiddleware { + public function __construct(private TelegramUserService $telegramUserService) + { + } + /** * Handle an incoming request. * @@ -22,19 +27,8 @@ public function handle(Request $request, Closure $next): Response $telegramHash = $request->get('hash'); abort_if(!$telegramHash, Response::HTTP_BAD_REQUEST, 'Missing Telegram signature'); - $hashedToken = hash('sha256', config('nutgram.token'), true); - - // Transform all request fields into signature - $signature = $request->collect() - ->except('hash') - ->map(fn(mixed $value, string $key) => sprintf('%s=%s', $key, $value)) - ->values() - ->sort() - ->implode(PHP_EOL); - - $hashedSignature = hash_hmac('sha256', $signature, $hashedToken); - - abort_if(!hash_equals($telegramHash, $hashedSignature), Response::HTTP_FORBIDDEN); + $signature = $this->telegramUserService->generateSignature($request->toArray()); + abort_if(!hash_equals($telegramHash, $signature), Response::HTTP_FORBIDDEN); return $next($request); } diff --git a/src/app/Http/Requests/Telegram/AssignRequest.php b/src/app/Http/Requests/Telegram/AssignRequest.php index a273511c..3b4b24fb 100644 --- a/src/app/Http/Requests/Telegram/AssignRequest.php +++ b/src/app/Http/Requests/Telegram/AssignRequest.php @@ -25,7 +25,7 @@ public function authorize(): bool public function rules(): array { return [ - 'id' => 'required|int', + 'id' => 'required|int|unique:telegram_users,telegram_id', 'auth_date' => 'required|int', 'hash' => 'required|string', 'first_name' => 'nullable|string', diff --git a/src/app/Jobs/Telegram/RegisterTelegramUserJob.php b/src/app/Jobs/Telegram/RegisterTelegramUserJob.php index 672b931c..dd97f04e 100644 --- a/src/app/Jobs/Telegram/RegisterTelegramUserJob.php +++ b/src/app/Jobs/Telegram/RegisterTelegramUserJob.php @@ -4,14 +4,16 @@ namespace App\Jobs\Telegram; -use App\DTO\Service\Telegram\User\RegisterTelegramUserDTO; +use App\DTO\Service\Telegram\User\TelegramUserDTO; use App\Enums\QueueEnum; +use App\Exceptions\Service\Telegram\TelegramUserException; use App\Services\TelegramUserService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Throwable; class RegisterTelegramUserJob implements ShouldQueue { @@ -23,16 +25,17 @@ class RegisterTelegramUserJob implements ShouldQueue /** * Create a new job instance. */ - public function __construct(public readonly RegisterTelegramUserDTO $dto) + public function __construct(public readonly TelegramUserDTO $dto) { $this->onQueue(QueueEnum::TELEGRAM_QUEUE->value)->onConnection('redis'); } /** * Execute the job. + * @throws TelegramUserException|Throwable */ public function handle(TelegramUserService $telegramUserService): void { - $telegramUserService->upsert($this->dto); + $telegramUserService->register($this->dto); } } diff --git a/src/app/Models/User.php b/src/app/Models/User.php index 0f8f1c2b..476032cb 100644 --- a/src/app/Models/User.php +++ b/src/app/Models/User.php @@ -6,6 +6,7 @@ use App\Notifications\Auth\VerifyEmailNotification; use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasOne; @@ -56,4 +57,15 @@ public function sendEmailVerificationNotification(): void { $this->notify(new VerifyEmailNotification()); } + + /** + * @psalm-suppress TooManyTemplateParams Suppressed because PHPStan needs description, but Psalm conflicts with it + * @return Attribute + */ + protected function hasTemporaryEmail(): Attribute + { + return Attribute::make( + get: fn(): bool => str_ends_with($this->email, config('mail.temporary_domain')), + )->shouldCache(); + } } diff --git a/src/app/Services/TelegramUserService.php b/src/app/Services/TelegramUserService.php index 78825a1c..e75adcd3 100644 --- a/src/app/Services/TelegramUserService.php +++ b/src/app/Services/TelegramUserService.php @@ -4,43 +4,69 @@ namespace App\Services; -use App\DTO\Service\Telegram\User\RegisterTelegramUserDTO; +use App\DTO\Service\Telegram\User\TelegramUserDTO; +use App\Exceptions\Service\Telegram\TelegramUserException; use App\Models\TelegramUser; use App\Models\User; use App\Repositories\TelegramUser\TelegramUserRepositoryInterface; +use App\Repositories\User\UserRepositoryInterface; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Throwable; final readonly class TelegramUserService { - public function __construct(private TelegramUserRepositoryInterface $telegramUserRepository) + public function __construct( + private UserRepositoryInterface $userRepository, + private TelegramUserRepositoryInterface $telegramUserRepository + ) { + } + + public function generateSignature(array $data = []): string { + $token = hash('sha256', config('nutgram.token'), true); + + $signature = collect($data) + ->except('hash') + ->map(fn(mixed $value, string $key): string => sprintf('%s=%s', $key, $value)) + ->values() + ->sort() + ->implode(PHP_EOL); + + return hash_hmac('sha256', $signature, $token); } - public function upsert(RegisterTelegramUserDTO $dto): TelegramUser + public function upsert(TelegramUserDTO $dto): TelegramUser { return $this->telegramUserRepository->upsert($dto->toArray()); } /** - * @throws Throwable + * @throws TelegramUserException|Throwable */ - public function createAndAttach(User $user, RegisterTelegramUserDTO $dto): TelegramUser + public function register(TelegramUserDTO $dto): TelegramUser { - return DB::transaction(function () use ($dto, $user): TelegramUser { - /** @var TelegramUser $telegramUser */ - $telegramUser = TelegramUser::withTrashed()->updateOrCreate( - ['telegram_id' => $dto->telegramId], - $dto->toArray() - ); + if ($this->telegramUserRepository->findByTelegramId($dto->telegramId)) { + throw TelegramUserException::userAlreadyRegistered(); + } - if ($telegramUser->trashed()) { - $telegramUser->restore(); - } + $domain = config('mail.temporary_domain'); - $telegramUser->user()->associate($user)->save(); + return DB::transaction(function () use ($dto, $domain): TelegramUser { + $user = $this->userRepository->upsert([ + 'name' => $dto->telegramId, + 'email' => "$dto->telegramId@$domain", + 'password' => Str::random(), + ]); - return $telegramUser; + $user->markEmailAsVerified(); + + return $user->telegramUser()->create($dto->toArray()); }); } + + public function assign(User $user, TelegramUserDTO $dto): void + { + $user->telegramUser()->updateOrCreate(['telegram_id' => $dto->telegramId], $dto->toArray()); + } } diff --git a/src/app/Telegram/Commands/StartCommand.php b/src/app/Telegram/Commands/StartCommand.php index 808d4f6d..0f8fcc4e 100644 --- a/src/app/Telegram/Commands/StartCommand.php +++ b/src/app/Telegram/Commands/StartCommand.php @@ -4,7 +4,7 @@ namespace App\Telegram\Commands; -use App\DTO\Service\Telegram\User\RegisterTelegramUserDTO; +use App\DTO\Service\Telegram\User\TelegramUserDTO; use App\Enums\Telegram\Actions\ActionEnum; use App\Enums\Telegram\Buttons\CommandButtonEnum; use App\Jobs\Telegram\RegisterTelegramUserJob; @@ -25,11 +25,11 @@ public function handle(Nutgram $bot): void if ($user && !$user->is_bot) { RegisterTelegramUserJob::dispatch( - new RegisterTelegramUserDTO( + new TelegramUserDTO( telegramId: $user->id, firstName : $user->first_name, lastName : $user->last_name, - userName : $user->username + username : $user->username ) ); } diff --git a/src/config/mail.php b/src/config/mail.php index 788cc501..f2e01f6d 100644 --- a/src/config/mail.php +++ b/src/config/mail.php @@ -127,4 +127,13 @@ ], ], + /*------------------------------------------------------------------------- + | Email Domain + |-------------------------------------------------------------------------- + | + | Temporary email domain used for registration and verification. + | + */ + 'temporary_domain' => env('MAIL_TEMPORARY_DOMAIN'), + ]; diff --git a/src/resources/js/features/profile/integration-list/IntegrationList.vue b/src/resources/js/features/profile/integration-list/IntegrationList.vue index 3a4fae4d..e52d1fcb 100644 --- a/src/resources/js/features/profile/integration-list/IntegrationList.vue +++ b/src/resources/js/features/profile/integration-list/IntegrationList.vue @@ -3,9 +3,10 @@ import { TelegramLoginWidget } from '@/features/telegram/login-widget'; import { TelegramUser } from '@/entities/telegram-user'; import { router, usePage } from '@inertiajs/vue3'; import { computed } from 'vue'; -import { Button } from '@/shared/ui/button'; +import { useToast } from 'primevue/usetoast'; const page = usePage(); +const toast = useToast(); const telegramUser = computed(() => page.props.auth.user.telegram_user); const handleTelegramLogin = (user: TelegramUser) => { @@ -13,12 +14,14 @@ const handleTelegramLogin = (user: TelegramUser) => { router.post(route('telegram.assign'), payload, { preserveScroll: true, - }); -}; - -const revokeTelegramAccount = () => { - router.delete(route('telegram.detach'), { - preserveScroll: true, + onError: (response) => { + toast.add({ + summary: response?.id, + severity: 'error', + life: 2000, + closable: true, + }); + }, }); }; @@ -51,14 +54,12 @@ const revokeTelegramAccount = () => { Connected as {{ telegramUser.username ?? telegramUser.telegram_id }}

- - + + diff --git a/src/tests/Concerns/Fake/CanCreateFakeUsers.php b/src/tests/Concerns/Fake/CanCreateFakeUsers.php index 485e44c0..6ec04806 100644 --- a/src/tests/Concerns/Fake/CanCreateFakeUsers.php +++ b/src/tests/Concerns/Fake/CanCreateFakeUsers.php @@ -5,6 +5,7 @@ namespace Tests\Concerns\Fake; use App\Enums\RoleEnum; +use App\Models\TelegramUser; use App\Models\User; trait CanCreateFakeUsers @@ -28,4 +29,13 @@ protected function createAdmin(array $data = []): User { return $this->createUser($data)->assignRole(RoleEnum::ADMIN); } + + protected function createUserWithTelegramAccount(): User + { + $user = $this->createUser(); + + $user->telegramUser()->save(TelegramUser::factory()->make()); + + return $user; + } } diff --git a/src/tests/Feature/Http/Controllers/Telegram/TelegramControllerTest.php b/src/tests/Feature/Http/Controllers/Telegram/TelegramControllerTest.php index 23f6fd11..a93cd39b 100644 --- a/src/tests/Feature/Http/Controllers/Telegram/TelegramControllerTest.php +++ b/src/tests/Feature/Http/Controllers/Telegram/TelegramControllerTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Http\Controllers\Telegram; use App\Models\TelegramUser; +use App\Services\TelegramUserService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\WithFaker; use Tests\Concerns\Fake\CanCreateFakeUsers; @@ -16,18 +17,13 @@ class TelegramControllerTest extends TestCase use WithFaker; use CanCreateFakeUsers; - private function createValidSignature(array $data = []): string - { - $hashedToken = hash('sha256', config('nutgram.token'), true); + private TelegramUserService $telegramUserService; - $signature = collect($data) - ->except('hash') - ->map(fn(mixed $value, string $key) => sprintf('%s=%s', $key, $value)) - ->values() - ->sort() - ->implode(PHP_EOL); + protected function setUp(): void + { + parent::setUp(); - return hash_hmac('sha256', $signature, $hashedToken); + $this->telegramUserService = $this->app->make(TelegramUserService::class); } private function getValidTelegramData(): array @@ -40,15 +36,15 @@ private function getValidTelegramData(): array 'username' => $this->faker->userName, ]; - return [...$data, 'hash' => $this->createValidSignature($data)]; + return [...$data, 'hash' => $this->telegramUserService->generateSignature($data)]; } - public function testUserCannotConnectToTelegramWithoutTelegramSignature(): void + public function testUserCannotAssignTelegramAccountWithoutTelegramSignature(): void { $this->actingAs($this->createUser())->post(route('telegram.assign'))->assertBadRequest(); } - public function testUserCannotConnectToTelegramWithInvalidTelegramSignature(): void + public function testUserCannotAssignTelegramAccountWithInvalidTelegramSignature(): void { $this->actingAs($this->createUser()) ->post(route('telegram.assign'), ['hash' => $this->faker->word]) @@ -60,6 +56,7 @@ public function testUserCannotAssignTelegramAccountIfHeAlreadyHasOne(): void $user = $this->createUser(); $user->telegramUser()->save(TelegramUser::factory()->make()); + // RedirectIfHasAssignedUserMiddleware is applied to this route, it will handle this case $this->actingAs($user) ->post(route('telegram.assign'), $this->getValidTelegramData()) ->assertRedirect() @@ -76,21 +73,4 @@ public function testUserCanAssignHisTelegramAccount(): void $user->refresh(); $this->assertNotNull($user->telegramUser); } - - public function testUserCanDetachHisTelegramAccount(): void - { - $user = $this->createUser(); - - $telegramUser = $user->telegramUser()->save(TelegramUser::factory()->make()); - $this->assertInstanceOf(TelegramUser::class, $telegramUser); - $this->assertEquals($user->id, $telegramUser->user_id); - - $this->actingAs($user)->delete(route('telegram.detach'))->assertRedirect(); - - $user->refresh(); - $telegramUser->refresh(); - - $this->assertNull($user->telegramUser); - $this->assertSoftDeleted($telegramUser); - } } diff --git a/src/tests/Feature/Services/TelegramUserServiceTest.php b/src/tests/Feature/Services/TelegramUserServiceTest.php new file mode 100644 index 00000000..556b677c --- /dev/null +++ b/src/tests/Feature/Services/TelegramUserServiceTest.php @@ -0,0 +1,61 @@ +telegramUserService = $this->app->make(TelegramUserService::class); + } + + public function testUserCannotBeRegisteredTwice(): void + { + $user = $this->createUserWithTelegramAccount()->telegramUser; + + $this->expectException(TelegramUserException::class); + $this->expectExceptionMessage(TelegramUserException::userAlreadyRegistered()->getMessage()); + + $this->telegramUserService->register( + new TelegramUserDTO( + telegramId: $user->telegram_id, + firstName : $user->first_name, + lastName : $user->last_name, + username : $user->username + ) + ); + } + + public function testRegisteredUserWillHaveTemporaryEmail(): void + { + $telegramUser = $this->telegramUserService->register( + new TelegramUserDTO( + telegramId: $this->faker->randomNumber(), + firstName : $this->faker->firstName(), + lastName : $this->faker->lastName(), + username : $this->faker->userName() + ) + ); + + $this->assertNotNull($telegramUser->user); + $this->assertTrue($telegramUser->user->has_temporary_email); + } +}