Current File : /home/pacjaorg/www/nsa/administrator/components/com_akeebabackup/webpush/WebPushModelTrait.php |
<?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;
}
}