diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c37da9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.idea +/vendor +composer.lock \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..4048205 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,28 @@ +language: php + +cache: + directories: + - $HOME/.composer/cache + +php: + - '7.1' + - '7.2' + - '7.3' + - nightly + +env: + - COMPOSER_FLAGS="--prefer-lowest --prefer-stable" + - COMPOSER_FLAGS="--prefer-stable" + +matrix: + fast_finish: true + allow_failures: + - php: nightly + + +install: + - composer update $COMPOSER_FLAGS --no-interaction --prefer-dist --no-progress --no-suggest --ansi + +script: + - vendor/bin/phpunit + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..30b7948 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2018 Beno!t POLASZEK + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index a65b589..82969d9 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,23 @@ +[![Latest Stable Version](https://poser.pugx.org/bentools/webpush-bundle/v/stable)](https://packagist.org/packages/bentools/webpush-bundle) +[![License](https://poser.pugx.org/bentools/webpush-bundle/license)](https://packagist.org/packages/bentools/webpush-bundle) +[![Build Status](https://img.shields.io/travis/bpolaszek/webpush-bundle/master.svg?style=flat-square)](https://travis-ci.org/bpolaszek/webpush-bundle) +[![Quality Score](https://img.shields.io/scrutinizer/g/bpolaszek/webpush-bundle.svg?style=flat-square)](https://scrutinizer-ci.com/g/bpolaszek/webpush-bundle) +[![Total Downloads](https://poser.pugx.org/bentools/webpush-bundle/downloads)](https://packagist.org/packages/bentools/webpush-bundle) + # Webpush Bundle -This bundle leverages [minishlink/web-push](https://github.com/web-push-libs/web-push-php) library to associate your Symfony users with Webpush subscriptions. +This bundle allows your app to leverage [the Web Push protocol](https://developers.google.com/web/fundamentals/push-notifications/web-push-protocol) to send notifications to your users' devices, whether they're online or not. + +With a small amount of code, you'll be able to associate your [Symfony users](https://symfony.com/doc/current/security.html#a-create-your-user-class) to WebPush Subscriptions: -This way you can integrate push messages into your app to send notifications. +* A single user can subscribe from multiple browsers/devices +* Multiple users can subscribe from a single browser/device + +This bundle uses your own persistence system (Doctrine or anything else) to manage these associations. We assume you have a minimum knowledge of how Push Notifications work, otherwise we highly recommend you to read [Matt Gaunt's Web Push Book](https://web-push-book.gauntface.com/). -## Use cases +**Example Use cases** * You have a todolist app - notify users they're assigned a task * You have an eCommerce app: @@ -25,51 +36,20 @@ We assume you have a minimum knowledge of how Push Notifications work, otherwise ## Getting started -Because there can be different User implementations, and that some front-end is implied, there are several steps to follow to get started: -1. Install the bundle and its assets -2. Create your own `UserSubscription` class and its associated manager -3. Update your `config.yml` and `routing.yml` -4. Insert a JS snippet in your twig views. - -Let's go! - -------------- +This bundle is just the back-end part of the subscription process. For the front-end part, have a look at the [webpush-client](https://www.npmjs.com/package/webpush-client) package. ### Composer is your friend: PHP7.1+ is required. ```bash -composer require bentools/webpush-bundle 0.3.* +composer require bentools/webpush-bundle 0.4.* ``` -_We aren't on stable version yet - expect some changes._ - +If you're using Symfony 3, add the bundle to your kernel. With Symfony Flex, this should be done automatically. -### Add the bundle to your kernel: -```php -# app/AppKernel.php +⚠️ _We aren't on stable version yet - expect some changes._ -class AppKernel extends Kernel -{ - public function registerBundles() - { - $bundles = [ - // ... - new BenTools\WebPushBundle\WebPushBundle(), - ]; - - return $bundles; - } -} -``` - -### Install assets: - -```bash -php bin/console assets:install --symlink -``` -_We provide a service worker and a JS client._ ### Generate your VAPID keys: @@ -78,13 +58,19 @@ _We provide a service worker and a JS client._ php bin/console webpush:generate:keys ``` +You'll have to update your config with the given keys. We encourage you to store them in environment variables or in `parameters.yml`. + Next: [Create your UserSubscription class](doc/01%20-%20The%20UserSubscription%20Class.md) ## Tests -We mostly need functionnal tests. Contributions are very welcome! +> ./vendor/bin/phpunit ## License -MIT \ No newline at end of file +MIT + +## Credits + +This bundle leverages the [minishlink/web-push](https://github.com/web-push-libs/web-push-php) library. \ No newline at end of file diff --git a/composer.json b/composer.json index e5d7b04..5d1f61a 100644 --- a/composer.json +++ b/composer.json @@ -10,11 +10,26 @@ ], "require": { "php": ">=7.1", - "minishlink/web-push": "^2.0" + "ext-json": "*", + "ext-curl": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "minishlink/web-push": "^4.0", + "symfony/http-kernel": "^3.0|^4.0" }, "require-dev": { - "symfony/var-dumper": "^3.3", - "symfony/symfony": "^2.7|^3.0|^4.0" + "bentools/doctrine-static": "1.0.x-dev", + "nyholm/symfony-bundle-test": "^1.4", + "phpunit/phpunit": "^5.0|^6.0|^7.0", + "symfony/config": "^4.1", + "symfony/dependency-injection": "^3.0|^4.0", + "symfony/framework-bundle": "^3.0|^4.0", + "symfony/http-foundation": "^3.0|^4.0", + "symfony/routing": "^3.0|^4.0", + "symfony/security": "^3.0|^4.0", + "symfony/var-dumper": "^3.0|^4.0", + "symfony/yaml": "^3.0|^4.0", + "twig/twig": "~1.0|~2.0" }, "autoload": { "psr-4": { @@ -22,8 +37,11 @@ } }, "autoload-dev": { + "psr-4": { + "BenTools\\WebPushBundle\\Tests\\": "tests" + }, "files": [ - "vendor/symfony/symfony/src/Symfony/Component/VarDumper/Resources/functions/dump.php" + "vendor/symfony/var-dumper/Resources/functions/dump.php" ] }, "config": { diff --git a/doc/01 - The UserSubscription Class.md b/doc/01 - The UserSubscription Class.md index 679ae43..2e4f968 100644 --- a/doc/01 - The UserSubscription Class.md +++ b/doc/01 - The UserSubscription Class.md @@ -1,26 +1,19 @@ -Basically: - -* A user can have several subscriptions (you can log in from several browsers) -* A single subscription can be shared among multiple users (you can log in with several accounts on the same browser). - -We need to store these associations. - ## Create your UserSubscription class First, you have to implement `BenTools\WebPushBundle\Model\Subscription\UserSubscriptionInterface`. It's a simple entity which associates: 1. Your user entity -2. The subscription details - it will store the JSON representation of the `Subscription` javascript object. +2. The subscription details - it will store the JSON representation of the `PushSubscription` javascript object. 3. A hash of the endpoint (or any string that could help in retrieving it). You're free to use Doctrine or anything else. Example class: ```php -# src/AppBundle/Entity/UserSubscription.php +# src/Entity/UserSubscription.php -namespace AppBundle\Entity; +namespace App\Entity; use BenTools\WebPushBundle\Model\Subscription\UserSubscriptionInterface; use Doctrine\ORM\Mapping as ORM; @@ -43,7 +36,7 @@ class UserSubscription implements UserSubscriptionInterface /** * @var User - * @ORM\ManyToOne(targetEntity="AppBundle\Entity\User") + * @ORM\ManyToOne(targetEntity="App\Entity\User") * @ORM\JoinColumn(nullable=false) */ private $user; @@ -104,7 +97,7 @@ class UserSubscription implements UserSubscriptionInterface */ public function getEndpoint(): string { - return $this->subscription['endpoint'] ?? null; + return $this->subscription['endpoint']; } /** @@ -112,7 +105,7 @@ class UserSubscription implements UserSubscriptionInterface */ public function getPublicKey(): string { - return $this->subscription['keys']['p256dh'] ?? null; + return $this->subscription['keys']['p256dh']; } /** @@ -120,7 +113,18 @@ class UserSubscription implements UserSubscriptionInterface */ public function getAuthToken(): string { - return $this->subscription['keys']['auth'] ?? null; + return $this->subscription['keys']['auth']; + } + + + /** + * Content-encoding (default: aesgcm). + * + * @return string + */ + public function getContentEncoding(): string + { + return $this->subscription['content-encoding'] ?? 'aesgcm'; } } diff --git a/doc/02 - The UserSubscription Manager.md b/doc/02 - The UserSubscription Manager.md index ed856f4..0f9daa1 100644 --- a/doc/02 - The UserSubscription Manager.md +++ b/doc/02 - The UserSubscription Manager.md @@ -8,11 +8,11 @@ Then, create a class that implements `BenTools\WebPushBundle\Model\Subscription\ Example with Doctrine: ```php -# src/AppBundle/Services/UserSubscriptionManager.php +# src/Services/UserSubscriptionManager.php -namespace AppBundle\Services; +namespace App\Services; -use AppBundle\Entity\UserSubscription; +use App\Entity\UserSubscription; use BenTools\WebPushBundle\Model\Subscription\UserSubscriptionInterface; use BenTools\WebPushBundle\Model\Subscription\UserSubscriptionManagerInterface; use Doctrine\Common\Persistence\ManagerRegistry; @@ -37,15 +37,17 @@ class UserSubscriptionManager implements UserSubscriptionManagerInterface /** * @inheritDoc */ - public function factory(UserInterface $user, string $subscriptionHash, array $subscription): UserSubscriptionInterface + public function factory(UserInterface $user, string $subscriptionHash, array $subscription, array $options): UserSubscriptionInterface { + // $options is an arbitrary array that can be provided through the front-end code. + // You can use it to store meta-data about the subscription: the user agent, the referring domain, ... return new UserSubscription($user, $subscriptionHash, $subscription); } /** * @inheritDoc */ - public function hash(string $endpoint): string { + public function hash(string $endpoint, UserInterface $user): string { return md5($endpoint); // Encode it as you like } @@ -101,6 +103,21 @@ class UserSubscriptionManager implements UserSubscriptionManagerInterface } ``` +Now, register your `UserSubscriptionManager` in your `services.yaml`: + +```yaml +# app/config/services.yml (SF3) +# config/services.yaml (SF4) + +services: + App\Services\UserSubscriptionManager: + class: App\Services\UserSubscriptionManager + arguments: + - '@doctrine' + tags: + - { name: bentools_webpush.subscription_manager, user_class: 'App\Entity\User' } +``` + Previous: [The UserSubscription Class](01%20-%20The%20UserSubscription%20Class.md) Next: [Configuration](03%20-%20Configuration.md) \ No newline at end of file diff --git a/doc/03 - Configuration.md b/doc/03 - Configuration.md index c8d37dc..7d95835 100644 --- a/doc/03 - Configuration.md +++ b/doc/03 - Configuration.md @@ -3,48 +3,28 @@ #### Configure the bundle: ```yaml -# app/config/config.yml +# app/config/config.yml (SF3) +# config/packages/bentools_webpush.yaml (SF4) bentools_webpush: settings: - public_key: '%bentools_webpush.public_key%' - private_key: '%bentools_webpush.private_key%' - associations: - my_users: - user_class: AppBundle\Entity\User - user_subscription_class: AppBundle\Entity\UserSubscription - manager: '@AppBundle\Services\UserSubscriptionManager' # Manager service id + public_key: 'your_public_key' + private_key: 'your_private_key' ``` #### Update your router: ```yaml -# app/config/routing.yml +# app/config/routing.yml (SF3) +# config/routing.yaml (SF4) bentools_webpush: resource: '@WebPushBundle/Resources/config/routing.xml' prefix: /webpush ``` -#### Update your services file: -```yaml -# app/config/services.yml -services: - AppBundle\Services\UserSubscriptionManager: - arguments: ["@doctrine"] -``` -#### Update your templates: +You will have a new route called `bentools_webpush` which will be the Ajax endpoint for handling subscriptions (POST requests) / unsubscriptions (DELETE requests). -Insert this snippet in the templates where your user is logged in: - -```twig - - -``` +The global variable `bentools_webpush.public_key` is now exposed in Twig. -_This will install the service worker and prompt your users to accept notifications._ +To handle subscriptions/unsubscriptions on the front-end side, have a look at [webpush-client](https://www.npmjs.com/package/webpush-client). Previous: [The UserSubscription Manager](02%20-%20The%20UserSubscription%20Manager.md) diff --git a/doc/04 - Usage.md b/doc/04 - Usage.md index 06dae71..fdd1053 100644 --- a/doc/04 - Usage.md +++ b/doc/04 - Usage.md @@ -1,54 +1,113 @@ ## Now, send notifications! +Here's a sample example of an e-commerce app which will notify both the customer and the related category managers when an order has been placed. ```php -# src/AppBundle/Services/NotificationSender.php +namespace App\Services; -namespace AppBundle\Services; +use App\Entity\Employee; +use App\Entity\Order; +use App\Events\OrderEvent; +use App\Events\OrderEvents; +use BenTools\WebPushBundle\Model\Message\PushNotification; +use BenTools\WebPushBundle\Model\Subscription\UserSubscriptionManagerRegistry; +use BenTools\WebPushBundle\Sender\GuzzleClientSender; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; -use AppBundle\Entity\User; -use BenTools\WebPushBundle\Model\Message\Notification; -use BenTools\WebPushBundle\Registry\WebPushManagerRegistry; -use Minishlink\WebPush\WebPush; -use Symfony\Component\DependencyInjection\ContainerAwareInterface; -use Symfony\Component\DependencyInjection\ContainerAwareTrait; - -class NotificationSender implements ContainerAwareInterface +class NotificationSenderListener implements EventSubscriberInterface { - use ContainerAwareTrait; + /** + * @var UserSubscriptionManagerRegistry + */ + private $userSubscriptionManager; + + /** + * @var GuzzleClientSender + */ + private $sender; + + /** + * NotificationSender constructor. + * @param UserSubscriptionManagerRegistry $userSubscriptionManager + * @param GuzzleClientSender $sender + */ + public function __construct( + UserSubscriptionManagerRegistry $userSubscriptionManager, + GuzzleClientSender $sender + ) { + $this->userSubscriptionManager = $userSubscriptionManager; + $this->sender = $sender; + } + + public static function getSubscribedEvents() + { + return [ + OrderEvents::PLACED => 'onOrderPlaced', + ]; + } + + /** + * @param OrderEvent $event + */ + public function onOrderPlaced(OrderEvent $event): void + { + $order = $event->getOrder(); + $this->notifyCustomer($order); + $this->notifyCategoryManagers($order); + } /** - * @param User $user + * @param Order $order */ - public function sendAwesomeNotification(User $user) + private function notifyCustomer(Order $order): void { - $sender = $this->container->get(WebPush::class); - $managers = $this->container->get(WebPushManagerRegistry::class); - $myUserManager = $managers->getManager($user); - foreach ($myUserManager->findByUser($user) as $subscription) { - $sender->sendNotification( - $subscription->getEndpoint(), - $this->createAwesomeNotification(), - $subscription->getPublicKey(), - $subscription->getAuthToken() - ); + $customer = $order->getCustomer(); + $subscriptions = $this->userSubscriptionManager->findByUser($customer); + $notification = new PushNotification('Congratulations!', [ + PushNotification::BODY => 'Your order has been placed.', + PushNotification::ICON => '/assets/icon_success.png', + ]); + $responses = $this->sender->push($notification->createMessage(), $subscriptions); + + foreach ($responses as $response) { + if ($response->isExpired()) { + $this->userSubscriptionManager->delete($response->getSubscription()); + } } - $sender->flush(); } /** - * @return Notification + * @param Order $order */ - private function createAwesomeNotification(): Notification + private function notifyCategoryManagers(Order $order): void { - return new Notification([ - 'title' => 'Awesome title', - 'body' => 'Symfony is great!', - 'icon' => 'https://symfony.com/logos/symfony_black_03.png', - 'data' => [ - 'link' => 'https://www.symfony.com', - ], + $products = $order->getProducts(); + $employees = []; + foreach ($products as $product) { + $employees[] = $product->getCategoryManager(); + } + + $employees = array_unique($employees); + + $subscriptions = []; + foreach ($employees as $employee) { + foreach ($this->userSubscriptionManager->findByUser($employee) as $subscription) { + $subscriptions[] = $subscription; + } + } + + $notification = new PushNotification('A new order has been placed!', [ + PushNotification::BODY => 'A customer just bought some of your products.', + PushNotification::ICON => '/assets/icon_success.png', ]); + + $responses = $this->sender->push($notification->createMessage(), $subscriptions); + + foreach ($responses as $response) { + if ($response->isExpired()) { + $this->userSubscriptionManager->delete($response->getSubscription()); + } + } } } ``` diff --git a/doc/05 - FAQ.md b/doc/05 - FAQ.md index 1992888..44790cc 100644 --- a/doc/05 - FAQ.md +++ b/doc/05 - FAQ.md @@ -14,20 +14,23 @@ It's OK. You can subscribe separately your `Employees` and your `Customers`, for Example config: ```yaml -# app/config/config.yml -bentools_webpush: - settings: - public_key: '%bentools_webpush.public_key%' - private_key: '%bentools_webpush.private_key%' - associations: - employees: - user_class: AppBundle\Entity\Employee - user_subscription_class: AppBundle\Entity\EmployeeSubscription - manager: '@AppBundle\Services\EmployeeSubscriptionManager' - customers: - user_class: AppBundle\Entity\Customer - user_subscription_class: AppBundle\Entity\CustomerSubscription - manager: '@AppBundle\Services\CustomerSubscriptionManager' +# app/config/services.yml (SF3) +# config/services.yaml (SF4) + +services: + App\Services\EmployeeSubscriptionManager: + class: App\Services\EmployeeSubscriptionManager + arguments: + - '@doctrine' + tags: + - { name: bentools_webpush.subscription_manager, user_class: 'App\Entity\Employee' } + + App\Services\CustomerSubscriptionManager: + class: App\Services\CustomerSubscriptionManager + arguments: + - '@doctrine' + tags: + - { name: bentools_webpush.subscription_manager, user_class: 'App\Entity\Customer' } ``` @@ -39,48 +42,7 @@ You can control subscriptions on the client-side. **How do I manage subscriptions / unsubscriptions from an UI point of view?** -```twig - -``` +For the front-end part of the subscription / unsubscription process, check-out the [WebPush Client Javascript Library](https://www.npmjs.com/package/webpush-client) that has been designed to work with this bundle. -**How do I handle expired subscriptions?** - -When you push a notification, you can know which endpoints failed. -After pushing, you can retrieve the corresponding recipients and manage their deletion, for instance with Doctrine: - -```php -foreach ($manager->findByUser($user) as $subscription) { - $webpush->sendNotification( - $subscription->getEndpoint(), - 'ho hi', - $subscription->getPublicKey(), - $subscription->getAuthToken() - ); -} - -$results = $webpush->flush(); - -if (is_array($results)) { - foreach ($results as $result) { - if (!empty($result['expired'])) { - foreach ($manager->findByHash($manager->hash($result['endpoint'])) as $subscription) { - $manager->delete($subscription); - } - } - } -} -``` Previous: [Usage](04%20-%20Usage.md) \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..4e9f6b1 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + tests + + + + + + src + + + diff --git a/src/Action/SubscriptionAction.php b/src/Action/RegisterSubscriptionAction.php similarity index 54% rename from src/Action/SubscriptionAction.php rename to src/Action/RegisterSubscriptionAction.php index 7185a15..f64b240 100644 --- a/src/Action/SubscriptionAction.php +++ b/src/Action/RegisterSubscriptionAction.php @@ -2,33 +2,26 @@ namespace BenTools\WebPushBundle\Action; -use BenTools\WebPushBundle\Registry\WebPushManagerRegistry; +use BenTools\WebPushBundle\Model\Subscription\UserSubscriptionManagerRegistry; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\User\UserInterface; -final class SubscriptionAction +final class RegisterSubscriptionAction { /** - * @var WebPushManagerRegistry + * @var UserSubscriptionManagerRegistry */ private $registry; /** * RegisterSubscriptionAction constructor. - * @param TokenStorageInterface $tokenStorage - * @param WebPushManagerRegistry $registry + * @param UserSubscriptionManagerRegistry $registry */ - public function __construct( - TokenStorageInterface $tokenStorage, - WebPushManagerRegistry $registry - ) { - - $this->tokenStorage = $tokenStorage; + public function __construct(UserSubscriptionManagerRegistry $registry) + { $this->registry = $registry; } @@ -36,13 +29,17 @@ public function __construct( * @param UserInterface $user * @param string $subscriptionHash * @param array $subscription + * @param array $options + * @throws \InvalidArgumentException * @throws \RuntimeException + * @throws \Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException + * @throws \Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException */ - private function processSubscription(UserInterface $user, string $subscriptionHash, array $subscription) + private function subscribe(UserInterface $user, string $subscriptionHash, array $subscription, array $options = []) { $manager = $this->registry->getManager($user); $userSubscription = $manager->getUserSubscription($user, $subscriptionHash) - or $userSubscription = $manager->factory($user, $subscriptionHash, $subscription); + or $userSubscription = $manager->factory($user, $subscriptionHash, $subscription, $options); $manager->save($userSubscription); } @@ -52,7 +49,7 @@ private function processSubscription(UserInterface $user, string $subscriptionHa * @throws BadRequestHttpException * @throws \RuntimeException */ - private function processUnsubscription(UserInterface $user, string $subscriptionHash) + private function unsubscribe(UserInterface $user, string $subscriptionHash) { $manager = $this->registry->getManager($user); $subscription = $manager->getUserSubscription($user, $subscriptionHash); @@ -63,32 +60,20 @@ private function processUnsubscription(UserInterface $user, string $subscription } /** - * @param Request $request + * @param Request $request + * @param UserInterface $user * @return Response - * @throws BadRequestHttpException - * @throws MethodNotAllowedHttpException - * @throws \LogicException - * @throws \RuntimeException */ - public function __invoke(Request $request): Response + public function __invoke(Request $request, UserInterface $user): Response { if (!in_array($request->getMethod(), ['POST', 'DELETE'])) { throw new MethodNotAllowedHttpException(['POST', 'DELETE']); } - $token = $this->tokenStorage->getToken(); - if (null === $token) { - throw new AccessDeniedHttpException('No authentication information available.'); - } - - $user = $token->getUser(); - - if (null === $user) { - throw new BadRequestHttpException("User is not logged in."); - } - - $subscription = json_decode($request->getContent(), true); + $data = json_decode($request->getContent(), true); + $subscription = $data['subscription'] ?? []; + $options = $data['options'] ?? []; if (JSON_ERROR_NONE !== json_last_error()) { throw new BadRequestHttpException(json_last_error_msg()); @@ -98,17 +83,13 @@ public function __invoke(Request $request): Response throw new BadRequestHttpException('Invalid subscription object.'); } - if (!$user instanceof UserInterface) { - throw new \RuntimeException('This bundle only works with user object that implement ' . UserInterface::class); - } - $manager = $this->registry->getManager($user); - $subscriptionHash = $manager->hash($subscription['endpoint']); + $subscriptionHash = $manager->hash($subscription['endpoint'], $user); if ('DELETE' === $request->getMethod()) { - $this->processUnsubscription($user, $subscriptionHash); + $this->unsubscribe($user, $subscriptionHash); } else { - $this->processSubscription($user, $subscriptionHash, $subscription); + $this->subscribe($user, $subscriptionHash, $subscription, $options); } return new Response('', Response::HTTP_NO_CONTENT); diff --git a/src/Command/WebPushGenerateKeysCommand.php b/src/Command/WebPushGenerateKeysCommand.php index 25359a3..433faaa 100644 --- a/src/Command/WebPushGenerateKeysCommand.php +++ b/src/Command/WebPushGenerateKeysCommand.php @@ -7,6 +7,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\HttpKernel\Kernel; final class WebPushGenerateKeysCommand extends ContainerAwareCommand { @@ -17,7 +18,7 @@ protected function configure() { $this ->setName('webpush:generate:keys') - ->setDescription('Generate your VAPID keys for BenTools/WebPush.'); + ->setDescription('Generate your VAPID keys for bentools/webpush.'); } /** @@ -31,34 +32,22 @@ protected function execute(InputInterface $input, OutputInterface $output) $io->writeln(sprintf('Your public key is: %s ', $keys['publicKey'])); $io->writeln(sprintf('Your private key is: %s', $keys['privateKey'])); $io->newLine(2); - $io->writeln('Store them in your app/config/parameters.yml:'); - $io->newLine(1); - $io->writeln(<<# app/config/parameters.yml.dist -parameters: - bentools_webpush.public_key: ~ - bentools_webpush.private_key: ~ -EOF - ); - $io->newLine(1); - $io->writeln(<<# app/config/parameters.yml -parameters: - bentools_webpush.public_key: '{$keys['publicKey']}' - bentools_webpush.private_key: '{$keys['privateKey']}' -EOF - ); + if (-1 === version_compare(Kernel::VERSION, 4)) { + $io->writeln('Update app/config/config.yml:'); + $io->newLine(1); + $io->writeln('# app/config/config.yml'); + } else { + $io->writeln('Update config/packages/bentools_webpush.yaml:'); + $io->newLine(1); + $io->writeln('# config/packages/bentools_webpush.yaml'); + } - $io->newLine(2); - $io->writeln('Then update your app/config/config.yml:'); - $io->newLine(1); $io->writeln(<<# app/config/config.yml bentools_webpush: - settings: - public_key: '%bentools_webpush.public_key%' - private_key: '%bentools_webpush.private_key%' + settings: + public_key: '{$keys['publicKey']}' + private_key: '{$keys['privateKey']}' EOF ); } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 2faec75..01802fd 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -17,6 +17,8 @@ public function getConfigTreeBuilder() ->arrayNode('settings') ->children() + ->scalarNode('subject') + ->end() ->scalarNode('public_key') ->isRequired() ->end() @@ -25,22 +27,6 @@ public function getConfigTreeBuilder() ->end() ->end() ->end() - - ->arrayNode('associations') - ->useAttributeAsKey('name') - ->prototype('array') - ->children() - ->scalarNode('user_class') - ->isRequired() - ->end() - ->scalarNode('user_subscription_class') - ->isRequired() - ->end() - ->scalarNode('manager') - ->isRequired() - ->end() - ->end() - ->end() ->end() ; diff --git a/src/DependencyInjection/WebPushCompilerPass.php b/src/DependencyInjection/WebPushCompilerPass.php new file mode 100644 index 0000000..8bb1c88 --- /dev/null +++ b/src/DependencyInjection/WebPushCompilerPass.php @@ -0,0 +1,24 @@ +getDefinition(UserSubscriptionManagerRegistry::class); + $taggedSubscriptionManagers = $container->findTaggedServiceIds('bentools_webpush.subscription_manager'); + foreach ($taggedSubscriptionManagers as $id => $tag) { + if (!isset($tag[0]['user_class'])) { + throw new \InvalidArgumentException(sprintf('Missing user_class attribute in tag for service %s', $id)); + } + $registry->addMethodCall('register', [$tag[0]['user_class'], new Reference($id)]); + } + } +} diff --git a/src/DependencyInjection/WebPushExtension.php b/src/DependencyInjection/WebPushExtension.php index e519699..45f13af 100644 --- a/src/DependencyInjection/WebPushExtension.php +++ b/src/DependencyInjection/WebPushExtension.php @@ -22,9 +22,9 @@ public function load(array $configs, ContainerBuilder $container) $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs); - $container->setParameter('bentools_webpush.config.associations', $config['associations'] ?? []); - $container->setParameter('bentools_webpush.public_key', $config['settings']['public_key'] ?? null); - $container->setParameter('bentools_webpush.private_key', $config['settings']['private_key'] ?? null); + $container->setParameter('bentools_webpush.vapid_subject', $config['settings']['subject'] ?? $container->getParameter('router.request_context.host')); + $container->setParameter('bentools_webpush.vapid_public_key', $config['settings']['public_key'] ?? null); + $container->setParameter('bentools_webpush.vapid_private_key', $config['settings']['private_key'] ?? null); $loader = new XmlFileLoader($container, new FileLocator([__DIR__ . '/../Resources/config/'])); $loader->load('services.xml'); } diff --git a/src/Model/Message/JsonPayloadInterface.php b/src/Model/Message/JsonPayloadInterface.php deleted file mode 100644 index 90eae73..0000000 --- a/src/Model/Message/JsonPayloadInterface.php +++ /dev/null @@ -1,19 +0,0 @@ -data = $data; - } - - /** - * @inheritDoc - */ - public function jsonSerialize(): array - { - return $this->data; - } - - /** - * @inheritDoc - */ - public function __toString(): string - { - return json_encode(['notification' => $this]); - } - - /** - * @inheritDoc - */ - public function offsetExists($offset) - { - return array_key_exists($offset, $this->data); - } - - /** - * @inheritDoc - */ - public function offsetGet($offset) - { - return $this->data[$offset] ?? null; - } - - /** - * @inheritDoc - */ - public function offsetSet($offset, $value) - { - $this->data[$offset] = $value; - } - - /** - * @inheritDoc - */ - public function offsetUnset($offset) - { - unset($this->data[$offset]); - } -} diff --git a/src/Model/Message/PayloadInterface.php b/src/Model/Message/PayloadInterface.php deleted file mode 100644 index 8ebc838..0000000 --- a/src/Model/Message/PayloadInterface.php +++ /dev/null @@ -1,14 +0,0 @@ -payload = $payload; + $this->options = $options; + $this->auth = $auth; + } + + /** + * @param null|string $payload + */ + public function setPayload(?string $payload): void + { + $this->payload = $payload; + } + + /** + * @return null|string + */ + public function getPayload(): ?string + { + return $this->payload; + } + + /** + * @param int $ttl + */ + public function setTTL(int $ttl): void + { + $this->options['TTL'] = $ttl; + } + + /** + * @param null|string $topic + */ + public function setTopic(?string $topic): void + { + $this->options['topic'] = $topic; + } + + /** + * @param null|string $urgency + * @throws \InvalidArgumentException + */ + public function setUrgency(?string $urgency): void + { + if (null === $urgency) { + unset($this->options['urgency']); + return; + } + + if (!in_array($urgency, ['very-low', 'low', 'normal', 'high'])) { + throw new \InvalidArgumentException('Urgency must be one of: very-low | low | normal | high'); + } + + $this->options['urgency'] = $urgency; + } + + /** + * @return array + */ + public function getOptions(): array + { + return array_diff($this->options, array_filter($this->options, 'is_null')); + } + + public function getOption(string $key) + { + return $this->options[$key] ?? null; + } + + /** + * @return array + */ + public function getAuth(): array + { + return $this->auth; + } +} diff --git a/src/Model/Message/PushNotification.php b/src/Model/Message/PushNotification.php new file mode 100644 index 0000000..6d639b1 --- /dev/null +++ b/src/Model/Message/PushNotification.php @@ -0,0 +1,187 @@ +title = $title; + $this->options = $options; + } + + /** + * @return null|string + */ + public function getTitle(): ?string + { + return $this->title; + } + + + /** + * @param null|string $title + */ + public function setTitle(?string $title): void + { + $this->title = $title; + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @param array $options + */ + public function setOptions(array $options): void + { + $this->options = $options; + } + + /** + * @param $key + * @param $value + */ + public function setOption($key, $value): void + { + if (null === $value) { + unset($this->options[$key]); + return; + } + + $this->options[$key] = $value; + } + + public function getOption($key) + { + return $this->options[$key] ?? null; + } + + /** + * @param array $options + * @param array $auth + * @return PushMessage + */ + public function createMessage(array $options = [], array $auth = []): PushMessage + { + return new PushMessage((string) $this, $options, $auth); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'title' => $this->title, + 'options' => array_diff($this->options, array_filter($this->options, 'is_null')), + ]; + } + + /** + * @return string + */ + public function __toString(): string + { + return (string) json_encode($this); + } + + /** + * Whether a offset exists + * @link https://php.net/manual/en/arrayaccess.offsetexists.php + * @param mixed $offset

+ * An offset to check for. + *

+ * @return boolean true on success or false on failure. + *

+ *

+ * The return value will be casted to boolean if non-boolean was returned. + * @since 5.0.0 + */ + public function offsetExists($offset) + { + return array_key_exists($offset, $this->options); + } + + /** + * Offset to retrieve + * @link https://php.net/manual/en/arrayaccess.offsetget.php + * @param mixed $offset

+ * The offset to retrieve. + *

+ * @return mixed Can return all value types. + * @since 5.0.0 + */ + public function offsetGet($offset) + { + return $this->options[$offset] ?? null; + } + + /** + * Offset to set + * @link https://php.net/manual/en/arrayaccess.offsetset.php + * @param mixed $offset

+ * The offset to assign the value to. + *

+ * @param mixed $value

+ * The value to set. + *

+ * @return void + * @since 5.0.0 + */ + public function offsetSet($offset, $value) + { + $this->options[$offset] = $value; + } + + /** + * Offset to unset + * @link https://php.net/manual/en/arrayaccess.offsetunset.php + * @param mixed $offset

+ * The offset to unset. + *

+ * @return void + * @since 5.0.0 + */ + public function offsetUnset($offset) + { + unset($this->options[$offset]); + } +} diff --git a/src/Model/Response/PushResponse.php b/src/Model/Response/PushResponse.php new file mode 100644 index 0000000..09dc499 --- /dev/null +++ b/src/Model/Response/PushResponse.php @@ -0,0 +1,68 @@ +subscription = $subscription; + $this->statusCode = $statusCode; + } + + /** + * @return UserSubscriptionInterface + */ + public function getSubscription(): UserSubscriptionInterface + { + return $this->subscription; + } + + /** + * @return int + */ + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * @return bool + */ + public function isExpired(): bool + { + return in_array($this->statusCode, [self::NOT_FOUND, self::GONE]); + } + + /** + * @return bool + */ + public function isSuccessFul(): bool + { + return self::SUCCESS === $this->statusCode; + } +} diff --git a/src/Model/Subscription/UserSubscriptionInterface.php b/src/Model/Subscription/UserSubscriptionInterface.php index acbb2cd..92c449e 100644 --- a/src/Model/Subscription/UserSubscriptionInterface.php +++ b/src/Model/Subscription/UserSubscriptionInterface.php @@ -41,4 +41,11 @@ public function getPublicKey(): string; * @return string */ public function getAuthToken(): string; + + /** + * Content-encoding (default: aesgcm). + * + * @return string + */ + public function getContentEncoding(): string; } diff --git a/src/Model/Subscription/UserSubscriptionManagerInterface.php b/src/Model/Subscription/UserSubscriptionManagerInterface.php index 5bc5593..66a6156 100644 --- a/src/Model/Subscription/UserSubscriptionManagerInterface.php +++ b/src/Model/Subscription/UserSubscriptionManagerInterface.php @@ -13,18 +13,20 @@ interface UserSubscriptionManagerInterface * @param UserInterface $user * @param string $subscriptionHash * @param array $subscription + * @param array $options * @return UserSubscriptionInterface */ - public function factory(UserInterface $user, string $subscriptionHash, array $subscription): UserSubscriptionInterface; + public function factory(UserInterface $user, string $subscriptionHash, array $subscription, array $options = []): UserSubscriptionInterface; /** * Return a string representation of the subscription's endpoint. * Example: md5($endpoint). * - * @param array $subscription + * @param string $endpoint + * @param UserInterface $user * @return string */ - public function hash(string $endpoint): string; + public function hash(string $endpoint, UserInterface $user): string; /** * Return the subscription attached to this user. diff --git a/src/Model/Subscription/UserSubscriptionManagerRegistry.php b/src/Model/Subscription/UserSubscriptionManagerRegistry.php new file mode 100644 index 0000000..d4f174e --- /dev/null +++ b/src/Model/Subscription/UserSubscriptionManagerRegistry.php @@ -0,0 +1,134 @@ +registry)) { + throw new \InvalidArgumentException(sprintf('User class %s is already registered.', $userClass)); + } + + if (self::class === get_class($userSubscriptionManager)) { + throw new \InvalidArgumentException(sprintf('You must define your own user subscription manager for %s.', $userClass)); + } + + $this->registry[$userClass] = $userSubscriptionManager; + } + + /** + * @param UserInterface|string $userClass + * @return UserSubscriptionManagerInterface + * @throws RuntimeException + * @throws ServiceNotFoundException + * @throws \InvalidArgumentException + * @throws \Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException + */ + public function getManager($userClass): UserSubscriptionManagerInterface + { + if (!is_a($userClass, UserInterface::class, true)) { + throw new \InvalidArgumentException( + sprintf( + 'Expected class or object that implements %s, %s given', + UserInterface::class, + is_object($userClass) ? get_class($userClass) : gettype($userClass) + ) + ); + } + + if (is_object($userClass)) { + $userClass = get_class($userClass); + } + + // Case of a doctrine proxied class + if (0 === strpos($userClass, 'Proxies\__CG__') && class_exists('Doctrine\Common\Util\ClassUtils')) { + return $this->getManager(ClassUtils::getRealClass($userClass)); + } + + if (!isset($this->registry[$userClass])) { + throw new \InvalidArgumentException(sprintf('There is no user subscription manager configured for class %s.', $userClass)); + } + + return $this->registry[$userClass]; + } + + /** + * @inheritDoc + */ + public function factory(UserInterface $user, string $subscriptionHash, array $subscription, array $options = []): UserSubscriptionInterface + { + return $this->getManager($user)->factory($user, $subscriptionHash, $subscription, $options); + } + + /** + * @inheritDoc + */ + public function hash(string $endpoint, UserInterface $user): string + { + return $this->getManager($user)->hash($endpoint, $user); + } + + /** + * @inheritDoc + */ + public function getUserSubscription(UserInterface $user, string $subscriptionHash): ?UserSubscriptionInterface + { + return $this->getManager($user)->getUserSubscription($user, $subscriptionHash); + } + + /** + * @inheritDoc + */ + public function findByUser(UserInterface $user): iterable + { + return $this->getManager($user)->findByUser($user); + } + + /** + * @inheritDoc + */ + public function findByHash(string $subscriptionHash): iterable + { + foreach ($this->registry as $manager) { + foreach ($manager->findByHash($subscriptionHash) as $userSubscription) { + yield $userSubscription; + } + } + } + + /** + * @inheritDoc + */ + public function save(UserSubscriptionInterface $userSubscription): void + { + $this->getManager($userSubscription->getUser())->save($userSubscription); + } + + /** + * @inheritDoc + */ + public function delete(UserSubscriptionInterface $userSubscription): void + { + $this->getManager($userSubscription->getUser())->delete($userSubscription); + } +} diff --git a/src/Registry/WebPushManagerRegistry.php b/src/Registry/WebPushManagerRegistry.php deleted file mode 100644 index cd5601d..0000000 --- a/src/Registry/WebPushManagerRegistry.php +++ /dev/null @@ -1,94 +0,0 @@ -setContainer($container); - foreach ($associations as $key => $values) { - $this->register($key, $values); - } - } - - /** - * @param string $key - * @param array $values - * @throws ServiceNotFoundException - * @throws RuntimeException - */ - private function register(string $key, array $values): void - { - if (!is_a($values['user_class'], UserInterface::class, true)) { - throw new RuntimeException(sprintf('User class %s must implement %s', $values['user_class'], UserInterface::class)); - } - if (!is_a($values['user_subscription_class'], UserSubscriptionInterface::class, true)) { - throw new RuntimeException(sprintf('User subscription class %s must implement %s', $values['user_subscription_class'], UserSubscriptionInterface::class)); - } - if (!$this->container->has(ltrim($values['manager'], '@'))) { - throw new ServiceNotFoundException(sprintf('Service %s not found - or make sure it is public.', $values['manager'])); - } - $this->associations[$key] = $values; - } - - /** - * @param UserInterface|string $userClass - * @return UserSubscriptionManagerInterface - * @throws RuntimeException - * @throws ServiceNotFoundException - * @throws \InvalidArgumentException - * @throws \Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException - */ - public function getManager($userClass): UserSubscriptionManagerInterface - { - if (!is_a($userClass, UserInterface::class, true)) { - throw new \InvalidArgumentException( - sprintf( - 'Expected class or object that implements %s, %s given', - UserInterface::class, - is_object($userClass) ? get_class($userClass) : gettype($userClass) - ) - ); - } - - if (is_object($userClass)) { - $userClass = get_class($userClass); - } - - foreach ($this->associations as $association) { - if ($association['user_class'] === $userClass) { - $service = $this->container->get(ltrim($association['manager'], '@')); - if (!$service instanceof UserSubscriptionManagerInterface) { - throw new RuntimeException(sprintf('Service %s must implement %s', $association['manager'], UserSubscriptionManagerInterface::class)); - } - return $service; - } - } - - // Case of a doctrine proxied class - if (0 === strpos($userClass, 'Proxies\__CG__') && class_exists('Doctrine\Common\Util\ClassUtils')) { - return $this->getManager(ClassUtils::getRealClass($userClass)); - } - - throw new \InvalidArgumentException(sprintf('Webpush service not found for class %s', $userClass)); - } -} diff --git a/src/Resources/config/routing.xml b/src/Resources/config/routing.xml index 359d77c..171c432 100644 --- a/src/Resources/config/routing.xml +++ b/src/Resources/config/routing.xml @@ -5,7 +5,7 @@ xsi:schemaLocation="http://symfony.com/schema/routing http://symfony.com/schema/routing/routing-1.0.xsd"> - BenTools\WebPushBundle\Action\SubscriptionAction::__invoke + BenTools\WebPushBundle\Action\RegisterSubscriptionAction diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index c5cb3d0..1b2aebd 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -4,41 +4,36 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> - - - - - - - - - + + + + + + %bentools_webpush.vapid_public_key% + - - - %bentools_webpush.config.associations% + + + + + + - + - %router.request_context.host% - %bentools_webpush.public_key% - %bentools_webpush.private_key% + %bentools_webpush.vapid_subject% + %bentools_webpush.vapid_public_key% + %bentools_webpush.vapid_private_key% - - - - - - diff --git a/src/Resources/public/js/webpush_client.js b/src/Resources/public/js/webpush_client.js deleted file mode 100644 index f1e8135..0000000 --- a/src/Resources/public/js/webpush_client.js +++ /dev/null @@ -1,176 +0,0 @@ -var thisScript = document.querySelector('script[data-webpushclient]'); -if (null === thisScript) { - console.log('Do not forget to add "data-webpushclient", i.e. '); - throw Error("Cannot find where webpush_client.js is."); -} -var BenToolsWebPushClient = function BenToolsWebPushClient(options) { - - return { - - options: {}, - worker: null, - registration: null, - subscription: null, - - init: function init(options) { - this.options = options || {}; - - if (!options.url) { - throw Error('Url has not been defined.'); - } - - this.options.url = options.url; - this.options.swPath = this.options.swPath || thisScript.src.replace('/webpush_client.js', '/webpush_sw.js'); - this.options.promptIfNotSubscribed = 'boolean' === typeof options.promptIfNotSubscribed ? options.promptIfNotSubscribed : true; - return this.initSW(); - }, - - initSW: function initSW() { - var that = this; - - if (!('serviceWorker' in navigator) || !('PushManager' in window)) { - return; - } - - that.registerServiceWorker(this.options.swPath).then(function (registration) { - that.registration = registration; - - that.getSubscription(registration).then(function (subscription) { - - // If a subscription was found, return it. - if (subscription) { - that.subscription = subscription; - return subscription; - } else { - if (true === that.options.promptIfNotSubscribed) { - return that.subscribe(); - } - } - }); - }); - return this; - }, - - askPermission: function askPermission() { - return new Promise(function (resolve, reject) { - var permissionResult = Notification.requestPermission(function (result) { - resolve(result); - }); - - if (permissionResult) { - permissionResult.then(resolve, reject); - } - }).then(function (permissionResult) { - if (permissionResult !== 'granted') { - throw new Error('Permission was not granted.'); - } - }); - }, - - getNotificationPermissionState: function getNotificationPermissionState() { - if (navigator.permissions) { - return navigator.permissions.query({ name: 'notifications' }).then(function (result) { - return result.state; - }); - } - - return new Promise(function (resolve) { - resolve(Notification.permission); - }); - }, - - subscribe: function subscribe() { - var that = this; - return that.registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: that.encodeServerKey(that.options.serverKey) - }).then(function (subscription) { - that.subscription = subscription; - return that.registerSubscription(subscription).then(function (subscription) { - if ('function' === typeof that.options.onSubscribe) { - return that.options.onSubscribe(subscription); - } - }); - }); - }, - - unsubscribe: function unsubscribe() { - var that = this; - return this.getSubscription(this.registration).then(function (subscription) { - that.unregisterSubscription(subscription); - if ('function' === typeof that.options.onUnsubscribe) { - return that.options.onUnsubscribe(subscription); - } - }); - }, - - revoke: function unsubscribe() { - var that = this; - return this.getSubscription(this.registration).then(function (subscription) { - subscription.unsubscribe().then(function () { - that.unregisterSubscription(subscription); - if ('function' === typeof that.options.onUnsubscribe) { - return that.options.onUnsubscribe(subscription); - } - }); - }); - }, - - - registerServiceWorker: function registerServiceWorker(swPath) { - var that = this; - return navigator.serviceWorker.register(swPath).then(function (registration) { - that.worker = registration.active || registration.installing; - return registration; - }); - }, - - getSubscription: function getSubscription(registration) { - return registration.pushManager.getSubscription(); - }, - - registerSubscription: function registerSubscription(subscription) { - return fetch(this.options.url, { - method: 'POST', - mode: 'cors', - credentials: 'include', - cache: 'default', - headers: new Headers({ - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }), - body: JSON.stringify(subscription) - }).then(function () { - return subscription; - }); - }, - - unregisterSubscription: function unregisterSubscription(subscription) { - return fetch(this.options.url, { - method: 'DELETE', - mode: 'cors', - credentials: 'include', - cache: 'default', - headers: new Headers({ - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }), - body: JSON.stringify(subscription) - }); - }, - - encodeServerKey: function encodeServerKey(serverKey) { - var padding = '='.repeat((4 - serverKey.length % 4) % 4); - var base64 = (serverKey + padding).replace(/\-/g, '+').replace(/_/g, '/'); - - var rawData = window.atob(base64); - var outputArray = new Uint8Array(rawData.length); - - for (var i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i); - } - return outputArray; - } - - }.init(options); -}; \ No newline at end of file diff --git a/src/Resources/public/js/webpush_sw.js b/src/Resources/public/js/webpush_sw.js deleted file mode 100644 index d69f029..0000000 --- a/src/Resources/public/js/webpush_sw.js +++ /dev/null @@ -1,52 +0,0 @@ -self.processMessage = payload => { - - try { - const jsonData = JSON.parse(payload); - const promises = []; - for (key in jsonData) { - if ('notification' === key) { - promises.push(self.registration.showNotification(jsonData.notification.title, jsonData.notification)); - } - } - return Promise.race(promises); - } catch (e) { - return self.registration.showNotification('Notification', { - body: payload - }); - } - -}; - -// Register event listener for the 'push' event. -self.addEventListener('push', event => { - console.log('SW received push event', event); - const pushMessageData = event.data; - const payload = pushMessageData ? pushMessageData.text() : undefined; - event.waitUntil(self.processMessage(payload)); -}); - -self.addEventListener('notificationclick', event => { - - event.notification.close(); - const url = event.notification.data.link; - - if (url.length > 0) { - event.waitUntil( - clients.matchAll({ - type: 'window' - }) - .then(windowClients => { - for (const client of windowClients) { - if (client.url === url && 'focus' in client) { - return client.focus(); - } - } - - if (clients.openWindow) { - return clients.openWindow(url); - } - }) - ); - } -}); - diff --git a/src/Sender/PushMessageSender.php b/src/Sender/PushMessageSender.php new file mode 100644 index 0000000..672565c --- /dev/null +++ b/src/Sender/PushMessageSender.php @@ -0,0 +1,183 @@ +auth = $auth; + $this->setDefaultOptions($defaultOptions); + $this->client = $client ?? new Client(); + $this->requestBuilder = new RequestBuilder(); + } + + /** + * @param PushMessage $message + * @param iterable $subscriptions + * @return PushResponse[] + * @throws \ErrorException + * @throws \InvalidArgumentException + * @throws \LogicException + */ + public function push(PushMessage $message, iterable $subscriptions): iterable + { + /** @var UserSubscriptionInterface[] $subscriptions */ + $promises = []; + + if (isset($this->auth['VAPID']) && empty($this->auth['VAPID']['validated'])) { + $this->auth['VAPID'] = VAPID::validate($this->auth['VAPID']) + ['validated' => true]; + } + + foreach ($subscriptions as $subscription) { + $subscriptionHash = $subscription->getSubscriptionHash(); + $auth = $message->getAuth() + $this->auth; + + $request = $this->requestBuilder->createRequest( + $message, + $subscription, + $message->getOption('TTL') ?? $this->defaultOptions['TTL'], + $this->maxPaddingLength + ); + + if (isset($auth['VAPID'])) { + $request = $this->requestBuilder->withVAPIDAuthentication($request, $auth['VAPID'], $subscription); + } elseif (isset($auth['GCM'])) { + $request = $this->requestBuilder->withGCMAuthentication($request, $auth['GCM']); + } + + $promises[$subscriptionHash] = $this->client->sendAsync($request) + ->then(function (ResponseInterface $response) use ($subscription) { + return new PushResponse($subscription, $response->getStatusCode()); + }) + ->otherwise(function (\Throwable $reason) use ($subscription) { + + if ($reason instanceof RequestException && $reason->hasResponse()) { + return new PushResponse($subscription, $reason->getResponse()->getStatusCode()); + } + + throw $reason; + }) + ; + } + + $promise = Promise\settle($promises) + ->then(function ($results) { + foreach ($results as $subscriptionHash => $promise) { + yield $subscriptionHash => $promise['value'] ?? $promise['reason']; + } + }) + ; + + return $promise->wait(); + } + + /** + * @return bool + */ + public function isAutomaticPadding(): bool + { + return $this->maxPaddingLength !== 0; + } + + /** + * @return int + */ + public function getMaxPaddingLength() + { + return $this->maxPaddingLength; + } + + /** + * @param int|bool $maxPaddingLength Max padding length + * + * @return self + * + * @throws \Exception + */ + public function setMaxPaddingLength($maxPaddingLength): self + { + if ($maxPaddingLength > 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 ($maxPaddingLength < 0) { + throw new \Exception('Padding length should be positive or zero.'); + } elseif ($maxPaddingLength === true) { + $this->maxPaddingLength = Encryption::MAX_COMPATIBILITY_PAYLOAD_LENGTH; + } elseif ($maxPaddingLength === false) { + $this->maxPaddingLength = 0; + } else { + $this->maxPaddingLength = $maxPaddingLength; + } + + return $this; + } + + /** + * @return array + */ + public function getDefaultOptions(): array + { + return $this->defaultOptions; + } + + /** + * @param array $defaultOptions Keys 'TTL' (Time To Live, defaults 0), 'urgency', 'topic', 'batchSize' + */ + public function setDefaultOptions(array $defaultOptions) + { + $this->defaultOptions['TTL'] = $defaultOptions['TTL'] ?? 0; + $this->defaultOptions['urgency'] = $defaultOptions['urgency'] ?? null; + $this->defaultOptions['topic'] = $defaultOptions['topic'] ?? null; + $this->defaultOptions['batchSize'] = $defaultOptions['batchSize'] ?? 1000; + } +} diff --git a/src/Sender/PushMessagerSenderInterface.php b/src/Sender/PushMessagerSenderInterface.php new file mode 100644 index 0000000..5ab3bda --- /dev/null +++ b/src/Sender/PushMessagerSenderInterface.php @@ -0,0 +1,20 @@ +getEndpoint()); + $request = $this->withOptionalHeaders($request, $message); + $request = $request->withHeader('TTL', $ttl); + + + if (null !== $message->getPayload() && null !== $subscription->getPublicKey() && null !== $subscription->getAuthToken()) { + $request = $request + ->withHeader('Content-Type', 'application/octet-stream') + ->withHeader('Content-Encoding', $subscription->getContentEncoding()); + + + $payload = $this->getNormalizedPayload($message->getPayload(), $subscription->getContentEncoding(), $maxPaddingLength); + + $encrypted = Encryption::encrypt( + $payload, + $subscription->getPublicKey(), + $subscription->getAuthToken(), + $subscription->getContentEncoding() + ); + + if ('aesgcm' === $subscription->getContentEncoding()) { + $request = $request->withHeader('Encryption', 'salt=' . Base64Url::encode($encrypted['salt'])) + ->withHeader('Crypto-Key', 'dh=' . Base64Url::encode($encrypted['localPublicKey'])); + } + + $encryptionContentCodingHeader = Encryption::getContentCodingHeader($encrypted['salt'], $encrypted['localPublicKey'], $subscription->getContentEncoding()); + $content = $encryptionContentCodingHeader . $encrypted['cipherText']; + + return $request + ->withBody(stream_for($content)) + ->withHeader('Content-Length', Utils::safeStrlen($content)); + } + + + return $request + ->withHeader('Content-Length', 0); + } + + /** + * @param RequestInterface $request + * @param array $vapid + * @param UserSubscriptionInterface $subscription + * @return RequestInterface + * @throws \ErrorException + * @throws \InvalidArgumentException + */ + public function withVAPIDAuthentication(RequestInterface $request, array $vapid, UserSubscriptionInterface $subscription): RequestInterface + { + + $endpoint = $subscription->getEndpoint(); + $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 = VAPID::getVapidHeaders($audience, $vapid['subject'], $vapid['publicKey'], $vapid['privateKey'], $subscription->getContentEncoding()); + + $request = $request->withHeader('Authorization', $vapidHeaders['Authorization']); + + if ('aesgcm' === $subscription->getContentEncoding()) { + if ($request->hasHeader('Crypto-Key')) { + $request = $request->withHeader('Crypto-Key', $request->getHeaderLine('Crypto-Key') . ';' . $vapidHeaders['Crypto-Key']); + } else { + $headers['Crypto-Key'] = $vapidHeaders['Crypto-Key']; + $request->withHeader('Crypto-Key', $vapidHeaders['Crypto-Key']); + } + } else if ('aes128gcm' === $subscription->getContentEncoding() && substr($endpoint, 0, strlen(self::FCM_BASE_URL)) === self::FCM_BASE_URL) { + $request = $request->withUri(new Uri(str_replace('fcm/send', 'wp', $endpoint))); + } + + return $request; + } + + /** + * @param RequestInterface $request + * @param string $apiKey + * @return RequestInterface + * @throws \InvalidArgumentException + */ + public function withGCMAuthentication(RequestInterface $request, string $apiKey): RequestInterface + { + return $request->withHeader('Authorization', 'key=' . $apiKey); + } + + /** + * @param RequestInterface $request + * @param PushMessage $message + * @return RequestInterface + * @throws \InvalidArgumentException + */ + private function withOptionalHeaders(RequestInterface $request, PushMessage $message): RequestInterface + { + foreach (['urgency', 'topic'] as $option) { + if (null !== $message->getOption($option)) { + $request = $request->withHeader($option, $message->getOption($option)); + } + } + + return $request; + } + + /** + * @param null|string $payload + * @param string $contentEncoding + * @param $automaticPadding + * @return null|string + * @throws \ErrorException + */ + private function getNormalizedPayload(?string $payload, string $contentEncoding, $automaticPadding): ?string + { + if (null === $payload) { + return null; + } + if (Utils::safeStrlen($payload) > Encryption::MAX_PAYLOAD_LENGTH) { + throw new \ErrorException('Size of payload must not be greater than ' . Encryption::MAX_PAYLOAD_LENGTH . ' bytes.'); + } + + return Encryption::padPayload($payload, $automaticPadding, $contentEncoding); + } +} diff --git a/src/Twig/WebPushTwigExtension.php b/src/Twig/WebPushTwigExtension.php index 7fb96e5..7dc0312 100644 --- a/src/Twig/WebPushTwigExtension.php +++ b/src/Twig/WebPushTwigExtension.php @@ -2,24 +2,38 @@ namespace BenTools\WebPushBundle\Twig; -use Symfony\Component\DependencyInjection\ContainerAwareInterface; -use Symfony\Component\DependencyInjection\ContainerAwareTrait; -use Twig_Extension; +use BenTools\WebPushBundle\Model\Subscription\UserSubscriptionManagerRegistry; +use Twig\Extension\AbstractExtension; use Twig_Extension_GlobalsInterface; -final class WebPushTwigExtension extends Twig_Extension implements Twig_Extension_GlobalsInterface, ContainerAwareInterface +final class WebPushTwigExtension extends AbstractExtension implements Twig_Extension_GlobalsInterface { - use ContainerAwareTrait; + /** + * @var string + */ + private $publicKey; + + /** + * @var UserSubscriptionManagerRegistry + */ + private $registry; + + public function __construct( + string $publicKey, + ?UserSubscriptionManagerRegistry $registry = null + ) { + $this->publicKey = $publicKey; + $this->registry = $registry; + } /** * @inheritDoc */ public function getGlobals() { - $publicKey = $this->container->getParameter('bentools_webpush.public_key'); return [ - 'bentools_pusher' => [ - 'server_key' => $publicKey ?? null, + 'bentools_webpush' => [ + 'server_key' => $this->publicKey, ], ]; } diff --git a/src/WebPushBundle.php b/src/WebPushBundle.php index 6bf7443..efc59df 100644 --- a/src/WebPushBundle.php +++ b/src/WebPushBundle.php @@ -2,7 +2,9 @@ namespace BenTools\WebPushBundle; +use BenTools\WebPushBundle\DependencyInjection\WebPushCompilerPass; use BenTools\WebPushBundle\DependencyInjection\WebPushExtension; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; class WebPushBundle extends Bundle @@ -14,4 +16,9 @@ public function getContainerExtension() { return new WebPushExtension(); } + + public function build(ContainerBuilder $container) + { + $container->addCompilerPass(new WebPushCompilerPass()); + } } diff --git a/tests/BundleTest.php b/tests/BundleTest.php new file mode 100644 index 0000000..87dc87b --- /dev/null +++ b/tests/BundleTest.php @@ -0,0 +1,47 @@ +assertEquals('this_is_a_private_key', self::$kernel->getContainer()->getParameter('bentools_webpush.vapid_private_key')); + $this->assertEquals('this_is_a_public_key', self::$kernel->getContainer()->getParameter('bentools_webpush.vapid_public_key')); + $this->assertTrue(self::$kernel->getContainer()->has(UserSubscriptionManagerRegistry::class)); + } + + /** + * @test + */ + public function manager_is_found() + { + // Find by class name + $this->assertInstanceOf(TestUserSubscriptionManager::class, self::$kernel->getContainer()->get(UserSubscriptionManagerRegistry::class)->getManager(TestUser::class)); + + // Find by object + $this->assertInstanceOf(TestUserSubscriptionManager::class, self::$kernel->getContainer()->get(UserSubscriptionManagerRegistry::class)->getManager(new TestUser('foo'))); + } + + /** + * @test + * @expectedException \InvalidArgumentException + */ + public function unknown_manager_raises_exception() + { + self::$kernel->getContainer()->get(UserSubscriptionManagerRegistry::class)->getManager(Foo::class); + } +} diff --git a/tests/Classes/TestKernel.php b/tests/Classes/TestKernel.php new file mode 100644 index 0000000..1fdab94 --- /dev/null +++ b/tests/Classes/TestKernel.php @@ -0,0 +1,67 @@ +cacheDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $uniqid . DIRECTORY_SEPARATOR . 'cache'; + $this->logDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $uniqid . DIRECTORY_SEPARATOR . 'logs'; + } + + public function registerBundles() + { + return [ + new FrameworkBundle(), + new WebPushBundle(), + ]; + } + + protected function configureRoutes(RouteCollectionBuilder $routes) + { + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader) + { + $c->loadFromExtension('framework', [ + 'secret' => getenv('APP_SECRET'), + ]); + $c->loadFromExtension('bentools_webpush', [ + 'settings' => [ + 'private_key' => 'this_is_a_private_key', + 'public_key' => 'this_is_a_public_key', + ] + ]); + $loader->load(dirname(__DIR__) . '/Resources/services.yaml'); + + + $c->addCompilerPass(new PublicServicePass()); + } + + public function getCacheDir() + { + return $this->cacheDir; + } + + public function getLogDir() + { + return $this->logDir; + } +} diff --git a/tests/Classes/TestUser.php b/tests/Classes/TestUser.php new file mode 100644 index 0000000..8419013 --- /dev/null +++ b/tests/Classes/TestUser.php @@ -0,0 +1,39 @@ +userName = $userName; + } + + public function getUsername() + { + return $this->userName; + } + + public function getRoles() + { + } + + public function getPassword() + { + } + + public function getSalt() + { + } + + public function eraseCredentials() + { + } +} diff --git a/tests/Classes/TestUserSubscription.php b/tests/Classes/TestUserSubscription.php new file mode 100644 index 0000000..b85e56b --- /dev/null +++ b/tests/Classes/TestUserSubscription.php @@ -0,0 +1,82 @@ +id = $user->getUsername(); + $this->user = $user; + $this->endpoint = $endpoint; + $this->publicKey = $publicKey; + $this->authtoken = $authtoken; + $this->subscriptionHash = $subscriptionHash; + } + + public function getUser(): UserInterface + { + return $this->user; + } + + public function getSubscriptionHash(): string + { + return $this->subscriptionHash; + } + + public function getEndpoint(): string + { + return $this->endpoint; + } + + public function getPublicKey(): string + { + return $this->publicKey; + } + + public function getAuthToken(): string + { + return $this->authtoken; + } + + public function getContentEncoding(): string + { + return 'aesgcm'; + } +} diff --git a/tests/Classes/TestUserSubscriptionManager.php b/tests/Classes/TestUserSubscriptionManager.php new file mode 100644 index 0000000..f53f520 --- /dev/null +++ b/tests/Classes/TestUserSubscriptionManager.php @@ -0,0 +1,96 @@ +doctrine = $doctrine; + } + + /** + * @inheritDoc + */ + public function factory(UserInterface $user, string $subscriptionHash, array $subscription, array $options = []): UserSubscriptionInterface + { + return new TestUserSubscription( + $user, + $subscription['endpoint'], + $subscription['keys']['p256dh'], + $subscription['keys']['auth'], + $subscriptionHash + ); + } + + /** + * @inheritDoc + */ + public function hash(string $endpoint, UserInterface $user): string + { + return md5($endpoint); + } + + /** + * @inheritDoc + */ + public function getUserSubscription(UserInterface $user, string $subscriptionHash): ?UserSubscriptionInterface + { + return $this->doctrine->getManagerForClass(TestUserSubscription::class)->getRepository(TestUserSubscription::class)->findOneBy([ + 'user' => $user, + 'subscriptionHash' => $subscriptionHash, + ]); + } + + /** + * @inheritDoc + */ + public function findByUser(UserInterface $user): iterable + { + return $this->doctrine->getManagerForClass(TestUserSubscription::class)->getRepository(TestUserSubscription::class)->findBy([ + 'user' => $user, + ]); + } + + /** + * @inheritDoc + */ + public function findByHash(string $subscriptionHash): iterable + { + return $this->doctrine->getManagerForClass(TestUserSubscription::class)->getRepository(TestUserSubscription::class)->findBy([ + 'subscriptionHash' => $subscriptionHash, + ]); + } + + /** + * @inheritDoc + */ + public function save(UserSubscriptionInterface $userSubscription): void + { + $this->doctrine->getManagerForClass(TestUserSubscription::class)->persist($userSubscription); + $this->doctrine->getManagerForClass(TestUserSubscription::class)->flush(); + } + + /** + * @inheritDoc + */ + public function delete(UserSubscriptionInterface $userSubscription): void + { + $this->doctrine->getManagerForClass(TestUserSubscription::class)->remove($userSubscription); + $this->doctrine->getManagerForClass(TestUserSubscription::class)->flush(); + } +} diff --git a/tests/RegistrationTest.php b/tests/RegistrationTest.php new file mode 100644 index 0000000..9d8eb70 --- /dev/null +++ b/tests/RegistrationTest.php @@ -0,0 +1,58 @@ +getContainer()->get('doctrine'); + $registry = self::$kernel->getContainer()->get(UserSubscriptionManagerRegistry::class); + $em = $persistence->getManagerForClass(TestUser::class); + $bob = new TestUser('bob'); + $em->persist($bob); + $em->flush(); + $this->assertNotNull($em->find(TestUser::class, 'bob')); + + + $register = self::$kernel->getContainer()->get(RegisterSubscriptionAction::class); + + $rawSubscriptionData = [ + 'subscription' => [ + 'endpoint' => 'http://foo.bar', + 'keys' => [ + 'p256dh' => 'bob_public_key', + 'auth' => 'bob_private_key', + ] + ] + ]; + + $request = new Request([], [], [], [], [], ['REQUEST_METHOD' => 'POST'], json_encode($rawSubscriptionData)); + $register($request, $bob); + + $subscriptions = $registry->getManager($bob)->findByUser($bob); + $this->assertCount(1, $subscriptions); + + $request = new Request([], [], [], [], [], ['REQUEST_METHOD' => 'DELETE'], json_encode($rawSubscriptionData)); + $register($request, $bob); + + $subscriptions = $registry->getManager($bob)->findByUser($bob); + $this->assertCount(0, $subscriptions); + } +} diff --git a/tests/Resources/services.yaml b/tests/Resources/services.yaml new file mode 100644 index 0000000..a1f135e --- /dev/null +++ b/tests/Resources/services.yaml @@ -0,0 +1,33 @@ +services: + + doctrine: + class: BenTools\DoctrineStatic\ManagerRegistry + arguments: + - + - '@object_manager' + + object_manager: + class: BenTools\DoctrineStatic\ObjectManager + arguments: + - + - '@test_user.repository' + - '@test_user_subscription.repository' + + test_user.repository: + class: BenTools\DoctrineStatic\ObjectRepository + arguments: + - 'BenTools\WebPushBundle\Tests\Classes\TestUser' + - 'userName' + + test_user_subscription.repository: + class: BenTools\DoctrineStatic\ObjectRepository + arguments: + - 'BenTools\WebPushBundle\Tests\Classes\TestUserSubscription' + - 'id' + + BenTools\WebPushBundle\Tests\Classes\TestUserSubscriptionManager: + class: BenTools\WebPushBundle\Tests\Classes\TestUserSubscriptionManager + arguments: + - '@doctrine' + tags: + - { name: bentools_webpush.subscription_manager, user_class: 'BenTools\WebPushBundle\Tests\Classes\TestUser' } \ No newline at end of file