Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
96 / 96
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
ExecuteEmailReactionEndpoint
100.00% covered (success)
100.00%
96 / 96
100.00% covered (success)
100.00%
6 / 6
29
100.00% covered (success)
100.00%
1 / 1
 handle
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 actionUnsubscribe
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
6
 removeNotificationSubscription
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 actionResetPassword
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 actionVerifyEmail
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 actionDeleteNews
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace Olz\Common\Endpoints;
4
5use Olz\Api\OlzTypedEndpoint;
6use Olz\Constants\NotificationDeliveryType;
7use Olz\Constants\NotificationType;
8use Olz\Entity\News\NewsEntry;
9use Olz\Entity\NotificationSubscription;
10use Olz\Entity\Users\User;
11
12/**
13 * @extends OlzTypedEndpoint<
14 *   array{
15 *     token: non-empty-string,
16 *   },
17 *   array{
18 *     status: 'INVALID_TOKEN'|'OK',
19 *   }
20 * >
21 */
22class ExecuteEmailReactionEndpoint extends OlzTypedEndpoint {
23    /** @var ?array<string, mixed> */
24    protected ?array $reaction_data;
25
26    protected function handle(mixed $input): mixed {
27        $token = $input['token'];
28        $this->reaction_data = $this->emailUtils()->decryptEmailReactionToken($token);
29
30        if (!$this->reaction_data) {
31            $this->log()->error("Invalid email reaction token: {$token}", [$this->reaction_data]);
32            return ['status' => 'INVALID_TOKEN'];
33        }
34
35        $action = $this->reaction_data['action'] ?? null;
36        switch ($action) {
37            case 'unsubscribe':
38                return $this->actionUnsubscribe();
39            case 'reset_password':
40                return $this->actionResetPassword();
41            case 'verify_email':
42                return $this->actionVerifyEmail();
43            case 'delete_news':
44                return $this->actionDeleteNews();
45            default:
46                $this->log()->error("Unknown email reaction action: {$action}.", [$this->reaction_data]);
47                return ['status' => 'INVALID_TOKEN'];
48        }
49    }
50
51    protected function actionUnsubscribe(): mixed {
52        $user = intval($this->reaction_data['user'] ?? '0');
53        if ($user <= 0) {
54            $this->log()->error("Invalid user {$user} to unsubscribe from email notifications.", [$this->reaction_data]);
55            return ['status' => 'INVALID_TOKEN'];
56        }
57        $notification_subscription_repo = $this->entityManager()->getRepository(NotificationSubscription::class);
58        if (isset($this->reaction_data['notification_type'])) {
59            $notification_type = $this->reaction_data['notification_type'];
60            $subscriptions = $notification_subscription_repo->findBy([
61                'delivery_type' => NotificationDeliveryType::EMAIL,
62                'notification_type' => NotificationType::from($notification_type),
63                'user' => $user,
64            ]);
65            foreach ($subscriptions as $subscription) {
66                $this->log()->notice("Removing email subscription: {$subscription}.");
67                $this->removeNotificationSubscription($subscription);
68            }
69            $this->entityManager()->flush();
70            $this->log()->notice("Email subscriptions removed.", [$this->reaction_data]);
71            return ['status' => 'OK'];
72        }
73        if (isset($this->reaction_data['notification_type_all'])) {
74            $subscriptions = $notification_subscription_repo->findBy([
75                'delivery_type' => NotificationDeliveryType::EMAIL,
76                'user' => $user,
77            ]);
78            foreach ($subscriptions as $subscription) {
79                $this->log()->notice("Removing email subscription: {$subscription}.", [$this->reaction_data]);
80                $this->removeNotificationSubscription($subscription);
81            }
82            $this->entityManager()->flush();
83            $this->log()->notice("Email subscriptions removed.", [$this->reaction_data]);
84            return ['status' => 'OK'];
85        }
86        $this->log()->error("Invalid email notification type to unsubscribe from.", [$this->reaction_data]);
87        return ['status' => 'INVALID_TOKEN'];
88    }
89
90    protected function removeNotificationSubscription(NotificationSubscription $subscription): void {
91        // If it is an autogenerated reminder subscription, just mark it cancelled.
92        if (
93            $subscription->getNotificationType() === NotificationType::EMAIL_CONFIG_REMINDER
94            || $subscription->getNotificationType() === NotificationType::ROLE_REMINDER
95        ) {
96            $args = json_decode($subscription->getNotificationTypeArgs() ?? '{}', true) ?? [];
97            $args['cancelled'] = true;
98            $subscription->setNotificationTypeArgs(json_encode($args) ?: '{}');
99        } else {
100            $this->entityManager()->remove($subscription);
101        }
102    }
103
104    protected function actionResetPassword(): mixed {
105        $user_id = intval($this->reaction_data['user'] ?? '0');
106        $user_repo = $this->entityManager()->getRepository(User::class);
107        $user = $user_repo->findOneBy(['id' => $user_id]);
108        if (!$user) {
109            $this->log()->error("Invalid user {$user_id} to reset password.", [$this->reaction_data]);
110            return ['status' => 'INVALID_TOKEN'];
111        }
112        $new_password = $this->reaction_data['new_password'] ?? '';
113        if (strlen($new_password) < 8) {
114            $this->log()->error("New password is too short.", [$this->reaction_data]);
115            return ['status' => 'INVALID_TOKEN'];
116        }
117        $user->setPasswordHash($this->authUtils()->hashPassword($new_password));
118        $this->entityManager()->flush();
119        return ['status' => 'OK'];
120    }
121
122    protected function actionVerifyEmail(): mixed {
123        $user_id = intval($this->reaction_data['user'] ?? '0');
124        $user_repo = $this->entityManager()->getRepository(User::class);
125        $user = $user_repo->findOneBy(['id' => $user_id]);
126        if (!$user) {
127            $this->log()->error("Invalid user {$user_id} to verify email.", [$this->reaction_data]);
128            return ['status' => 'INVALID_TOKEN'];
129        }
130        $verify_email = $this->reaction_data['email'] ?? '';
131        $user_email = $user->getEmail();
132        if ($verify_email !== $user_email) {
133            $this->log()->error("Trying to verify email ({$verify_email}) for user {$user_id} (email: {$user_email}).", [$this->reaction_data]);
134            return ['status' => 'INVALID_TOKEN'];
135        }
136        $verify_token = $this->reaction_data['token'] ?? null;
137        $user_token = $user->getEmailVerificationToken();
138        if (!$verify_token || !$user_token || $verify_token !== $user_token) {
139            $this->log()->error("Invalid email verification token {$verify_token} for user {$user_id} (token: {$user_token}).", [$this->reaction_data]);
140            return ['status' => 'INVALID_TOKEN'];
141        }
142        $user->setEmailIsVerified(true);
143        $user->setEmailVerificationToken(null);
144        $user->addPermission('verified_email');
145        $this->entityManager()->flush();
146        return ['status' => 'OK'];
147    }
148
149    protected function actionDeleteNews(): mixed {
150        $news_id = $this->reaction_data['news_id'] ?? null;
151        $news_repo = $this->entityManager()->getRepository(NewsEntry::class);
152        $news_entry = $news_repo->findOneBy(['id' => $news_id]);
153        if (!$news_id || !$news_entry) {
154            $this->log()->error("Trying to delete inexistent news entry: {$news_id}.", [$this->reaction_data]);
155            return ['status' => 'INVALID_TOKEN'];
156        }
157        $this->entityUtils()->updateOlzEntity($news_entry, ['onOff' => false]);
158        $this->entityManager()->flush();
159        return ['status' => 'OK'];
160    }
161}