* @license GNU General Public License, version 2 (GPL-2.0) * * For full copyright and license information, please see * the docs/CREDITS.txt file. * */ namespace phpbb\notification\method; use Minishlink\WebPush\Subscription; use phpbb\config\config; use phpbb\controller\helper; use phpbb\db\driver\driver_interface; use phpbb\form\form_helper; use phpbb\log\log_interface; use phpbb\notification\type\type_interface; use phpbb\user; use phpbb\user_loader; /** * Web Push notification method class * This class handles sending push messages for notifications */ class webpush extends base implements extended_method_interface { /** @var config */ protected $config; /** @var driver_interface */ protected $db; /** @var log_interface */ protected $log; /** @var user_loader */ protected $user_loader; /** @var user */ protected $user; /** @var string */ protected $phpbb_root_path; /** @var string */ protected $php_ext; /** @var string Notification Web Push table */ protected $notification_webpush_table; /** @var string Notification push subscriptions table */ protected $push_subscriptions_table; /** @var int Fallback size for padding if endpoint is mozilla, see https://github.com/web-push-libs/web-push-php/issues/108#issuecomment-2133477054 */ const MOZILLA_FALLBACK_PADDING = 2820; /** @var array Map for storing push token between db insertion and sending of notifications */ private array $push_token_map = []; /** * Notification Method Web Push constructor * * @param config $config * @param driver_interface $db * @param log_interface $log * @param user_loader $user_loader * @param user $user * @param string $phpbb_root_path * @param string $php_ext * @param string $notification_webpush_table * @param string $push_subscriptions_table */ public function __construct(config $config, driver_interface $db, log_interface $log, user_loader $user_loader, user $user, string $phpbb_root_path, string $php_ext, string $notification_webpush_table, string $push_subscriptions_table) { $this->config = $config; $this->db = $db; $this->log = $log; $this->user_loader = $user_loader; $this->user = $user; $this->phpbb_root_path = $phpbb_root_path; $this->php_ext = $php_ext; $this->notification_webpush_table = $notification_webpush_table; $this->push_subscriptions_table = $push_subscriptions_table; } /** * {@inheritDoc} */ public function get_type(): string { return 'notification.method.webpush'; } /** * {@inheritDoc} */ public function is_available(type_interface|null $notification_type = null): bool { return $this->config['webpush_enable'] && $this->config['webpush_vapid_public'] && $this->config['webpush_vapid_private']; } /** * {@inheritDoc} */ public function is_enabled_by_default() { return (bool) $this->config['webpush_method_default_enable']; } /** * {@inheritdoc} */ public function get_notified_users($notification_type_id, array $options): array { $notified_users = []; $sql = 'SELECT user_id FROM ' . $this->notification_webpush_table . ' WHERE notification_type_id = ' . (int) $notification_type_id . (isset($options['item_id']) ? ' AND item_id = ' . (int) $options['item_id'] : '') . (isset($options['item_parent_id']) ? ' AND item_parent_id = ' . (int) $options['item_parent_id'] : '') . (isset($options['user_id']) ? ' AND user_id = ' . (int) $options['user_id'] : ''); $result = $this->db->sql_query($sql); while ($row = $this->db->sql_fetchrow($result)) { $notified_users[$row['user_id']] = $row; } $this->db->sql_freeresult($result); return $notified_users; } /** * Parse the queue and notify the users */ public function notify() { $insert_buffer = new \phpbb\db\sql_insert_buffer($this->db, $this->notification_webpush_table); /** @var type_interface $notification */ foreach ($this->queue as $notification) { $data = $notification->get_insert_array(); $data += [ 'push_data' => json_encode(array_merge( $data, ['notification_type_name' => $notification->get_type()], )), 'notification_time' => time(), 'push_token' => hash('sha256', random_bytes(32)) ]; $data = self::clean_data($data); $insert_buffer->insert($data); $this->push_token_map[$notification->notification_type_id][$notification->item_id] = $data['push_token']; } $insert_buffer->flush(); $this->notify_using_webpush(); return false; } /** * Notify using Web Push * * @return void */ protected function notify_using_webpush(): void { if (empty($this->queue)) { return; } // Load all users we want to notify $user_ids = []; foreach ($this->queue as $notification) { $user_ids[] = $notification->user_id; } // Do not send push notifications to banned users if (!function_exists('phpbb_get_banned_user_ids')) { include($this->phpbb_root_path . 'includes/functions_user.' . $this->php_ext); } $banned_users = phpbb_get_banned_user_ids($user_ids); // Load all the users we need $notify_users = array_diff($user_ids, $banned_users); $this->user_loader->load_users($notify_users, array(USER_IGNORE)); // Get subscriptions for users $user_subscription_map = $this->get_user_subscription_map($notify_users); $auth = [ 'VAPID' => [ 'subject' => generate_board_url(false), 'publicKey' => $this->config['webpush_vapid_public'], 'privateKey' => $this->config['webpush_vapid_private'], ], ]; $web_push = new \Minishlink\WebPush\WebPush($auth); $number_of_notifications = 0; $remove_subscriptions = []; // Time to go through the queue and send notifications /** @var type_interface $notification */ foreach ($this->queue as $notification) { $user = $this->user_loader->get_user($notification->user_id); $user_subscriptions = $user_subscription_map[$notification->user_id] ?? []; if ($user['user_type'] == USER_INACTIVE && $user['user_inactive_reason'] == INACTIVE_MANUAL || empty($user_subscriptions)) { continue; } // Add actual Web Push data $data = [ 'item_id' => $notification->item_id, 'type_id' => $notification->notification_type_id, 'user_id' => $notification->user_id, 'version' => $this->config['assets_version'], 'token' => hash('sha256', $user['user_form_salt'] . $this->push_token_map[$notification->notification_type_id][$notification->item_id]), ]; $json_data = json_encode($data); foreach ($user_subscriptions as $subscription) { try { $this->set_endpoint_padding($web_push, $subscription['endpoint']); $push_subscription = Subscription::create([ 'endpoint' => $subscription['endpoint'], 'keys' => [ 'p256dh' => $subscription['p256dh'], 'auth' => $subscription['auth'], ], ]); $web_push->queueNotification($push_subscription, $json_data); $number_of_notifications++; } catch (\ErrorException $exception) { $remove_subscriptions[] = $subscription['subscription_id']; $this->log->add('user', $user['user_id'], $user['user_ip'] ?? '', 'LOG_WEBPUSH_SUBSCRIPTION_REMOVED', false, [ 'reportee_id' => $user['user_id'], $user['username'], ]); } } } // Remove any subscriptions that couldn't be queued, i.e. that have invalid data $this->remove_subscriptions($remove_subscriptions); // List to fill with expired subscriptions based on return $expired_endpoints = []; try { foreach ($web_push->flush($number_of_notifications) as $report) { if (!$report->isSuccess()) { // Fill array of endpoints to remove if subscription has expired if ($report->isSubscriptionExpired()) { $expired_endpoints[] = $report->getEndpoint(); } else { $report_data = \phpbb\json\sanitizer::sanitize($report->jsonSerialize()); $this->log->add('admin', ANONYMOUS, '', 'LOG_WEBPUSH_MESSAGE_FAIL', false, [$report_data['reason']]); } } } } catch (\ErrorException $exception) { $this->log->add('critical', ANONYMOUS, '', 'LOG_WEBPUSH_MESSAGE_FAIL', false, [$exception->getMessage()]); } $this->clean_expired_subscriptions($user_subscription_map, $expired_endpoints); // We're done, empty the queue $this->empty_queue(); } /** * {@inheritdoc} */ public function mark_notifications($notification_type_id, $item_id, $user_id, $time = false, $mark_read = true) { $sql = 'DELETE FROM ' . $this->notification_webpush_table . ' 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') . ($user_id !== false ? ' AND ' . $this->db->sql_in_set('user_id', $user_id) : '') . ($item_id !== false ? ' AND ' . $this->db->sql_in_set('item_id', $item_id) : ''); $this->db->sql_query($sql); } /** * {@inheritdoc} */ public function mark_notifications_by_parent($notification_type_id, $item_parent_id, $user_id, $time = false, $mark_read = true) { $sql = 'DELETE FROM ' . $this->notification_webpush_table . ' 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') . ($user_id !== false ? ' AND ' . $this->db->sql_in_set('user_id', $user_id) : '') . ($item_parent_id !== false ? ' AND ' . $this->db->sql_in_set('item_parent_id', $item_parent_id, false, true) : ''); $this->db->sql_query($sql); } /** * {@inheritDoc} */ public function prune_notifications($timestamp, $only_read = true): void { $sql = 'DELETE FROM ' . $this->notification_webpush_table . ' WHERE notification_time < ' . (int) $timestamp; $this->db->sql_query($sql); $this->config->set('read_notification_last_gc', (string) time(), false); } /** * Clean data to contain only what we need for webpush notifications table * * @param array $data Notification data * @return array Cleaned notification data */ public static function clean_data(array $data): array { $row = [ 'notification_type_id' => null, 'item_id' => null, 'item_parent_id' => null, 'user_id' => null, 'push_data' => null, 'push_token' => null, 'notification_time' => null, ]; return array_intersect_key($data, $row); } /** * Get template data for the UCP * * @param helper $controller_helper * @param form_helper $form_helper * * @return array */ public function get_ucp_template_data(helper $controller_helper, form_helper $form_helper): array { $subscription_map = $this->get_user_subscription_map([$this->user->id()]); $subscriptions = []; if (isset($subscription_map[$this->user->id()])) { foreach ($subscription_map[$this->user->id()] as $subscription) { $subscriptions[] = [ 'endpoint' => $subscription['endpoint'], 'expirationTime' => $subscription['expiration_time'], ]; } } return [ 'NOTIFICATIONS_WEBPUSH_ENABLE' => $this->config['webpush_dropdown_subscribe'] || stripos($this->user->page['page'], 'notification_options'), 'U_WEBPUSH_SUBSCRIBE' => $controller_helper->route('phpbb_ucp_push_subscribe_controller'), 'U_WEBPUSH_UNSUBSCRIBE' => $controller_helper->route('phpbb_ucp_push_unsubscribe_controller'), 'VAPID_PUBLIC_KEY' => $this->config['webpush_vapid_public'], 'U_WEBPUSH_WORKER_URL' => $controller_helper->route('phpbb_ucp_push_worker_controller'), 'SUBSCRIPTIONS' => $subscriptions, 'WEBPUSH_FORM_TOKENS' => $form_helper->get_form_tokens(\phpbb\ucp\controller\webpush::FORM_TOKEN_UCP), ]; } /** * Get subscriptions for notify users * * @param array $notify_users Users to notify * * @return array Subscription map */ protected function get_user_subscription_map(array $notify_users): array { // Get subscriptions for users $user_subscription_map = []; $sql = 'SELECT subscription_id, user_id, endpoint, p256dh, auth, expiration_time FROM ' . $this->push_subscriptions_table . ' WHERE ' . $this->db->sql_in_set('user_id', $notify_users); $result = $this->db->sql_query($sql); while ($row = $this->db->sql_fetchrow($result)) { $user_subscription_map[$row['user_id']][] = $row; } $this->db->sql_freeresult($result); return $user_subscription_map; } /** * Remove subscriptions * * @param array $subscription_ids Subscription ids to remove * @return void */ public function remove_subscriptions(array $subscription_ids): void { if (count($subscription_ids)) { $sql = 'DELETE FROM ' . $this->push_subscriptions_table . ' WHERE ' . $this->db->sql_in_set('subscription_id', $subscription_ids); $this->db->sql_query($sql); } } /** * Clean expired subscriptions from the database * * @param array $user_subscription_map User subscription map * @param array $expired_endpoints Expired endpoints * @return void */ protected function clean_expired_subscriptions(array $user_subscription_map, array $expired_endpoints): void { if (!count($expired_endpoints)) { return; } $remove_subscriptions = []; foreach ($expired_endpoints as $endpoint) { foreach ($user_subscription_map as $subscriptions) { foreach ($subscriptions as $subscription) { if (isset($subscription['endpoint']) && $subscription['endpoint'] == $endpoint) { $remove_subscriptions[] = $subscription['subscription_id']; } } } } $this->remove_subscriptions($remove_subscriptions); } /** * Set web push padding for endpoint * * @param \Minishlink\WebPush\WebPush $web_push * @param string $endpoint * * @return void */ protected function set_endpoint_padding(\Minishlink\WebPush\WebPush $web_push, string $endpoint): void { if (str_contains($endpoint, 'mozilla.com') || str_contains($endpoint, 'mozaws.net')) { try { $web_push->setAutomaticPadding(self::MOZILLA_FALLBACK_PADDING); } catch (\Exception) { // This shouldn't happen since we won't pass padding length outside limits } } } }