559 lines
15 KiB
PHP
559 lines
15 KiB
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;
|
|
}
|
|
} |