diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 7b170626..6042c631 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -2,6 +2,7 @@ namespace App\Exceptions; +use Illuminate\Routing\Exceptions\InvalidSignatureException; use Throwable; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; @@ -46,5 +47,9 @@ public function register() $this->reportable(function (Throwable $e) { // }); + + $this->renderable(function (InvalidSignatureException $e) { + return response()->view('errors.link-expired', [], 403); + }); } } diff --git a/app/Http/Controllers/ItemEmailUnsubscribeController.php b/app/Http/Controllers/ItemEmailUnsubscribeController.php new file mode 100644 index 00000000..aef63ce0 --- /dev/null +++ b/app/Http/Controllers/ItemEmailUnsubscribeController.php @@ -0,0 +1,21 @@ +isSubscribedToItem($item)) { + return redirect()->route('home'); + } + + $user->toggleVoteSubscription($item->id, Item::class); + + return redirect()->route('items.show', $item->getAttributeValue('slug')); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 4318f614..7aff5b2f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -139,6 +139,25 @@ public function needsToVerifyEmail() : bool !auth()->user()->hasVerifiedEmail(); } + public function isSubscribedToItem(Item $item): bool + { + return $item->subscribedVotes()->where('user_id', $this->id)->exists(); + } + + public function toggleVoteSubscription(int $id, string $type) + { + $vote = Vote::where('model_id', $id) + ->where('model_type', $type) + ->where('user_id', $this->id) + ->first(); + + if (!$vote) { + return; + } + + $vote->update(['subscribed' => !$vote->subscribed]); + } + public static function booted() { static::creating(function (self $user) { diff --git a/app/Notifications/Item/ItemHasNewCommentNotification.php b/app/Notifications/Item/ItemHasNewCommentNotification.php index 81be4b85..f40b6be4 100644 --- a/app/Notifications/Item/ItemHasNewCommentNotification.php +++ b/app/Notifications/Item/ItemHasNewCommentNotification.php @@ -8,6 +8,7 @@ use Illuminate\Notifications\Notification; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Support\Facades\URL; class ItemHasNewCommentNotification extends Notification implements ShouldQueue { @@ -36,6 +37,10 @@ public function toMail($notifiable): MailMessage 'comment' => $this->comment, 'user' => $this->user, 'url' => route('items.show', $this->comment->item) . '#comment-' . $this->comment->id, + 'unsubscribeUrl' => URL::signedRoute('items.email-unsubscribe', [ + 'item' => $this->comment->item, + 'user' => $this->user, + ]), ]); } } diff --git a/app/Notifications/Item/ItemUpdatedNotification.php b/app/Notifications/Item/ItemUpdatedNotification.php index 970d48d6..4ab3b43a 100644 --- a/app/Notifications/Item/ItemUpdatedNotification.php +++ b/app/Notifications/Item/ItemUpdatedNotification.php @@ -8,6 +8,7 @@ use Illuminate\Notifications\Notification; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Support\Facades\URL; class ItemUpdatedNotification extends Notification implements ShouldQueue { @@ -31,6 +32,10 @@ public function toMail(User $notifiable): MailMessage 'user' => $notifiable, 'item' => $this->item, 'activities' => $this->item->activities()->latest()->limit(2)->get(), + 'unsubscribeUrl' => URL::signedRoute('items.email-unsubscribe', [ + 'item' => $this->item, + 'user' => $notifiable, + ]), ]); } } diff --git a/lang/en/errors.php b/lang/en/errors.php new file mode 100644 index 00000000..f7b1600a --- /dev/null +++ b/lang/en/errors.php @@ -0,0 +1,6 @@ + 'Link expired', + 'link-expired' => 'The link you clicked has expired or otherwise has become invalid. Please try again.', +]; diff --git a/lang/en/notifications.php b/lang/en/notifications.php index 38736fbb..d68dbee1 100644 --- a/lang/en/notifications.php +++ b/lang/en/notifications.php @@ -26,5 +26,6 @@ 'from' => 'From', 'on' => 'on', - 'unsubscribe-info' => 'If you do not want to receive notifications like this anymore, you can unsubscribe from your profile.', + 'unsubscribe-info' => 'If you do not want to receive notifications like this anymore, you can unsubscribe from your profile or by clicking the link below.', + 'unsubscribe-link' => 'Unsubscribe from future emails about this item.', ]; diff --git a/resources/views/emails/item/new-comment.blade.php b/resources/views/emails/item/new-comment.blade.php index 03807b42..79368e1f 100644 --- a/resources/views/emails/item/new-comment.blade.php +++ b/resources/views/emails/item/new-comment.blade.php @@ -18,4 +18,8 @@ {{ trans('notifications.salutation') }}
{{ config('app.name') }} + + + {{ trans('notifications.unsubscribe-link') }} + @endcomponent diff --git a/resources/views/emails/item/updated.blade.php b/resources/views/emails/item/updated.blade.php index 2e671982..43f0c8c4 100644 --- a/resources/views/emails/item/updated.blade.php +++ b/resources/views/emails/item/updated.blade.php @@ -18,4 +18,8 @@ {{ trans('notifications.salutation') }}
{{ config('app.name') }} + + + {{ trans('notifications.unsubscribe-link') }} + @endcomponent diff --git a/resources/views/errors/link-expired.blade.php b/resources/views/errors/link-expired.blade.php new file mode 100644 index 00000000..1a7e838a --- /dev/null +++ b/resources/views/errors/link-expired.blade.php @@ -0,0 +1,5 @@ +@section('title', trans('errors.link-expired.title')) + + +

{{ trans('errors.link-expired') }}

+
diff --git a/routes/web.php b/routes/web.php index e353cb29..9d14ff98 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ name('mention-search'); Route::get('user/{username}', \App\Http\Controllers\PublicUserController::class)->name('public-user'); }); + +Route::get('/unsubscribe/{item}/{user}', [ItemEmailUnsubscribeController::class, '__invoke']) + ->name('items.email-unsubscribe') + ->middleware('signed'); diff --git a/tests/Feature/Controllers/ItemEmailUnsubscribeControllerTest.php b/tests/Feature/Controllers/ItemEmailUnsubscribeControllerTest.php new file mode 100644 index 00000000..6497bc58 --- /dev/null +++ b/tests/Feature/Controllers/ItemEmailUnsubscribeControllerTest.php @@ -0,0 +1,101 @@ +create(); + $item = Item::factory()->create(); + + DB::table('votes')->insert([ + 'user_id' => $user->id, + 'model_id' => $item->id, + 'model_type' => Item::class, + 'subscribed' => true, + ]); + + $this->assertTrue($user->isSubscribedToItem($item)); + + $response = $this->get(URL::signedRoute('items.email-unsubscribe', [ + 'item' => $item, + 'user' => $user, + ])); + + $this->assertFalse($user->isSubscribedToItem($item)); + + $this->assertDatabaseHas('votes', [ + 'user_id' => $user->id, + 'model_id' => $item->id, + 'model_type' => Item::class, + 'subscribed' => false, + ]); + + $response->assertRedirect(route('items.show', $item->getAttributeValue('slug'))); + $this->assertGuest(); +}); + +it('does not resubscribe a user who clicks the link whilst unsubscribed', function () { + + $user = User::factory()->create(); + $item = Item::factory()->create(); + + DB::table('votes')->insert([ + 'user_id' => $user->id, + 'model_id' => $item->id, + 'model_type' => Item::class, + 'subscribed' => false, + ]); + + $this->assertFalse($user->isSubscribedToItem($item)); + + $response = $this->get(URL::signedRoute('items.email-unsubscribe', [ + 'item' => $item, + 'user' => $user, + ])); + + $this->assertFalse($user->isSubscribedToItem($item)); + $response->assertRedirect(route('home')); + $this->assertGuest(); +}); + +it('returns a 403 if the signed link has been modified', function () { + + $user = User::factory()->create(); + $item = Item::factory()->create(); + + $response = $this->get(URL::signedRoute('items.email-unsubscribe', [ + 'item' => $item, + 'user' => $user, + ]) . '&foo=bar'); + + $response->assertStatus(403); +}); + +it('returns a 404 if the item does not exist', function () { + + $user = User::factory()->create(); + $item = Item::factory()->create(); + + $response = $this->get(URL::signedRoute('items.email-unsubscribe', [ + 'item' => 999, + 'user' => $user, + ])); + + $response->assertStatus(404); +}); + +it('returns a 404 if the user does not exist', function () { + + $user = User::factory()->create(); + $item = Item::factory()->create(); + + $response = $this->get(URL::signedRoute('items.email-unsubscribe', [ + 'item' => $item, + 'user' => 999, + ])); + + $response->assertStatus(404); +}); diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php index 62f64c6a..25e75b97 100644 --- a/tests/Unit/Models/UserTest.php +++ b/tests/Unit/Models/UserTest.php @@ -5,6 +5,7 @@ use App\Enums\UserRole; use App\Models\Comment; use App\Settings\GeneralSettings; +use Illuminate\Support\Facades\DB; it('can generate an username upon user creation', function () { $user = createUser(); @@ -111,3 +112,60 @@ expect($user->fresh())->toBeNull(); }); + +it('can return true if a user is a subscribed to an item', function () { + + $user = createUser(); + $item = Item::factory()->create(); + + DB::table('votes')->insert([ + 'user_id' => $user->id, + 'model_id' => $item->id, + 'model_type' => Item::class, + 'subscribed' => true, + ]); + + $this->assertTrue($user->isSubscribedToItem($item)); +}); + +it('can return false if a user is not subscribed to an item', function () { + + $user = createUser(); + $item = Item::factory()->create(); + + DB::table('votes')->insert([ + 'user_id' => $user->id, + 'model_id' => $item->id, + 'model_type' => Item::class, + 'subscribed' => false, + ]); + + $this->assertFalse($user->isSubscribedToItem($item)); +}); + +it('toggles the subscription state of a vote the user belongs to', function () { + + $user = createUser(); + $item = Item::factory()->create(); + + DB::table('votes')->insert([ + 'user_id' => $user->id, + 'model_id' => $item->id, + 'model_type' => Item::class, + 'subscribed' => false, + ]); + + $user->toggleVoteSubscription($item->id, Item::class); + + $this->assertTrue($user->isSubscribedToItem($item)); +}); + +it('does not toggle the subscription if the user does not have a vote for that item', function () { + + $user = createUser(); + $item = Item::factory()->create(); + + $user->toggleVoteSubscription($item->id, Item::class); + + $this->assertFalse($user->isSubscribedToItem($item)); +});