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