<?php /** * Akeeba WebPush * * An abstraction layer for easier implementation of WebPush in Joomla components. * * @copyright (c) 2022 Akeeba Ltd * @license GNU GPL v3 or later; see LICENSE.txt * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ namespace Akeeba\WebPush; use Akeeba\WebPush\WebPush\MessageSentReport; use Akeeba\WebPush\WebPush\Subscription; use Akeeba\WebPush\WebPush\VAPID; use Exception; use Joomla\Application\ApplicationInterface; use Joomla\CMS\Cache\CacheControllerFactoryInterface; use Joomla\CMS\Cache\Controller\CallbackController; use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Factory; use Joomla\CMS\Uri\Uri; use Joomla\Database\DatabaseInterface; use Joomla\Database\ParameterType; use RuntimeException; use Throwable; /** * Trait for models implementing Web Push * * @since 1.0.0 */ trait WebPushModelTrait { /** * Internal cache of VAPID keys per component * * @since 1.0.0 * @var array */ private static $vapidKeys = []; /** * The component parameters key holding the VAPID keys configuration * * @since 1.0.0 * @var string */ private $webPushConfigKey; /** * The current component, e.g. com_example * * @since 1.0.0 * @var string */ private $webPushOption; /** * Return the VAPID keys for this component * * @return array{publicKey: string, privateKey: string} * @since 1.0.0 */ public function getVapidKeys(): ?array { if (is_array(self::$vapidKeys[$this->webPushOption] ?? null)) { return self::$vapidKeys[$this->webPushOption]; } $json = ComponentHelper::getParams($this->webPushOption)->get($this->webPushConfigKey); if (!empty($json)) { try { self::$vapidKeys[$this->webPushOption] = @json_decode($json, true); } catch (Exception $e) { self::$vapidKeys[$this->webPushOption] = null; } } if ( is_array(self::$vapidKeys[$this->webPushOption]) && isset(self::$vapidKeys[$this->webPushOption]['publicKey']) && isset(self::$vapidKeys[$this->webPushOption]['privateKey'])) { return self::$vapidKeys[$this->webPushOption]; } try { self::$vapidKeys[$this->webPushOption] = $this->getNewVapidKeys(); } catch (\ErrorException $e) { return null; } return self::$vapidKeys[$this->webPushOption]; } /** * Returns the user's Web Push subscription object, or NULL if it's not defined or invalid. * * @param int|null $user_id The user ID to get the subscription for. NULL for current user. * * @return object[]|null The Web Push subscription object. NULL if not defined or invalid. * @throws Exception * @since 1.0.0 */ public function getWebPushSubscriptions(?int $user_id = null): ?array { if (empty($user_id)) { $app = Factory::getApplication(); $user_id = $app->getIdentity()->id; } $key = $this->webPushOption . '.webPushSubscription'; /** @var DatabaseInterface $db */ $db = method_exists($this, 'getDatabase') ? $this->getDatabase() : $this->getDbo(); $query = $db->getQuery(true) ->select($db->quoteName('profile_value')) ->from($db->quoteName('#__user_profiles')) ->where([ $db->quoteName('user_id') . ' = :user_id', $db->quoteName('profile_key') . ' = :key', ]) ->bind(':user_id', $user_id, ParameterType::INTEGER) ->bind(':key', $key, ParameterType::STRING); $json = $db->setQuery($query)->loadResult() ?: null; if (empty($json)) { return null; } try { $array = @json_decode($json) ?: null; if (!is_array($array)) { return null; } return $array; } catch (Exception $e) { return null; } } /** * Send a notification to all the user's subscribed browsers. * * @param string $title Notification title * @param array $options Notification options * @param int|null $user_id Optional. The user_id of the subscribed user. NULL for current user. * @param object|null $subscription Optional. A specific subscription to send the notifications to. * * @return array * * @throws \ErrorException * @since 1.0.0 */ public function sendNotification(string $title, array $options, ?int $user_id = null, ?object $subscription = null): array { // Get the user's subscriptions (or use a forced subscription) $subscriptions = is_object($subscription) ? [$subscription] : $this->getWebPushSubscriptions($user_id); if (empty($subscriptions)) { return []; } // Convert the raw subscription data to Subscription objects $subscriptions = array_map( function ($subData) { try { return new Subscription( $subData->endpoint, $subData->keys->p256dh, $subData->keys->auth ); } catch (\ErrorException $e) { return null; } }, $subscriptions ); $subscriptions = array_filter( $subscriptions, function ($x) { return $x !== null; } ); // Get the WebPush object $vapidKeys = $this->getVapidKeys(); $auth = ($vapidKeys === null) ? [] : [ 'VAPID' => [ 'subject' => Uri::root(), 'publicKey' => $vapidKeys['publicKey'], 'privateKey' => $vapidKeys['privateKey'], ], ]; $webPush = new WebPush\WebPush($auth); // Get the payload as JSON $payload = json_encode([ 'title' => $title, 'options' => $options, ]); // Send all notifications $reports = []; foreach ($subscriptions as $subscription) { $reports[] = $webPush->sendOneNotification($subscription, $payload); } return $reports; } /** * Save the Web Push user subscription record sent from the browser * * @param string $json The JSON serialised Web Push registration sent by the browser * * @return void * @throws Exception * @since 1.0.0 */ public function webPushSaveSubscription(string $json): void { // Try to decode the JSON we retrieved from the browser try { $subscriptionData = @json_decode($json); } catch (Exception $e) { $subscriptionData = null; } // Validate the format of the data we received from the browser if ( !is_object($subscriptionData) || !isset($subscriptionData->endpoint) || !isset($subscriptionData->keys) || !is_object($subscriptionData->keys) || !isset($subscriptionData->keys->p256dh) || !is_string($subscriptionData->keys->p256dh) || empty($subscriptionData->keys->p256dh) || !isset($subscriptionData->keys->auth) || !is_string($subscriptionData->keys->auth) || empty($subscriptionData->keys->auth) ) { throw new RuntimeException('Invalid Web Push user subscription record'); } // Get the user options key and the user ID $user = Factory::getApplication()->getIdentity(); $user_id = $user->id; $key = $this->webPushOption . '.webPushSubscription'; // Get any existing subscriptions, append the new one $subscriptions = $this->getWebPushSubscriptions() ?: []; $subscriptions[] = $subscriptionData ?: []; // Remove any existing options /** @var DatabaseInterface $db */ $db = method_exists($this, 'getDatabase') ? $this->getDatabase() : $this->getDbo(); $query = $db->getQuery(true) ->delete($db->quoteName('#__user_profiles')) ->where([ $db->quoteName('user_id') . ' = :user_id', $db->quoteName('profile_key') . ' = :key', ]) ->bind(':user_id', $user_id, ParameterType::INTEGER) ->bind(':key', $key, ParameterType::STRING); $db->setQuery($query)->execute(); // Add the new options $profileObject = (object) [ 'user_id' => $user_id, 'profile_key' => $key, 'profile_value' => json_encode($subscriptions), 'ordering' => 0, ]; $db->insertObject('#__user_profiles', $profileObject); } /** * Remove the Web Push user subscription record sent from the browser * * @param string $json The JSON serialised Web Push registration sent by the browser * * @return void * @throws Exception * @since 1.0.0 */ public function webPushRemoveSubscription(string $json): void { // Try to decode the JSON we retrieved from the browser try { $subscriptionData = @json_decode($json); } catch (Exception $e) { $subscriptionData = null; } if ($subscriptionData === null) { return; } // Validate the format of the data we received from the browser if ( !is_object($subscriptionData) || !isset($subscriptionData->endpoint) || !isset($subscriptionData->keys) || !is_object($subscriptionData->keys) || !isset($subscriptionData->keys->p256dh) || !is_string($subscriptionData->keys->p256dh) || empty($subscriptionData->keys->p256dh) || !isset($subscriptionData->keys->auth) || !is_string($subscriptionData->keys->auth) || empty($subscriptionData->keys->auth) ) { throw new RuntimeException('Invalid Web Push user subscription record'); } // Get the user options key and the user ID $user = Factory::getApplication()->getIdentity(); $user_id = $user->id; $key = $this->webPushOption . '.webPushSubscription'; // Get any existing subscriptions, remove the specified one $subscriptions = $this->getWebPushSubscriptions() ?: []; $index = null; foreach ($subscriptions as $k => $v) { if ( $v->endpoint === $subscriptionData->endpoint && $v->keys->p256dh === $subscriptionData->keys->p256dh && $v->keys->auth === $subscriptionData->keys->auth ) { $index = $k; break; } } if ($index === null) { return; } unset($subscriptions[$k]); $subscriptions = array_values($subscriptions); // Remove any existing options /** @var DatabaseInterface $db */ $db = method_exists($this, 'getDatabase') ? $this->getDatabase() : $this->getDbo(); $query = $db->getQuery(true) ->delete($db->quoteName('#__user_profiles')) ->where([ $db->quoteName('user_id') . ' = :user_id', $db->quoteName('profile_key') . ' = :key', ]) ->bind(':user_id', $user_id, ParameterType::INTEGER) ->bind(':key', $key, ParameterType::STRING); $db->setQuery($query)->execute(); // Add the new options $profileObject = (object) [ 'user_id' => $user_id, 'profile_key' => $key, 'profile_value' => json_encode($subscriptions), 'ordering' => 0, ]; $db->insertObject('#__user_profiles', $profileObject); } /** * Initialise the Web Push integration * * @param string $option The current component, e.g. com_example * @param string $configKey The component's configuration key holding the VAPID keys * * @return void * @since 1.0.0 */ protected function initialiseWebPush(string $option, string $configKey = 'vapidKey'): void { $this->webPushOption = $option; $this->webPushConfigKey = $configKey; } /** * Clear a cache group. * * Used internally when saving the component's options after creating new VAPID keys. * * @param string $group The cache to clean, e.g. com_content * @param int $client_id The application ID for which the cache will be cleaned * @param ApplicationInterface $app The current CMS application. * * @return array Cache controller options, including cleaning result * @throws Exception * @since 1.0.0 */ private function clearCacheGroup(string $group, int $client_id, ApplicationInterface $app): array { // Get the default cache folder. Start by using the JPATH_CACHE constant. $cacheBaseDefault = JPATH_CACHE; $appClientId = 0; if (method_exists($app, 'getClientId')) { $appClientId = $app->getClientId(); } // -- If we are asked to clean cache on the other side of the application we need to find a new cache base if ($client_id != $appClientId) { $cacheBaseDefault = (($client_id) ? JPATH_SITE : JPATH_ADMINISTRATOR) . '/cache'; } // Get the cache controller's options $options = [ 'defaultgroup' => $group, 'cachebase' => $app->get('cache_path', $cacheBaseDefault), 'result' => true, ]; try { $container = Factory::getContainer(); if (empty($container)) { throw new RuntimeException('Cannot get Joomla 4 application container'); } /** @var CacheControllerFactoryInterface $cacheControllerFactory */ $cacheControllerFactory = $container->get('cache.controller.factory'); if (empty($cacheControllerFactory)) { throw new RuntimeException('Cannot get Joomla 4 cache controller factory'); } /** @var CallbackController $cache */ $cache = $cacheControllerFactory->createCacheController('callback', $options); if (empty($cache) || !property_exists($cache, 'cache') || !method_exists($cache->cache, 'clean')) { throw new RuntimeException('Cannot get Joomla 4 cache controller'); } $cache->cache->clean(); } catch (Throwable $e) { $options['result'] = false; } return $options; } /** * Create, save and return new VAPID keys. * * DO NOT RUN MORE THAN ONCE. Doing so will invalidate all Web Push registrations for existing users! * * @return array{publicKey: string, privateKey: string} * @throws \ErrorException * @since 1.0.0 */ private function getNewVapidKeys(): array { $vapidKeys = VAPID::createVapidKeys(); $params = ComponentHelper::getParams($this->webPushOption); $params->set($this->webPushConfigKey, json_encode($vapidKeys)); /** @var DatabaseInterface $db */ $db = method_exists($this, 'getDatabase') ? $this->getDatabase() : $this->getDbo(); $data = $params->toString('JSON'); $sql = $db->getQuery(true) ->update($db->qn('#__extensions')) ->set($db->qn('params') . ' = ' . $db->q($data)) ->where($db->qn('element') . ' = :option') ->where($db->qn('type') . ' = ' . $db->q('component')) ->bind(':option', $this->webPushOption); $db->setQuery($sql); try { $db->execute(); // The component parameters are cached. We just changed them. Therefore we MUST reset the system cache which holds them. $app = Factory::getApplication(); $this->clearCacheGroup('_system', 0, $app); $this->clearCacheGroup('_system', 1, $app); } catch (Exception $e) { // Don't sweat if it fails } // Reset ComponentHelper's cache $refClass = new \ReflectionClass(ComponentHelper::class); $refProp = $refClass->getProperty('components'); $refProp->setAccessible(true); $components = $refProp->getValue(); $components[$this->webPushOption]->params = $params; $refProp->setValue($components); return $vapidKeys; } }