<?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/>. */ declare(strict_types=1); namespace Akeeba\WebPush\WebPush; use Base64Url\Base64Url; use Joomla\CMS\Http\Http as HttpClient; use Joomla\CMS\Http\HttpFactory; use Joomla\CMS\Uri\Uri; use Laminas\Diactoros\Request; use Laminas\Diactoros\StreamFactory; use function count; /** * This class is a derivative work based on the WebPush library by Louis Lagrange. It has been modified to only use * dependencies shipped with Joomla itself and must not be confused with the original work. * * You can find the original code at https://github.com/web-push-libs * * The original code came with the following copyright notice: * * ===================================================================================================================== * * This file is part of the WebPush library. * * (c) Louis Lagrange <lagrange.louis@gmail.com> * * For the full copyright and license information, please view the LICENSE-LAGRANGE.txt * file that was distributed with this source code. * * ===================================================================================================================== */ class WebPush { /** * @var array */ protected $auth; /** * @var int Automatic padding of payloads, if disabled, trade security for bandwidth */ protected $automaticPadding = Encryption::MAX_COMPATIBILITY_PAYLOAD_LENGTH; /** * @var HttpClient */ protected $client; /** * @var array Default options : TTL, urgency, topic, batchSize */ protected $defaultOptions; /** * @var null|array Array of array of Notifications */ protected $notifications; /** * @var bool Reuse VAPID headers in the same flush session to improve performance */ protected $reuseVAPIDHeaders = false; /** * @var array Dictionary for VAPID headers cache */ protected $vapidHeaders = []; /** * WebPush constructor. * * @param array $auth Some servers needs authentication * @param array $defaultOptions TTL, urgency, topic, batchSize * @param int|null $timeout Timeout of POST request * * @throws \ErrorException */ public function __construct(array $auth = [], array $defaultOptions = [], ?int $timeout = 30, array $clientOptions = []) { $extensions = [ 'curl' => '[WebPush] curl extension is not loaded but is required. You can fix this in your php.ini.', 'mbstring' => '[WebPush] mbstring extension is not loaded but is required for sending push notifications with payload or for VAPID authentication. You can fix this in your php.ini.', 'openssl' => '[WebPush] openssl extension is not loaded but is required for sending push notifications with payload or for VAPID authentication. You can fix this in your php.ini.', ]; $phpVersion = phpversion(); if ($phpVersion && version_compare($phpVersion, '7.3.0', '<')) { $extensions['gmp'] = '[WebPush] gmp extension is not loaded but is required for sending push notifications with payload or for VAPID authentication. You can fix this in your php.ini.'; } foreach ($extensions as $extension => $message) { if (!extension_loaded($extension)) { trigger_error($message, E_USER_WARNING); } } if (ini_get('mbstring.func_overload') >= 2) { trigger_error("[WebPush] mbstring.func_overload is enabled for str* functions. You must disable it if you want to send push notifications with payload or use VAPID. You can fix this in your php.ini.", E_USER_NOTICE); } if (isset($auth['VAPID'])) { $auth['VAPID'] = VAPID::validate($auth['VAPID']); } $this->auth = $auth; $this->setDefaultOptions($defaultOptions); if (!array_key_exists('timeout', $clientOptions) && isset($timeout)) { $clientOptions['timeout'] = $timeout; } $this->client = HttpFactory::getHttp($clientOptions); } public function countPendingNotifications(): int { return null !== $this->notifications ? count($this->notifications) : 0; } /** * Flush notifications. Triggers the requests. * * @param null|int $batchSize Defaults the value defined in defaultOptions during instantiation (which defaults * to 1000). * * @return \Generator|MessageSentReport[] * @throws \ErrorException */ public function flush(?int $batchSize = null): \Generator { if (empty($this->notifications)) { yield from []; return; } if (null === $batchSize) { $batchSize = $this->defaultOptions['batchSize']; } $batches = array_chunk($this->notifications, $batchSize); // reset queue $this->notifications = []; foreach ($batches as $batch) { // for each endpoint server type $requests = $this->prepare($batch); foreach ($requests as $request) { try { // So, this SHOULD work, but it doesn't because of a Joomla Framework bug. HARD MODE ENGAGED. //$response = $this->client->sendRequest($request); $httpMethod = strtolower($request->getMethod()); $headers = array_map( function ($values) { if (!is_array($values)) { return $values; } return implode(' ', $values); }, $request->getHeaders() ); $timeout = $this->client->getOption('timeout', 10); switch ($httpMethod) { case 'options': case 'head': case 'get': case 'trace': default: $response = $this->client->{$httpMethod}(new Uri($request->getUri()), $headers, $timeout); break; case 'post': case 'put': case 'delete': case 'patch': $response = $this->client->{$httpMethod}(new Uri($request->getUri()), $request->getBody()->getContents(), $headers, $timeout); break; } $success = $response->getStatusCode() >= 200 && $response->getStatusCode() < 400; $reason = $success ? 'OK' : (strip_tags($response->body) ?: $response->getReasonPhrase()); yield new MessageSentReport($request, $response, $success, $reason); } catch (\Exception $e) { yield new MessageSentReport($request, $response, false, $e->getMessage()); } } } if ($this->reuseVAPIDHeaders) { $this->vapidHeaders = []; } } /** * @return int */ public function getAutomaticPadding() { return $this->automaticPadding; } /** * @param int|bool $automaticPadding Max padding length * * @throws \Exception */ public function setAutomaticPadding($automaticPadding): WebPush { if ($automaticPadding > Encryption::MAX_PAYLOAD_LENGTH) { throw new \Exception('Automatic padding is too large. Max is ' . Encryption::MAX_PAYLOAD_LENGTH . '. Recommended max is ' . Encryption::MAX_COMPATIBILITY_PAYLOAD_LENGTH . ' for compatibility reasons (see README).'); } elseif ($automaticPadding < 0) { throw new \Exception('Padding length should be positive or zero.'); } elseif ($automaticPadding === true) { $this->automaticPadding = Encryption::MAX_COMPATIBILITY_PAYLOAD_LENGTH; } elseif ($automaticPadding === false) { $this->automaticPadding = 0; } else { $this->automaticPadding = $automaticPadding; } return $this; } public function getDefaultOptions(): array { return $this->defaultOptions; } /** * @param array $defaultOptions Keys 'TTL' (Time To Live, defaults 4 weeks), 'urgency', 'topic', 'batchSize' * * @return WebPush */ public function setDefaultOptions(array $defaultOptions) { $this->defaultOptions['TTL'] = $defaultOptions['TTL'] ?? 2419200; $this->defaultOptions['urgency'] = $defaultOptions['urgency'] ?? null; $this->defaultOptions['topic'] = $defaultOptions['topic'] ?? null; $this->defaultOptions['batchSize'] = $defaultOptions['batchSize'] ?? 1000; return $this; } /** * @return bool */ public function getReuseVAPIDHeaders() { return $this->reuseVAPIDHeaders; } /** * Reuse VAPID headers in the same flush session to improve performance * * @return WebPush */ public function setReuseVAPIDHeaders(bool $enabled) { $this->reuseVAPIDHeaders = $enabled; return $this; } public function isAutomaticPadding(): bool { return $this->automaticPadding !== 0; } /** * Queue a notification. Will be sent when flush() is called. * * @param string|null $payload If you want to send an array or object, json_encode it * @param array $options Array with several options tied to this notification. If not set, will use the * default options that you can set in the WebPush object * @param array $auth Use this auth details instead of what you provided when creating WebPush * * @throws \ErrorException */ public function queueNotification(SubscriptionInterface $subscription, ?string $payload = null, array $options = [], array $auth = []): void { if (isset($payload)) { if (Utils::safeStrlen($payload) > Encryption::MAX_PAYLOAD_LENGTH) { throw new \ErrorException('Size of payload must not be greater than ' . Encryption::MAX_PAYLOAD_LENGTH . ' octets.'); } $contentEncoding = $subscription->getContentEncoding(); if (!$contentEncoding) { throw new \ErrorException('Subscription should have a content encoding'); } $payload = Encryption::padPayload($payload, $this->automaticPadding, $contentEncoding); } if (array_key_exists('VAPID', $auth)) { $auth['VAPID'] = VAPID::validate($auth['VAPID']); } $this->notifications[] = new Notification($subscription, $payload, $options, $auth); } /** * @param string|null $payload If you want to send an array or object, json_encode it * @param array $options Array with several options tied to this notification. If not set, will use the * default options that you can set in the WebPush object * @param array $auth Use this auth details instead of what you provided when creating WebPush * * @throws \ErrorException */ public function sendOneNotification(SubscriptionInterface $subscription, ?string $payload = null, array $options = [], array $auth = []): MessageSentReport { $this->queueNotification($subscription, $payload, $options, $auth); return $this->flush()->current(); } /** * @return array * @throws \ErrorException */ protected function getVAPIDHeaders(string $audience, string $contentEncoding, array $vapid) { $vapidHeaders = null; $cache_key = null; if ($this->reuseVAPIDHeaders) { $cache_key = implode('#', [$audience, $contentEncoding, crc32(serialize($vapid))]); if (array_key_exists($cache_key, $this->vapidHeaders)) { $vapidHeaders = $this->vapidHeaders[$cache_key]; } } if (!$vapidHeaders) { $vapidHeaders = VAPID::getVapidHeaders($audience, $vapid['subject'], $vapid['publicKey'], $vapid['privateKey'], $contentEncoding); } if ($this->reuseVAPIDHeaders) { $this->vapidHeaders[$cache_key] = $vapidHeaders; } return $vapidHeaders; } /** * @return Request[] * @throws \ErrorException * */ protected function prepare(array $notifications): array { $requests = []; foreach ($notifications as $notification) { \assert($notification instanceof Notification); $subscription = $notification->getSubscription(); $endpoint = $subscription->getEndpoint(); $userPublicKey = $subscription->getPublicKey(); $userAuthToken = $subscription->getAuthToken(); $contentEncoding = $subscription->getContentEncoding(); $payload = $notification->getPayload(); $options = $notification->getOptions($this->getDefaultOptions()); $auth = $notification->getAuth($this->auth); if (!empty($payload) && !empty($userPublicKey) && !empty($userAuthToken)) { if (!$contentEncoding) { throw new \ErrorException('Subscription should have a content encoding'); } $encrypted = Encryption::encrypt($payload, $userPublicKey, $userAuthToken, $contentEncoding); $cipherText = $encrypted['cipherText']; $salt = $encrypted['salt']; $localPublicKey = $encrypted['localPublicKey']; $headers = [ 'Content-Type' => 'application/octet-stream', 'Content-Encoding' => $contentEncoding, ]; if ($contentEncoding === "aesgcm") { $headers['Encryption'] = 'salt=' . Base64Url::encode($salt); $headers['Crypto-Key'] = 'dh=' . Base64Url::encode($localPublicKey); } $encryptionContentCodingHeader = Encryption::getContentCodingHeader($salt, $localPublicKey, $contentEncoding); $content = $encryptionContentCodingHeader . $cipherText; $headers['Content-Length'] = (string) Utils::safeStrlen($content); } else { $headers = [ 'Content-Length' => '0', ]; $content = ''; } $headers['TTL'] = $options['TTL']; if (isset($options['urgency'])) { $headers['Urgency'] = $options['urgency']; } if (isset($options['topic'])) { $headers['Topic'] = $options['topic']; } if (array_key_exists('VAPID', $auth) && $contentEncoding) { $audience = parse_url($endpoint, PHP_URL_SCHEME) . '://' . parse_url($endpoint, PHP_URL_HOST); if (!parse_url($audience)) { throw new \ErrorException('Audience "' . $audience . '"" could not be generated.'); } $vapidHeaders = $this->getVAPIDHeaders($audience, $contentEncoding, $auth['VAPID']); $headers['Authorization'] = $vapidHeaders['Authorization']; if ($contentEncoding === 'aesgcm') { if (array_key_exists('Crypto-Key', $headers)) { $headers['Crypto-Key'] .= ';' . $vapidHeaders['Crypto-Key']; } else { $headers['Crypto-Key'] = $vapidHeaders['Crypto-Key']; } } } $streamFactory = new StreamFactory(); $requests[] = new Request($endpoint, 'POST', $streamFactory->createStream($content), $headers); } return $requests; } }