Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.97% covered (warning)
70.97%
44 / 62
87.50% covered (success)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebPushService
70.97% covered (warning)
70.97%
44 / 62
87.50% covered (success)
87.50%
7 / 8
42.54
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isEnabled
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 getVapidPublicKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generateVapidKeys
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 sendToAll
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 sendToUser
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 sendToUsers
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 sendToSubscriptions
43.75% covered (danger)
43.75%
14 / 32
0.00% covered (danger)
0.00%
0 / 1
19.39
1<?php
2
3/**
4 * Web Push notification service.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ\Push
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-02
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Push;
21
22use Minishlink\WebPush\MessageSentReport;
23use Minishlink\WebPush\Subscription;
24use Minishlink\WebPush\VAPID;
25use Minishlink\WebPush\WebPush;
26use phpMyFAQ\Configuration;
27use phpMyFAQ\Entity\PushSubscriptionEntity;
28
29readonly class WebPushService
30{
31    public function __construct(
32        private Configuration $configuration,
33        private PushSubscriptionRepository $repository,
34    ) {
35    }
36
37    /**
38     * Checks if Web Push is enabled and configured.
39     */
40    public function isEnabled(): bool
41    {
42        $enableWebPush = $this->configuration->get('push.enableWebPush');
43        // Handle null, string 'true', and boolean true
44        $isEnabled = $enableWebPush !== null && ($enableWebPush === 'true' || $enableWebPush === true);
45
46        $vapidPublicKey = $this->configuration->get('push.vapidPublicKey');
47        $vapidPrivateKey = $this->configuration->get('push.vapidPrivateKey');
48
49        return $isEnabled && (string) $vapidPublicKey !== '' && (string) $vapidPrivateKey !== '';
50    }
51
52    /**
53     * Returns the VAPID public key.
54     */
55    public function getVapidPublicKey(): string
56    {
57        return (string) ($this->configuration->get('push.vapidPublicKey') ?? '');
58    }
59
60    /**
61     * Generates a new VAPID key pair.
62     *
63     * @return array{publicKey: string, privateKey: string}
64     */
65    public static function generateVapidKeys(): array
66    {
67        $vapidKeys = VAPID::createVapidKeys();
68
69        return [
70            'publicKey' => (string) ($vapidKeys['publicKey'] ?? ''),
71            'privateKey' => (string) ($vapidKeys['privateKey'] ?? ''),
72        ];
73    }
74
75    /**
76     * Sends a push notification to all subscribers.
77     */
78    public function sendToAll(string $title, string $body, string $url = '', string $tag = ''): void
79    {
80        if (!$this->isEnabled()) {
81            return;
82        }
83
84        $subscriptions = $this->repository->getAll();
85        if ($subscriptions === []) {
86            return;
87        }
88
89        $this->sendToSubscriptions($subscriptions, $title, $body, $url, $tag);
90    }
91
92    /**
93     * Sends a push notification to a specific user.
94     */
95    public function sendToUser(int $userId, string $title, string $body, string $url = '', string $tag = ''): void
96    {
97        if (!$this->isEnabled()) {
98            return;
99        }
100
101        $subscriptions = $this->repository->getByUserId($userId);
102        if ($subscriptions === []) {
103            return;
104        }
105
106        $this->sendToSubscriptions($subscriptions, $title, $body, $url, $tag);
107    }
108
109    /**
110     * Sends a push notification to multiple specific users.
111     *
112     * @param int[] $userIds
113     */
114    public function sendToUsers(array $userIds, string $title, string $body, string $url = '', string $tag = ''): void
115    {
116        if (!$this->isEnabled() || $userIds === []) {
117            return;
118        }
119
120        $subscriptions = $this->repository->getByUserIds($userIds);
121        if ($subscriptions === []) {
122            return;
123        }
124
125        $this->sendToSubscriptions($subscriptions, $title, $body, $url, $tag);
126    }
127
128    /**
129     * @param PushSubscriptionEntity[] $subscriptions
130     */
131    private function sendToSubscriptions(
132        array $subscriptions,
133        string $title,
134        string $body,
135        string $url,
136        string $tag,
137    ): void {
138        $auth = [
139            'VAPID' => [
140                'subject' =>
141                    $this->configuration->get('push.vapidSubject') !== ''
142                    && $this->configuration->get('push.vapidSubject') !== null
143                        ? $this->configuration->get('push.vapidSubject')
144                        : 'mailto:' . $this->configuration->getAdminEmail(),
145                'publicKey' => $this->configuration->get('push.vapidPublicKey'),
146                'privateKey' => $this->configuration->get('push.vapidPrivateKey'),
147            ],
148        ];
149
150        try {
151            $webPush = new WebPush($auth);
152
153            $payload = json_encode([
154                'title' => $title,
155                'body' => $body,
156                'url' => $url,
157                'tag' => $tag,
158                'icon' => $this->configuration->getDefaultUrl() . 'assets/img/phpmyfaq.svg',
159            ], JSON_THROW_ON_ERROR);
160
161            foreach ($subscriptions as $subscription) {
162                $webPush->queueNotification(Subscription::create([
163                    'endpoint' => $subscription->getEndpoint(),
164                    'publicKey' => $subscription->getPublicKey(),
165                    'authToken' => $subscription->getAuthToken(),
166                    'contentEncoding' => $subscription->getContentEncoding() ?? 'aesgcm',
167                ]), $payload);
168            }
169
170            foreach ($webPush->flush() as $report) {
171                if (!$report instanceof MessageSentReport || !$report->isSubscriptionExpired()) {
172                    continue;
173                }
174
175                $this->repository->deleteByEndpoint($report->getEndpoint());
176            }
177        } catch (\Throwable $exception) {
178            $this->configuration->getLogger()->error('Web Push notification failed: ' . $exception->getMessage());
179        }
180    }
181}