Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 173 |
|
0.00% |
0 / 16 |
CRAP | |
0.00% |
0 / 1 |
webpush | |
0.00% |
0 / 173 |
|
0.00% |
0 / 16 |
3422 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
get_type | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
is_available | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
is_enabled_by_default | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
get_notified_users | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
30 | |||
notify | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
6 | |||
notify_using_webpush | |
0.00% |
0 / 64 |
|
0.00% |
0 / 1 |
210 | |||
mark_notifications | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
30 | |||
mark_notifications_by_parent | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
30 | |||
prune_notifications | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
clean_data | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
get_ucp_template_data | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
20 | |||
get_user_subscription_map | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
remove_subscriptions | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
clean_expired_subscriptions | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
56 | |||
set_endpoint_padding | |
0.00% |
0 / 3 |
|
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 | |
14 | namespace phpbb\notification\method; |
15 | |
16 | use Minishlink\WebPush\Subscription; |
17 | use phpbb\config\config; |
18 | use phpbb\controller\helper; |
19 | use phpbb\db\driver\driver_interface; |
20 | use phpbb\form\form_helper; |
21 | use phpbb\log\log_interface; |
22 | use phpbb\notification\type\type_interface; |
23 | use phpbb\user; |
24 | use phpbb\user_loader; |
25 | |
26 | /** |
27 | * Web Push notification method class |
28 | * This class handles sending push messages for notifications |
29 | */ |
30 | |
31 | class 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 | } |