Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 173
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
webpush
0.00% covered (danger)
0.00%
0 / 173
0.00% covered (danger)
0.00%
0 / 16
3422
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 get_type
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_available
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 is_enabled_by_default
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_notified_users
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 notify
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 notify_using_webpush
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 1
210
 mark_notifications
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 mark_notifications_by_parent
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 prune_notifications
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 clean_data
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 get_ucp_template_data
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 get_user_subscription_map
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 remove_subscriptions
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 clean_expired_subscriptions
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
56
 set_endpoint_padding
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3*
4* This file is part of the phpBB Forum Software package.
5*
6* @copyright (c) phpBB Limited <https://www.phpbb.com>
7* @license GNU General Public License, version 2 (GPL-2.0)
8*
9* For full copyright and license information, please see
10* the docs/CREDITS.txt file.
11*
12*/
13
14namespace phpbb\notification\method;
15
16use Minishlink\WebPush\Subscription;
17use phpbb\config\config;
18use phpbb\controller\helper;
19use phpbb\db\driver\driver_interface;
20use phpbb\form\form_helper;
21use phpbb\log\log_interface;
22use phpbb\notification\type\type_interface;
23use phpbb\user;
24use phpbb\user_loader;
25
26/**
27* Web Push notification method class
28* This class handles sending push messages for notifications
29*/
30
31class webpush extends base implements extended_method_interface
32{
33    /** @var config */
34    protected $config;
35
36    /** @var driver_interface */
37    protected $db;
38
39    /** @var log_interface */
40    protected $log;
41
42    /** @var user_loader */
43    protected $user_loader;
44
45    /** @var user */
46    protected $user;
47
48    /** @var string */
49    protected $phpbb_root_path;
50
51    /** @var string */
52    protected $php_ext;
53
54    /** @var string Notification Web Push table */
55    protected $notification_webpush_table;
56
57    /** @var string Notification push subscriptions table */
58    protected $push_subscriptions_table;
59
60    /** @var int Fallback size for padding if endpoint is mozilla, see https://github.com/web-push-libs/web-push-php/issues/108#issuecomment-2133477054 */
61    const MOZILLA_FALLBACK_PADDING = 2820;
62
63    /** @var array Map for storing push token between db insertion and sending of notifications */
64    private array $push_token_map = [];
65
66    /**
67     * Notification Method Web Push constructor
68     *
69     * @param config $config
70     * @param driver_interface $db
71     * @param log_interface $log
72     * @param user_loader $user_loader
73     * @param user $user
74     * @param string $phpbb_root_path
75     * @param string $php_ext
76     * @param string $notification_webpush_table
77     * @param string $push_subscriptions_table
78     */
79    public function __construct(config $config, driver_interface $db, log_interface $log, user_loader $user_loader, user $user, string $phpbb_root_path,
80                                string $php_ext, string $notification_webpush_table, string $push_subscriptions_table)
81    {
82        $this->config = $config;
83        $this->db = $db;
84        $this->log = $log;
85        $this->user_loader = $user_loader;
86        $this->user = $user;
87        $this->phpbb_root_path = $phpbb_root_path;
88        $this->php_ext = $php_ext;
89        $this->notification_webpush_table = $notification_webpush_table;
90        $this->push_subscriptions_table = $push_subscriptions_table;
91    }
92
93    /**
94    * {@inheritDoc}
95    */
96    public function get_type(): string
97    {
98        return 'notification.method.webpush';
99    }
100
101    /**
102    * {@inheritDoc}
103    */
104    public function is_available(type_interface $notification_type = null): bool
105    {
106        return $this->config['webpush_enable']
107            && $this->config['webpush_vapid_public']
108            && $this->config['webpush_vapid_private'];
109    }
110
111    /**
112     * {@inheritDoc}
113     */
114    public function is_enabled_by_default()
115    {
116        return (bool) $this->config['webpush_method_default_enable'];
117    }
118
119    /**
120    * {@inheritdoc}
121    */
122    public function get_notified_users($notification_type_id, array $options): array
123    {
124        $notified_users = [];
125
126        $sql = 'SELECT user_id
127            FROM ' . $this->notification_webpush_table . '
128            WHERE notification_type_id = ' . (int) $notification_type_id .
129            (isset($options['item_id']) ? ' AND item_id = ' . (int) $options['item_id'] : '') .
130            (isset($options['item_parent_id']) ? ' AND item_parent_id = ' . (int) $options['item_parent_id'] : '') .
131            (isset($options['user_id']) ? ' AND user_id = ' . (int) $options['user_id'] : '');
132        $result = $this->db->sql_query($sql);
133        while ($row = $this->db->sql_fetchrow($result))
134        {
135            $notified_users[$row['user_id']] = $row;
136        }
137        $this->db->sql_freeresult($result);
138
139        return $notified_users;
140    }
141
142    /**
143    * Parse the queue and notify the users
144    */
145    public function notify()
146    {
147        $insert_buffer = new \phpbb\db\sql_insert_buffer($this->db, $this->notification_webpush_table);
148
149        /** @var type_interface $notification */
150        foreach ($this->queue as $notification)
151        {
152            $data = $notification->get_insert_array();
153            $data += [
154                'push_data'                => json_encode(array_merge(
155                    $data,
156                    ['notification_type_name' => $notification->get_type()],
157                )),
158                'notification_time'        => time(),
159                'push_token'            => hash('sha256', random_bytes(32))
160            ];
161            $data = self::clean_data($data);
162            $insert_buffer->insert($data);
163            $this->push_token_map[$notification->notification_type_id][$notification->item_id] = $data['push_token'];
164        }
165
166        $insert_buffer->flush();
167
168        $this->notify_using_webpush();
169
170        return false;
171    }
172
173    /**
174     * Notify using Web Push
175     *
176     * @return void
177     */
178    protected function notify_using_webpush(): void
179    {
180        if (empty($this->queue))
181        {
182            return;
183        }
184
185        // Load all users we want to notify
186        $user_ids = [];
187        foreach ($this->queue as $notification)
188        {
189            $user_ids[] = $notification->user_id;
190        }
191
192        // Do not send push notifications to banned users
193        if (!function_exists('phpbb_get_banned_user_ids'))
194        {
195            include($this->phpbb_root_path . 'includes/functions_user.' . $this->php_ext);
196        }
197        $banned_users = phpbb_get_banned_user_ids($user_ids);
198
199        // Load all the users we need
200        $notify_users = array_diff($user_ids, $banned_users);
201        $this->user_loader->load_users($notify_users, array(USER_IGNORE));
202
203        // Get subscriptions for users
204        $user_subscription_map = $this->get_user_subscription_map($notify_users);
205
206        $auth = [
207            'VAPID' => [
208                'subject' => generate_board_url(false),
209                'publicKey' => $this->config['webpush_vapid_public'],
210                'privateKey' => $this->config['webpush_vapid_private'],
211            ],
212        ];
213
214        $web_push = new \Minishlink\WebPush\WebPush($auth);
215
216        $number_of_notifications = 0;
217        $remove_subscriptions = [];
218
219        // Time to go through the queue and send notifications
220        /** @var type_interface $notification */
221        foreach ($this->queue as $notification)
222        {
223            $user = $this->user_loader->get_user($notification->user_id);
224
225            $user_subscriptions = $user_subscription_map[$notification->user_id] ?? [];
226
227            if ($user['user_type'] == USER_INACTIVE && $user['user_inactive_reason'] == INACTIVE_MANUAL
228                || empty($user_subscriptions))
229            {
230                continue;
231            }
232
233            // Add actual Web Push data
234            $data = [
235                'item_id'    => $notification->item_id,
236                'type_id'    => $notification->notification_type_id,
237                'user_id'    => $notification->user_id,
238                'version'    => $this->config['assets_version'],
239                'token'        => hash('sha256', $user['user_form_salt'] . $this->push_token_map[$notification->notification_type_id][$notification->item_id]),
240            ];
241            $json_data = json_encode($data);
242
243            foreach ($user_subscriptions as $subscription)
244            {
245                try
246                {
247                    $this->set_endpoint_padding($web_push, $subscription['endpoint']);
248                    $push_subscription = Subscription::create([
249                        'endpoint'            => $subscription['endpoint'],
250                        'keys'                => [
251                            'p256dh'    => $subscription['p256dh'],
252                            'auth'        => $subscription['auth'],
253                        ],
254                    ]);
255                    $web_push->queueNotification($push_subscription, $json_data);
256                    $number_of_notifications++;
257                }
258                catch (\ErrorException $exception)
259                {
260                    $remove_subscriptions[] = $subscription['subscription_id'];
261                    $this->log->add('user', $user['user_id'], $user['user_ip'] ?? '', 'LOG_WEBPUSH_SUBSCRIPTION_REMOVED', false, [
262                        'reportee_id' => $user['user_id'],
263                        $user['username'],
264                    ]);
265                }
266            }
267        }
268
269        // Remove any subscriptions that couldn't be queued, i.e. that have invalid data
270        $this->remove_subscriptions($remove_subscriptions);
271
272        // List to fill with expired subscriptions based on return
273        $expired_endpoints = [];
274
275        try
276        {
277            foreach ($web_push->flush($number_of_notifications) as $report)
278            {
279                if (!$report->isSuccess())
280                {
281                    // Fill array of endpoints to remove if subscription has expired
282                    if ($report->isSubscriptionExpired())
283                    {
284                        $expired_endpoints[] = $report->getEndpoint();
285                    }
286                    else
287                    {
288                        $report_data = \phpbb\json\sanitizer::sanitize($report->jsonSerialize());
289                        $this->log->add('admin', ANONYMOUS, '', 'LOG_WEBPUSH_MESSAGE_FAIL', false, [$report_data['reason']]);
290                    }
291                }
292            }
293        }
294        catch (\ErrorException $exception)
295        {
296            $this->log->add('critical', ANONYMOUS, '', 'LOG_WEBPUSH_MESSAGE_FAIL', false, [$exception->getMessage()]);
297        }
298
299        $this->clean_expired_subscriptions($user_subscription_map, $expired_endpoints);
300
301        // We're done, empty the queue
302        $this->empty_queue();
303    }
304
305    /**
306    * {@inheritdoc}
307    */
308    public function mark_notifications($notification_type_id, $item_id, $user_id, $time = false, $mark_read = true)
309    {
310        $sql = 'DELETE FROM ' . $this->notification_webpush_table . '
311            WHERE ' . ($notification_type_id !== false ? $this->db->sql_in_set('notification_type_id', is_array($notification_type_id) ? $notification_type_id : [$notification_type_id]) : '1=1') .
312            ($user_id !== false ? ' AND ' . $this->db->sql_in_set('user_id', $user_id) : '') .
313            ($item_id !== false ? ' AND ' . $this->db->sql_in_set('item_id', $item_id) : '');
314        $this->db->sql_query($sql);
315    }
316
317    /**
318    * {@inheritdoc}
319    */
320    public function mark_notifications_by_parent($notification_type_id, $item_parent_id, $user_id, $time = false, $mark_read = true)
321    {
322        $sql = 'DELETE FROM ' . $this->notification_webpush_table . '
323            WHERE ' . ($notification_type_id !== false ? $this->db->sql_in_set('notification_type_id', is_array($notification_type_id) ? $notification_type_id : [$notification_type_id]) : '1=1') .
324            ($user_id !== false ? ' AND ' . $this->db->sql_in_set('user_id', $user_id) : '') .
325            ($item_parent_id !== false ? ' AND ' . $this->db->sql_in_set('item_parent_id', $item_parent_id, false, true) : '');
326        $this->db->sql_query($sql);
327    }
328
329    /**
330     * {@inheritDoc}
331     */
332    public function prune_notifications($timestamp, $only_read = true): void
333    {
334        $sql = 'DELETE FROM ' . $this->notification_webpush_table . '
335            WHERE notification_time < ' . (int) $timestamp;
336        $this->db->sql_query($sql);
337
338        $this->config->set('read_notification_last_gc', (string) time(), false);
339    }
340
341    /**
342     * Clean data to contain only what we need for webpush notifications table
343     *
344     * @param array $data Notification data
345     * @return array Cleaned notification data
346     */
347    public static function clean_data(array $data): array
348    {
349        $row = [
350            'notification_type_id'    => null,
351            'item_id'                => null,
352            'item_parent_id'        => null,
353            'user_id'                => null,
354            'push_data'                => null,
355            'push_token'            => null,
356            'notification_time'        => null,
357        ];
358
359        return array_intersect_key($data, $row);
360    }
361
362    /**
363     * Get template data for the UCP
364     *
365     * @param helper $controller_helper
366     * @param form_helper $form_helper
367     *
368     * @return array
369     */
370    public function get_ucp_template_data(helper $controller_helper, form_helper $form_helper): array
371    {
372        $subscription_map = $this->get_user_subscription_map([$this->user->id()]);
373        $subscriptions = [];
374
375        if (isset($subscription_map[$this->user->id()]))
376        {
377            foreach ($subscription_map[$this->user->id()] as $subscription)
378            {
379                $subscriptions[] = [
380                    'endpoint'            => $subscription['endpoint'],
381                    'expirationTime'    => $subscription['expiration_time'],
382                ];
383            }
384        }
385
386        return [
387            'NOTIFICATIONS_WEBPUSH_ENABLE'    => $this->config['webpush_dropdown_subscribe'] || stripos($this->user->page['page'], 'notification_options'),
388            'U_WEBPUSH_SUBSCRIBE'            => $controller_helper->route('phpbb_ucp_push_subscribe_controller'),
389            'U_WEBPUSH_UNSUBSCRIBE'            => $controller_helper->route('phpbb_ucp_push_unsubscribe_controller'),
390            'VAPID_PUBLIC_KEY'                => $this->config['webpush_vapid_public'],
391            'U_WEBPUSH_WORKER_URL'            => $controller_helper->route('phpbb_ucp_push_worker_controller'),
392            'SUBSCRIPTIONS'                    => $subscriptions,
393            'WEBPUSH_FORM_TOKENS'            => $form_helper->get_form_tokens(\phpbb\ucp\controller\webpush::FORM_TOKEN_UCP),
394        ];
395    }
396
397    /**
398     * Get subscriptions for notify users
399     *
400     * @param array $notify_users Users to notify
401     *
402     * @return array Subscription map
403     */
404    protected function get_user_subscription_map(array $notify_users): array
405    {
406        // Get subscriptions for users
407        $user_subscription_map = [];
408
409        $sql = 'SELECT subscription_id, user_id, endpoint, p256dh, auth, expiration_time
410            FROM ' . $this->push_subscriptions_table . '
411            WHERE ' . $this->db->sql_in_set('user_id', $notify_users);
412        $result = $this->db->sql_query($sql);
413        while ($row = $this->db->sql_fetchrow($result))
414        {
415            $user_subscription_map[$row['user_id']][] = $row;
416        }
417        $this->db->sql_freeresult($result);
418
419        return $user_subscription_map;
420    }
421
422    /**
423     * Remove subscriptions
424     *
425     * @param array $subscription_ids Subscription ids to remove
426     * @return void
427     */
428    public function remove_subscriptions(array $subscription_ids): void
429    {
430        if (count($subscription_ids))
431        {
432            $sql = 'DELETE FROM ' . $this->push_subscriptions_table . '
433                    WHERE ' . $this->db->sql_in_set('subscription_id', $subscription_ids);
434            $this->db->sql_query($sql);
435        }
436    }
437
438    /**
439     * Clean expired subscriptions from the database
440     *
441     * @param array $user_subscription_map User subscription map
442     * @param array $expired_endpoints Expired endpoints
443     * @return void
444     */
445    protected function clean_expired_subscriptions(array $user_subscription_map, array $expired_endpoints): void
446    {
447        if (!count($expired_endpoints))
448        {
449            return;
450        }
451
452        $remove_subscriptions = [];
453        foreach ($expired_endpoints as $endpoint)
454        {
455            foreach ($user_subscription_map as $subscriptions)
456            {
457                foreach ($subscriptions as $subscription)
458                {
459                    if (isset($subscription['endpoint']) && $subscription['endpoint'] == $endpoint)
460                    {
461                        $remove_subscriptions[] = $subscription['subscription_id'];
462                    }
463                }
464            }
465        }
466
467        $this->remove_subscriptions($remove_subscriptions);
468    }
469
470    /**
471     * Set web push padding for endpoint
472     *
473     * @param \Minishlink\WebPush\WebPush $web_push
474     * @param string $endpoint
475     *
476     * @return void
477     */
478    protected function set_endpoint_padding(\Minishlink\WebPush\WebPush $web_push, string $endpoint): void
479    {
480        if (str_contains($endpoint, 'mozilla.com') || str_contains($endpoint, 'mozaws.net'))
481        {
482            try
483            {
484                $web_push->setAutomaticPadding(self::MOZILLA_FALLBACK_PADDING);
485            }
486            catch (\Exception)
487            {
488                // This shouldn't happen since we won't pass padding length outside limits
489            }
490        }
491    }
492}