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