├── .gitignore ├── tests ├── Resources │ ├── framework.yaml │ └── services.yaml ├── Classes │ ├── TestUser.php │ ├── TestUserSubscription.php │ ├── TestKernel.php │ └── TestUserSubscriptionManager.php ├── bootstrap.php ├── BundleTest.php └── RegistrationTest.php ├── src ├── Sender │ ├── NullPushMessageSender.php │ ├── PushMessagerSenderInterface.php │ ├── RequestBuilder.php │ └── PushMessageSender.php ├── Resources │ └── config │ │ ├── routing.xml │ │ └── services.xml ├── Twig │ └── WebPushTwigExtension.php ├── WebPushBundle.php ├── Model │ ├── Subscription │ │ ├── UserSubscriptionInterface.php │ │ ├── UserSubscriptionManagerInterface.php │ │ └── UserSubscriptionManagerRegistry.php │ ├── Response │ │ └── PushResponse.php │ └── Message │ │ ├── PushMessage.php │ │ └── PushNotification.php ├── DependencyInjection │ ├── WebPushCompilerPass.php │ ├── Configuration.php │ └── WebPushExtension.php ├── Command │ └── WebPushGenerateKeysCommand.php └── Action │ └── RegisterSubscriptionAction.php ├── phpunit.xml.dist ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── doc ├── 03 - Configuration.md ├── 05 - FAQ.md ├── 01 - The UserSubscription Class.md ├── 04 - Usage.md └── 02 - The UserSubscription Manager.md ├── README.md └── composer.json /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor 3 | composer.lock -------------------------------------------------------------------------------- /tests/Resources/framework.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | test: true 3 | cache: 4 | default_pdo_provider: ~ 5 | -------------------------------------------------------------------------------- /src/Sender/NullPushMessageSender.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | BenTools\WebPushBundle\Action\RegisterSubscriptionAction 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/Classes/TestUser.php: -------------------------------------------------------------------------------- 1 | userName; 16 | } 17 | 18 | public function getRoles(): array 19 | { 20 | return []; 21 | } 22 | 23 | public function eraseCredentials(): void 24 | { 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Twig/WebPushTwigExtension.php: -------------------------------------------------------------------------------- 1 | [ 21 | 'server_key' => $this->publicKey, 22 | ], 23 | ]; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Sender/PushMessagerSenderInterface.php: -------------------------------------------------------------------------------- 1 | addCompilerPass(new WebPushCompilerPass()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 12 | src 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | tests 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/DependencyInjection/WebPushCompilerPass.php: -------------------------------------------------------------------------------- 1 | getDefinition(UserSubscriptionManagerRegistry::class); 15 | $taggedSubscriptionManagers = $container->findTaggedServiceIds('bentools_webpush.subscription_manager'); 16 | foreach ($taggedSubscriptionManagers as $id => $tag) { 17 | if (!isset($tag[0]['user_class'])) { 18 | throw new \InvalidArgumentException(sprintf('Missing user_class attribute in tag for service %s', $id)); 19 | } 20 | $registry->addMethodCall('register', [$tag[0]['user_class'], new Reference($id)]); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Beno!t POLASZEK 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 14 | 15 | $rootNode 16 | ->children() 17 | 18 | ->arrayNode('settings') 19 | ->children() 20 | ->scalarNode('subject') 21 | ->end() 22 | ->scalarNode('public_key') 23 | ->isRequired() 24 | ->end() 25 | ->scalarNode('private_key') 26 | ->isRequired() 27 | ->end() 28 | ->end() 29 | ->end() 30 | ->end() 31 | ; 32 | 33 | return $treeBuilder; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: ~ 4 | pull_request: ~ 5 | 6 | env: 7 | COMPOSER_FLAGS: "--prefer-stable" 8 | 9 | jobs: 10 | ci_job: 11 | runs-on: ${{ matrix.operating-system }} 12 | strategy: 13 | matrix: 14 | operating-system: ['ubuntu-latest'] 15 | php-versions: ['8.1', '8.2', '8.3'] 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Setup PHP 22 | uses: shivammathur/setup-php@v2 23 | with: 24 | php-version: ${{ matrix.php-versions }} 25 | ini-values: post_max_size=256M, max_execution_time=180 26 | 27 | - name: Install Dependencies 28 | run: composer update $COMPOSER_FLAGS --no-interaction --prefer-dist --no-progress --ansi 29 | 30 | - name: Process the tests 31 | run: vendor/bin/phpunit 32 | 33 | # Too much conflict with PHPUnit and old low package versions 34 | # - name: Tests with lowest posible packages 35 | # run: | 36 | # composer update $COMPOSER_FLAGS --no-interaction --prefer-dist --prefer-lowest --no-progress --ansi 37 | # vendor/bin/phpunit 38 | -------------------------------------------------------------------------------- /src/Model/Response/PushResponse.php: -------------------------------------------------------------------------------- 1 | subscription; 27 | } 28 | 29 | public function getStatusCode(): int 30 | { 31 | return $this->statusCode; 32 | } 33 | 34 | public function isExpired(): bool 35 | { 36 | return in_array($this->statusCode, [self::NOT_FOUND, self::GONE]); 37 | } 38 | 39 | public function isSuccessFul(): bool 40 | { 41 | return self::SUCCESS === $this->statusCode; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Resources/services.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | doctrine: 4 | class: BenTools\DoctrineStatic\ManagerRegistry 5 | arguments: 6 | - 7 | - '@object_manager' 8 | 9 | object_manager: 10 | class: BenTools\DoctrineStatic\ObjectManager 11 | arguments: 12 | - 13 | - '@test_user.repository' 14 | - '@test_user_subscription.repository' 15 | 16 | test_user.repository: 17 | class: BenTools\DoctrineStatic\ObjectRepository 18 | arguments: 19 | - 'BenTools\WebPushBundle\Tests\Classes\TestUser' 20 | - 'userName' 21 | 22 | test_user_subscription.repository: 23 | class: BenTools\DoctrineStatic\ObjectRepository 24 | arguments: 25 | - 'BenTools\WebPushBundle\Tests\Classes\TestUserSubscription' 26 | - 'id' 27 | 28 | BenTools\WebPushBundle\Tests\Classes\TestUserSubscriptionManager: 29 | class: BenTools\WebPushBundle\Tests\Classes\TestUserSubscriptionManager 30 | arguments: 31 | - '@doctrine' 32 | tags: 33 | - { name: bentools_webpush.subscription_manager, user_class: 'BenTools\WebPushBundle\Tests\Classes\TestUser' } 34 | -------------------------------------------------------------------------------- /tests/Classes/TestUserSubscription.php: -------------------------------------------------------------------------------- 1 | id = $user->getUserIdentifier(); 20 | } 21 | 22 | public function getUser(): UserInterface 23 | { 24 | return $this->user; 25 | } 26 | 27 | public function getSubscriptionHash(): string 28 | { 29 | return $this->subscriptionHash; 30 | } 31 | 32 | public function getEndpoint(): string 33 | { 34 | return $this->endpoint; 35 | } 36 | 37 | public function getPublicKey(): string 38 | { 39 | return $this->publicKey; 40 | } 41 | 42 | public function getAuthToken(): string 43 | { 44 | return $this->authtoken; 45 | } 46 | 47 | public function getContentEncoding(): string 48 | { 49 | return 'aesgcm'; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /doc/03 - Configuration.md: -------------------------------------------------------------------------------- 1 | ## Configuration 2 | 3 | #### Configure the bundle: 4 | 5 | ```yaml 6 | # app/config/config.yml (SF3) 7 | # config/packages/bentools_webpush.yaml (SF4) 8 | bentools_webpush: 9 | settings: 10 | # subject: 11 | public_key: 'your_public_key' 12 | private_key: 'your_private_key' 13 | ``` 14 | 15 | The subject is optional, with a fallback being `router.request_context.host`. 16 | You may need to define it explicitly if you are working in a CLI context. 17 | 18 | Note that Apple requires the subject to be an URL or a mailto URL. 19 | 20 | #### Update your router: 21 | ```yaml 22 | # app/config/routing.yml (SF3) 23 | # config/routing.yaml (SF4) 24 | bentools_webpush: 25 | resource: '@WebPushBundle/Resources/config/routing.xml' 26 | prefix: /webpush 27 | ``` 28 | 29 | You will have a new route called `bentools_webpush` which will be the Ajax endpoint for handling subscriptions (POST requests) / unsubscriptions (DELETE requests). 30 | 31 | Your VAPID public key is now exposed through Twig's `bentools_webpush.server_key` global variable. 32 | 33 | To handle subscriptions/unsubscriptions on the front-end side, have a look at [webpush-client](https://www.npmjs.com/package/webpush-client). 34 | 35 | Previous: [The UserSubscription Manager](02%20-%20The%20UserSubscription%20Manager.md) 36 | 37 | Next: [Usage](04%20-%20Usage.md) 38 | -------------------------------------------------------------------------------- /src/DependencyInjection/WebPushExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration($configuration, $configs); 25 | $container->setParameter('bentools_webpush.vapid_subject', $config['settings']['subject'] ?? $container->getParameter('router.request_context.host')); 26 | $container->setParameter('bentools_webpush.vapid_public_key', $config['settings']['public_key'] ?? null); 27 | $container->setParameter('bentools_webpush.vapid_private_key', $config['settings']['private_key'] ?? null); 28 | $loader = new XmlFileLoader($container, new FileLocator([__DIR__.'/../Resources/config/'])); 29 | $loader->load('services.xml'); 30 | } 31 | 32 | /** 33 | * {@inheritdoc} 34 | */ 35 | public function getAlias(): string 36 | { 37 | return 'bentools_webpush'; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /doc/05 - FAQ.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## FAQ 4 | 5 | **Do I need FOSUserBundle?** 6 | 7 | Nope. We rely on your `Symfony\Component\Security\Core\User\UserInterface` implementation, which can come from **FOSUserBundle** or anything else. 8 | 9 | 10 | **What if I have several kind of users in my app?** 11 | 12 | It's OK. You can subscribe separately your `Employees` and your `Customers`, for instance. 13 | 14 | Example config: 15 | 16 | ```yaml 17 | # app/config/services.yml (SF3) 18 | # config/services.yaml (SF4) 19 | 20 | services: 21 | App\Services\EmployeeSubscriptionManager: 22 | class: App\Services\EmployeeSubscriptionManager 23 | arguments: 24 | - '@doctrine' 25 | tags: 26 | - { name: bentools_webpush.subscription_manager, user_class: 'App\Entity\Employee' } 27 | 28 | App\Services\CustomerSubscriptionManager: 29 | class: App\Services\CustomerSubscriptionManager 30 | arguments: 31 | - '@doctrine' 32 | tags: 33 | - { name: bentools_webpush.subscription_manager, user_class: 'App\Entity\Customer' } 34 | ``` 35 | 36 | 37 | **Can a user subscribe with multiple browsers?** 38 | 39 | Of course. You send a notification to a user, it will be dispatched among the different browsers they subscribed. 40 | 41 | You can control subscriptions on the client-side. 42 | 43 | **How do I manage subscriptions / unsubscriptions from an UI point of view?** 44 | 45 | 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. 46 | 47 | 48 | Previous: [Usage](04%20-%20Usage.md) -------------------------------------------------------------------------------- /src/Model/Subscription/UserSubscriptionManagerInterface.php: -------------------------------------------------------------------------------- 1 | assertEquals('this_is_a_private_key', self::getContainer()->getParameter('bentools_webpush.vapid_private_key')); 23 | $this->assertEquals('this_is_a_public_key', self::getContainer()->getParameter('bentools_webpush.vapid_public_key')); 24 | $this->assertTrue(self::getContainer()->has(UserSubscriptionManagerRegistry::class)); 25 | } 26 | 27 | /** 28 | * @test 29 | */ 30 | public function manager_is_found(): void 31 | { 32 | // Find by class name 33 | $this->assertInstanceOf(TestUserSubscriptionManager::class, self::getContainer()->get(UserSubscriptionManagerRegistry::class)->getManager(TestUser::class)); 34 | 35 | // Find by object 36 | $this->assertInstanceOf(TestUserSubscriptionManager::class, self::getContainer()->get(UserSubscriptionManagerRegistry::class)->getManager(new TestUser('foo'))); 37 | } 38 | 39 | /** 40 | * @test 41 | */ 42 | public function unknown_manager_raises_exception(): void 43 | { 44 | $this->expectException(\InvalidArgumentException::class); 45 | self::getContainer()->get(UserSubscriptionManagerRegistry::class)->getManager(Foo::class); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Model/Message/PushMessage.php: -------------------------------------------------------------------------------- 1 | payload = $payload; 25 | } 26 | 27 | public function getPayload(): ?string 28 | { 29 | return $this->payload; 30 | } 31 | 32 | public function setTTL(int $ttl): void 33 | { 34 | $this->options['TTL'] = $ttl; 35 | } 36 | 37 | public function setTopic(?string $topic): void 38 | { 39 | $this->options['topic'] = $topic; 40 | } 41 | 42 | /** 43 | * @throws \InvalidArgumentException 44 | */ 45 | public function setUrgency(?string $urgency): void 46 | { 47 | if (null === $urgency) { 48 | unset($this->options['urgency']); 49 | 50 | return; 51 | } 52 | 53 | if (!in_array($urgency, ['very-low', 'low', 'normal', 'high'])) { 54 | throw new \InvalidArgumentException('Urgency must be one of: very-low | low | normal | high'); 55 | } 56 | 57 | $this->options['urgency'] = $urgency; 58 | } 59 | 60 | public function getOptions(): array 61 | { 62 | return array_diff($this->options, array_filter($this->options, 'is_null')); 63 | } 64 | 65 | public function getOption(string $key) 66 | { 67 | return $this->options[$key] ?? null; 68 | } 69 | 70 | public function getAuth(): array 71 | { 72 | return $this->auth; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/Classes/TestKernel.php: -------------------------------------------------------------------------------- 1 | cacheDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $uniqid . DIRECTORY_SEPARATOR . 'cache'; 24 | $this->logDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $uniqid . DIRECTORY_SEPARATOR . 'logs'; 25 | } 26 | 27 | public function registerBundles(): iterable 28 | { 29 | return [ 30 | new FrameworkBundle(), 31 | new WebPushBundle(), 32 | ]; 33 | } 34 | 35 | protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader): void 36 | { 37 | $container->loadFromExtension('framework', [ 38 | 'secret' => getenv('APP_SECRET'), 39 | ]); 40 | $container->loadFromExtension('bentools_webpush', [ 41 | 'settings' => [ 42 | 'private_key' => 'this_is_a_private_key', 43 | 'public_key' => 'this_is_a_public_key', 44 | ] 45 | ]); 46 | $loader->load(dirname(__DIR__) . '/Resources/services.yaml'); 47 | $loader->load(dirname(__DIR__) . '/Resources/framework.yaml'); 48 | } 49 | 50 | public function getCacheDir(): string 51 | { 52 | return $this->cacheDir; 53 | } 54 | 55 | public function getLogDir(): string 56 | { 57 | return $this->logDir; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Command/WebPushGenerateKeysCommand.php: -------------------------------------------------------------------------------- 1 | setName('webpush:generate:keys') 25 | ->setDescription('Generate your VAPID keys for bentools/webpush.'); 26 | } 27 | 28 | /** 29 | * {@inheritdoc} 30 | * @throws \ErrorException 31 | */ 32 | protected function execute(InputInterface $input, OutputInterface $output): int 33 | { 34 | $io = new SymfonyStyle($input, $output); 35 | $keys = VAPID::createVapidKeys(); 36 | $io->success('Your VAPID keys have been generated!'); 37 | $io->writeln(sprintf('Your public key is: %s ', $keys['publicKey'])); 38 | $io->writeln(sprintf('Your private key is: %s', $keys['privateKey'])); 39 | $io->newLine(2); 40 | 41 | if (-1 === version_compare(Kernel::VERSION, 4)) { 42 | $io->writeln('Update app/config/config.yml:'); 43 | $io->newLine(1); 44 | $io->writeln('# app/config/config.yml'); 45 | } else { 46 | $io->writeln('Update config/packages/bentools_webpush.yaml:'); 47 | $io->newLine(1); 48 | $io->writeln('# config/packages/bentools_webpush.yaml'); 49 | } 50 | 51 | $io->writeln(<<get('doctrine'); 26 | $registry = self::getContainer()->get(UserSubscriptionManagerRegistry::class); 27 | $em = $persistence->getManagerForClass(TestUser::class); 28 | $bob = new TestUser('bob'); 29 | $em->persist($bob); 30 | $em->flush(); 31 | $this->assertNotNull($em->find(TestUser::class, 'bob')); 32 | 33 | /** @var RegisterSubscriptionAction $register */ 34 | $register = self::getContainer()->get(RegisterSubscriptionAction::class); 35 | 36 | $rawSubscriptionData = [ 37 | 'subscription' => [ 38 | 'endpoint' => 'http://foo.bar', 39 | 'keys' => [ 40 | 'p256dh' => 'bob_public_key', 41 | 'auth' => 'bob_private_key', 42 | ] 43 | ] 44 | ]; 45 | 46 | $request = new Request([], [], [], [], [], ['REQUEST_METHOD' => 'POST'], json_encode($rawSubscriptionData)); 47 | $register($request, $bob); 48 | 49 | $subscriptions = $registry->getManager($bob)->findByUser($bob); 50 | $this->assertCount(1, $subscriptions); 51 | 52 | $request = new Request([], [], [], [], [], ['REQUEST_METHOD' => 'DELETE'], json_encode($rawSubscriptionData)); 53 | $register($request, $bob); 54 | 55 | $subscriptions = $registry->getManager($bob)->findByUser($bob); 56 | $this->assertCount(0, $subscriptions); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Resources/config/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | %bentools_webpush.vapid_public_key% 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | %bentools_webpush.vapid_subject% 30 | %bentools_webpush.vapid_public_key% 31 | %bentools_webpush.vapid_private_key% 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /tests/Classes/TestUserSubscriptionManager.php: -------------------------------------------------------------------------------- 1 | doctrine->getManagerForClass(TestUserSubscription::class)->getRepository(TestUserSubscription::class)->findOneBy([ 44 | 'user' => $user, 45 | 'subscriptionHash' => $subscriptionHash, 46 | ]); 47 | } 48 | 49 | /** 50 | * @inheritDoc 51 | */ 52 | public function findByUser(UserInterface $user): iterable 53 | { 54 | return $this->doctrine->getManagerForClass(TestUserSubscription::class)->getRepository(TestUserSubscription::class)->findBy([ 55 | 'user' => $user, 56 | ]); 57 | } 58 | 59 | /** 60 | * @inheritDoc 61 | */ 62 | public function findByHash(string $subscriptionHash): iterable 63 | { 64 | return $this->doctrine->getManagerForClass(TestUserSubscription::class)->getRepository(TestUserSubscription::class)->findBy([ 65 | 'subscriptionHash' => $subscriptionHash, 66 | ]); 67 | } 68 | 69 | /** 70 | * @inheritDoc 71 | */ 72 | public function save(UserSubscriptionInterface $userSubscription): void 73 | { 74 | $this->doctrine->getManagerForClass(TestUserSubscription::class)->persist($userSubscription); 75 | $this->doctrine->getManagerForClass(TestUserSubscription::class)->flush(); 76 | } 77 | 78 | /** 79 | * @inheritDoc 80 | */ 81 | public function delete(UserSubscriptionInterface $userSubscription): void 82 | { 83 | $this->doctrine->getManagerForClass(TestUserSubscription::class)->remove($userSubscription); 84 | $this->doctrine->getManagerForClass(TestUserSubscription::class)->flush(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest Stable Version](https://poser.pugx.org/bentools/webpush-bundle/v/stable)](https://packagist.org/packages/bentools/webpush-bundle) 2 | [![License](https://poser.pugx.org/bentools/webpush-bundle/license)](https://packagist.org/packages/bentools/webpush-bundle) 3 | [![CI](https://github.com/bpolaszek/webpush-bundle/actions/workflows/ci.yml/badge.svg)](https://github.com/bpolaszek/webpush-bundle/actions/workflows/ci.yml) 4 | [![Total Downloads](https://poser.pugx.org/bentools/webpush-bundle/downloads)](https://packagist.org/packages/bentools/webpush-bundle) 5 | 6 | # Webpush Bundle 7 | 8 | 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. 9 | 10 | 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: 11 | 12 | * A single user can subscribe from multiple browsers/devices 13 | * Multiple users can subscribe from a single browser/device 14 | 15 | This bundle uses your own persistence system (Doctrine or anything else) to manage these associations. 16 | 17 | 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/). 18 | 19 | **Example Use cases** 20 | 21 | * You have a todolist app - notify users they're assigned a task 22 | * You have an eCommerce app: 23 | * Notify your customer their order has been shipped 24 | * Notify your category manager they sell a product 25 | 26 | 27 | ## Summary 28 | 29 | 1. [Installation](#getting-started) 30 | 2. [The UserSubscription entity](doc/01%20-%20The%20UserSubscription%20Class.md) 31 | 3. [The UserSubscription manager](doc/02%20-%20The%20UserSubscription%20Manager.md) 32 | 4. [Configure the bundle](doc/03%20-%20Configuration.md) 33 | 5. [Enjoy!](doc/04%20-%20Usage.md) 34 | 6. [F.A.Q.](doc/05%20-%20FAQ.md) 35 | 36 | ## Getting started 37 | 38 | 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. 39 | 40 | ### Composer is your friend: 41 | 42 | PHP8.1+ is required. 43 | 44 | ```bash 45 | composer require bentools/webpush-bundle 46 | ``` 47 | 48 | ⚠️ _We aren't on stable version yet - expect some changes._ 49 | 50 | 51 | 52 | ### Generate your VAPID keys: 53 | 54 | ```bash 55 | php bin/console webpush:generate:keys 56 | ``` 57 | 58 | You'll have to update your config with the given keys. We encourage you to store them in environment variables or in `parameters.yml`. 59 | 60 | 61 | Next: [Create your UserSubscription class](doc/01%20-%20The%20UserSubscription%20Class.md) 62 | 63 | ## Tests 64 | 65 | > ./vendor/bin/phpunit 66 | 67 | ## License 68 | 69 | MIT 70 | 71 | ## Credits 72 | 73 | This bundle leverages the [minishlink/web-push](https://github.com/web-push-libs/web-push-php) library. 74 | -------------------------------------------------------------------------------- /src/Action/RegisterSubscriptionAction.php: -------------------------------------------------------------------------------- 1 | registry->getManager($user); 31 | $userSubscription = $manager->getUserSubscription($user, $subscriptionHash) 32 | or $userSubscription = $manager->factory($user, $subscriptionHash, $subscription, $options); 33 | $manager->save($userSubscription); 34 | } 35 | 36 | /** 37 | * @throws BadRequestHttpException 38 | * @throws \RuntimeException 39 | */ 40 | private function unsubscribe(UserInterface $user, string $subscriptionHash): void 41 | { 42 | $manager = $this->registry->getManager($user); 43 | $subscription = $manager->getUserSubscription($user, $subscriptionHash); 44 | if (null === $subscription) { 45 | throw new BadRequestHttpException('Subscription hash not found'); 46 | } 47 | $manager->delete($subscription); 48 | } 49 | 50 | public function __invoke(Request $request, ?UserInterface $user = null): Response 51 | { 52 | if (null === $user) { 53 | throw new AccessDeniedHttpException('Not authenticated.'); 54 | } 55 | 56 | if (!in_array($request->getMethod(), ['POST', 'DELETE'])) { 57 | throw new MethodNotAllowedHttpException(['POST', 'DELETE']); 58 | } 59 | 60 | $data = json_decode($request->getContent(), true); 61 | $subscription = $data['subscription'] ?? []; 62 | $options = $data['options'] ?? []; 63 | 64 | if (JSON_ERROR_NONE !== json_last_error()) { 65 | throw new BadRequestHttpException(json_last_error_msg()); 66 | } 67 | 68 | if (!isset($subscription['endpoint'])) { 69 | throw new BadRequestHttpException('Invalid subscription object.'); 70 | } 71 | 72 | $manager = $this->registry->getManager($user); 73 | $subscriptionHash = $manager->hash($subscription['endpoint'], $user); 74 | 75 | if ('DELETE' === $request->getMethod()) { 76 | $this->unsubscribe($user, $subscriptionHash); 77 | } else { 78 | $this->subscribe($user, $subscriptionHash, $subscription, $options); 79 | } 80 | 81 | return new Response('', Response::HTTP_NO_CONTENT); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /doc/01 - The UserSubscription Class.md: -------------------------------------------------------------------------------- 1 | ## Create your UserSubscription class 2 | 3 | First, you have to implement `BenTools\WebPushBundle\Model\Subscription\UserSubscriptionInterface`. 4 | 5 | It's a simple entity which associates: 6 | 1. Your user entity 7 | 2. The subscription details - it will store the JSON representation of the `PushSubscription` javascript object. 8 | 3. A hash of the endpoint (or any string that could help in retrieving it). 9 | 10 | You're free to use Doctrine or anything else. 11 | 12 | Example class: 13 | ```php 14 | # src/Entity/UserSubscription.php 15 | 16 | namespace App\Entity; 17 | 18 | use BenTools\WebPushBundle\Model\Subscription\UserSubscriptionInterface; 19 | use Doctrine\ORM\Mapping as ORM; 20 | use Symfony\Component\Security\Core\User\UserInterface; 21 | 22 | /** 23 | * @ORM\Entity() 24 | */ 25 | class UserSubscription implements UserSubscriptionInterface 26 | { 27 | 28 | /** 29 | * @var int 30 | * 31 | * @ORM\Id() 32 | * @ORM\GeneratedValue(strategy="AUTO") 33 | * @ORM\Column(type="integer") 34 | */ 35 | private $id; 36 | 37 | /** 38 | * @var User 39 | * @ORM\ManyToOne(targetEntity="App\Entity\User") 40 | * @ORM\JoinColumn(nullable=false) 41 | */ 42 | private $user; 43 | 44 | /** 45 | * @var string 46 | * 47 | * @ORM\Column(type="string") 48 | */ 49 | private $subscriptionHash; 50 | 51 | /** 52 | * @var array 53 | * 54 | * @ORM\Column(type="json") 55 | */ 56 | private $subscription; 57 | 58 | /** 59 | * UserSubscription constructor. 60 | * @param User $user 61 | * @param string $subscriptionHash 62 | * @param array $subscription 63 | */ 64 | public function __construct(User $user, string $subscriptionHash, array $subscription) 65 | { 66 | $this->user = $user; 67 | $this->subscriptionHash = $subscriptionHash; 68 | $this->subscription = $subscription; 69 | } 70 | 71 | /** 72 | * @return int 73 | */ 74 | public function getId(): ?int 75 | { 76 | return $this->id; 77 | } 78 | 79 | /** 80 | * @inheritDoc 81 | */ 82 | public function getUser(): UserInterface 83 | { 84 | return $this->user; 85 | } 86 | 87 | /** 88 | * @inheritDoc 89 | */ 90 | public function getSubscriptionHash(): string 91 | { 92 | return $this->subscriptionHash; 93 | } 94 | 95 | /** 96 | * @inheritDoc 97 | */ 98 | public function getEndpoint(): string 99 | { 100 | return $this->subscription['endpoint']; 101 | } 102 | 103 | /** 104 | * @inheritDoc 105 | */ 106 | public function getPublicKey(): string 107 | { 108 | return $this->subscription['keys']['p256dh']; 109 | } 110 | 111 | /** 112 | * @inheritDoc 113 | */ 114 | public function getAuthToken(): string 115 | { 116 | return $this->subscription['keys']['auth']; 117 | } 118 | 119 | 120 | /** 121 | * Content-encoding (default: aesgcm). 122 | * 123 | * @return string 124 | */ 125 | public function getContentEncoding(): string 126 | { 127 | return $this->subscription['content-encoding'] ?? 'aesgcm'; 128 | } 129 | 130 | } 131 | ``` 132 | 133 | Previous: [Installation](../README.md#getting-started) 134 | 135 | Next: [The UserSubscription Manager](02%20-%20The%20UserSubscription%20Manager.md) -------------------------------------------------------------------------------- /doc/04 - Usage.md: -------------------------------------------------------------------------------- 1 | ## Now, send notifications! 2 | 3 | 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. 4 | 5 | ```php 6 | namespace App\Services; 7 | 8 | use App\Entity\Employee; 9 | use App\Entity\Order; 10 | use App\Events\OrderEvent; 11 | use App\Events\OrderEvents; 12 | use BenTools\WebPushBundle\Model\Message\PushNotification; 13 | use BenTools\WebPushBundle\Model\Subscription\UserSubscriptionManagerRegistry; 14 | use BenTools\WebPushBundle\Sender\PushMessageSender; 15 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 16 | 17 | class NotificationSenderListener implements EventSubscriberInterface 18 | { 19 | /** 20 | * @var UserSubscriptionManagerRegistry 21 | */ 22 | private $userSubscriptionManager; 23 | 24 | /** 25 | * @var PushMessageSender 26 | */ 27 | private $sender; 28 | 29 | /** 30 | * NotificationSender constructor. 31 | * @param UserSubscriptionManagerRegistry $userSubscriptionManager 32 | * @param PushMessageSender $sender 33 | */ 34 | public function __construct( 35 | UserSubscriptionManagerRegistry $userSubscriptionManager, 36 | PushMessageSender $sender 37 | ) { 38 | $this->userSubscriptionManager = $userSubscriptionManager; 39 | $this->sender = $sender; 40 | } 41 | 42 | public static function getSubscribedEvents() 43 | { 44 | return [ 45 | OrderEvents::PLACED => 'onOrderPlaced', 46 | ]; 47 | } 48 | 49 | /** 50 | * @param OrderEvent $event 51 | */ 52 | public function onOrderPlaced(OrderEvent $event): void 53 | { 54 | $order = $event->getOrder(); 55 | $this->notifyCustomer($order); 56 | $this->notifyCategoryManagers($order); 57 | } 58 | 59 | /** 60 | * @param Order $order 61 | */ 62 | private function notifyCustomer(Order $order): void 63 | { 64 | $customer = $order->getCustomer(); 65 | $subscriptions = $this->userSubscriptionManager->findByUser($customer); 66 | $notification = new PushNotification('Congratulations!', [ 67 | PushNotification::BODY => 'Your order has been placed.', 68 | PushNotification::ICON => '/assets/icon_success.png', 69 | ]); 70 | $responses = $this->sender->push($notification->createMessage(), $subscriptions); 71 | 72 | foreach ($responses as $response) { 73 | if ($response->isExpired()) { 74 | $this->userSubscriptionManager->delete($response->getSubscription()); 75 | } 76 | } 77 | } 78 | 79 | /** 80 | * @param Order $order 81 | */ 82 | private function notifyCategoryManagers(Order $order): void 83 | { 84 | $products = $order->getProducts(); 85 | $employees = []; 86 | foreach ($products as $product) { 87 | $employees[] = $product->getCategoryManager(); 88 | } 89 | 90 | $employees = array_unique($employees); 91 | 92 | $subscriptions = []; 93 | foreach ($employees as $employee) { 94 | foreach ($this->userSubscriptionManager->findByUser($employee) as $subscription) { 95 | $subscriptions[] = $subscription; 96 | } 97 | } 98 | 99 | $notification = new PushNotification('A new order has been placed!', [ 100 | PushNotification::BODY => 'A customer just bought some of your products.', 101 | PushNotification::ICON => '/assets/icon_success.png', 102 | ]); 103 | 104 | $responses = $this->sender->push($notification->createMessage(), $subscriptions); 105 | 106 | foreach ($responses as $response) { 107 | if ($response->isExpired()) { 108 | $this->userSubscriptionManager->delete($response->getSubscription()); 109 | } 110 | } 111 | } 112 | } 113 | ``` 114 | 115 | Previous: [Configuration](03%20-%20Configuration.md) 116 | 117 | Next: [F.A.Q.](05%20-%20FAQ.md) -------------------------------------------------------------------------------- /doc/02 - The UserSubscription Manager.md: -------------------------------------------------------------------------------- 1 | The UserSubscription Manager will handle creation and persistence of `UserSubscription` entities. 2 | 3 | It can be connected to Doctrine, or you're free to use your own logic. 4 | 5 | ## Create your UserSubscription manager 6 | 7 | Then, create a class that implements `BenTools\WebPushBundle\Model\Subscription\UserSubscriptionManagerInterface` with your own logic. 8 | 9 | Example with Doctrine: 10 | ```php 11 | # src/Services/UserSubscriptionManager.php 12 | 13 | namespace App\Services; 14 | 15 | use App\Entity\UserSubscription; 16 | use BenTools\WebPushBundle\Model\Subscription\UserSubscriptionInterface; 17 | use BenTools\WebPushBundle\Model\Subscription\UserSubscriptionManagerInterface; 18 | use Doctrine\Persistence\ManagerRegistry; 19 | use Symfony\Component\Security\Core\User\UserInterface; 20 | 21 | class UserSubscriptionManager implements UserSubscriptionManagerInterface 22 | { 23 | /** 24 | * @var ManagerRegistry 25 | */ 26 | private $doctrine; 27 | 28 | /** 29 | * UserSubscriptionManager constructor. 30 | * @param ManagerRegistry $doctrine 31 | */ 32 | public function __construct(ManagerRegistry $doctrine) 33 | { 34 | $this->doctrine = $doctrine; 35 | } 36 | 37 | /** 38 | * @inheritDoc 39 | */ 40 | public function factory(UserInterface $user, string $subscriptionHash, array $subscription, array $options = []): UserSubscriptionInterface 41 | { 42 | // $options is an arbitrary array that can be provided through the front-end code. 43 | // You can use it to store meta-data about the subscription: the user agent, the referring domain, ... 44 | return new UserSubscription($user, $subscriptionHash, $subscription); 45 | } 46 | 47 | /** 48 | * @inheritDoc 49 | */ 50 | public function hash(string $endpoint, UserInterface $user): string { 51 | return md5($endpoint); // Encode it as you like 52 | } 53 | 54 | /** 55 | * @inheritDoc 56 | */ 57 | public function getUserSubscription(UserInterface $user, string $subscriptionHash): ?UserSubscriptionInterface 58 | { 59 | return $this->doctrine->getManager()->getRepository(UserSubscription::class)->findOneBy([ 60 | 'user' => $user, 61 | 'subscriptionHash' => $subscriptionHash, 62 | ]); 63 | } 64 | 65 | /** 66 | * @inheritDoc 67 | */ 68 | public function findByUser(UserInterface $user): iterable 69 | { 70 | return $this->doctrine->getManager()->getRepository(UserSubscription::class)->findBy([ 71 | 'user' => $user, 72 | ]); 73 | } 74 | 75 | /** 76 | * @inheritDoc 77 | */ 78 | public function findByHash(string $subscriptionHash): iterable 79 | { 80 | return $this->doctrine->getManager()->getRepository(UserSubscription::class)->findBy([ 81 | 'subscriptionHash' => $subscriptionHash, 82 | ]); 83 | } 84 | 85 | /** 86 | * @inheritDoc 87 | */ 88 | public function save(UserSubscriptionInterface $userSubscription): void 89 | { 90 | $this->doctrine->getManager()->persist($userSubscription); 91 | $this->doctrine->getManager()->flush(); 92 | } 93 | 94 | /** 95 | * @inheritDoc 96 | */ 97 | public function delete(UserSubscriptionInterface $userSubscription): void 98 | { 99 | $this->doctrine->getManager()->remove($userSubscription); 100 | $this->doctrine->getManager()->flush(); 101 | } 102 | 103 | } 104 | ``` 105 | 106 | Now, register your `UserSubscriptionManager` in your `services.yaml`: 107 | 108 | ```yaml 109 | # app/config/services.yml (SF3) 110 | # config/services.yaml (SF4) 111 | 112 | services: 113 | App\Services\UserSubscriptionManager: 114 | class: App\Services\UserSubscriptionManager 115 | arguments: 116 | - '@doctrine' 117 | tags: 118 | - { name: bentools_webpush.subscription_manager, user_class: 'App\Entity\User' } 119 | ``` 120 | 121 | Previous: [The UserSubscription Class](01%20-%20The%20UserSubscription%20Class.md) 122 | 123 | Next: [Configuration](03%20-%20Configuration.md) 124 | -------------------------------------------------------------------------------- /src/Model/Subscription/UserSubscriptionManagerRegistry.php: -------------------------------------------------------------------------------- 1 | registry)) { 27 | throw new \InvalidArgumentException(sprintf('User class %s is already registered.', $userClass)); 28 | } 29 | 30 | if (self::class === get_class($userSubscriptionManager)) { 31 | throw new \InvalidArgumentException(sprintf('You must define your own user subscription manager for %s.', $userClass)); 32 | } 33 | 34 | $this->registry[$userClass] = $userSubscriptionManager; 35 | } 36 | 37 | /** 38 | * @param UserInterface|string $userClass 39 | * 40 | * @throws RuntimeException 41 | * @throws ServiceNotFoundException 42 | * @throws \InvalidArgumentException 43 | * @throws \Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException 44 | */ 45 | public function getManager(UserInterface|string $userClass): UserSubscriptionManagerInterface 46 | { 47 | if (!is_a($userClass, UserInterface::class, true)) { 48 | throw new \InvalidArgumentException(sprintf('Expected class or object that implements %s, %s given', UserInterface::class, is_object($userClass) ? get_class($userClass) : gettype($userClass))); 49 | } 50 | 51 | if (is_object($userClass)) { 52 | $userClass = get_class($userClass); 53 | } 54 | 55 | // Case of a doctrine proxied class 56 | if (0 === strpos($userClass, 'Proxies\__CG__') && class_exists('Doctrine\Common\Util\ClassUtils')) { 57 | return $this->getManager(ClassUtils::getRealClass($userClass)); 58 | } 59 | 60 | if (!isset($this->registry[$userClass])) { 61 | throw new \InvalidArgumentException(sprintf('There is no user subscription manager configured for class %s.', $userClass)); 62 | } 63 | 64 | return $this->registry[$userClass]; 65 | } 66 | 67 | /** 68 | * {@inheritdoc} 69 | */ 70 | public function factory(UserInterface $user, string $subscriptionHash, array $subscription, array $options = []): UserSubscriptionInterface 71 | { 72 | return $this->getManager($user)->factory($user, $subscriptionHash, $subscription, $options); 73 | } 74 | 75 | /** 76 | * {@inheritdoc} 77 | */ 78 | public function hash(string $endpoint, UserInterface $user): string 79 | { 80 | return $this->getManager($user)->hash($endpoint, $user); 81 | } 82 | 83 | /** 84 | * {@inheritdoc} 85 | */ 86 | public function getUserSubscription(UserInterface $user, string $subscriptionHash): ?UserSubscriptionInterface 87 | { 88 | return $this->getManager($user)->getUserSubscription($user, $subscriptionHash); 89 | } 90 | 91 | /** 92 | * {@inheritdoc} 93 | */ 94 | public function findByUser(UserInterface $user): iterable 95 | { 96 | return $this->getManager($user)->findByUser($user); 97 | } 98 | 99 | /** 100 | * {@inheritdoc} 101 | */ 102 | public function findByHash(string $subscriptionHash): iterable 103 | { 104 | foreach ($this->registry as $manager) { 105 | foreach ($manager->findByHash($subscriptionHash) as $userSubscription) { 106 | yield $userSubscription; 107 | } 108 | } 109 | } 110 | 111 | /** 112 | * {@inheritdoc} 113 | */ 114 | public function save(UserSubscriptionInterface $userSubscription): void 115 | { 116 | $this->getManager($userSubscription->getUser())->save($userSubscription); 117 | } 118 | 119 | /** 120 | * {@inheritdoc} 121 | */ 122 | public function delete(UserSubscriptionInterface $userSubscription): void 123 | { 124 | $this->getManager($userSubscription->getUser())->delete($userSubscription); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Model/Message/PushNotification.php: -------------------------------------------------------------------------------- 1 | title; 38 | } 39 | 40 | public function setTitle(?string $title): void 41 | { 42 | $this->title = $title; 43 | } 44 | 45 | public function getOptions(): array 46 | { 47 | return $this->options; 48 | } 49 | 50 | public function setOptions(array $options): void 51 | { 52 | $this->options = $options; 53 | } 54 | 55 | public function setOption(string $key, mixed $value): void 56 | { 57 | if (null === $value) { 58 | unset($this->options[$key]); 59 | 60 | return; 61 | } 62 | 63 | $this->options[$key] = $value; 64 | } 65 | 66 | public function getOption(string $key): mixed 67 | { 68 | return $this->options[$key] ?? null; 69 | } 70 | 71 | public function createMessage(array $options = [], array $auth = []): PushMessage 72 | { 73 | return new PushMessage((string) $this, $options, $auth); 74 | } 75 | 76 | public function jsonSerialize(): array 77 | { 78 | return [ 79 | 'title' => $this->title, 80 | 'options' => self::sanitize($this->options), 81 | ]; 82 | } 83 | 84 | public function __toString(): string 85 | { 86 | return (string) json_encode($this); 87 | } 88 | 89 | /** 90 | * Whether a offset exists. 91 | * 92 | * @see https://php.net/manual/en/arrayaccess.offsetexists.php 93 | * 94 | * @param mixed $offset

95 | * An offset to check for. 96 | *

97 | * 98 | * @return bool true on success or false on failure. 99 | *

100 | *

101 | * The return value will be casted to boolean if non-boolean was returned. 102 | * 103 | * @since 5.0.0 104 | */ 105 | public function offsetExists(mixed $offset): bool 106 | { 107 | return array_key_exists($offset, $this->options); 108 | } 109 | 110 | /** 111 | * Offset to retrieve. 112 | * 113 | * @see https://php.net/manual/en/arrayaccess.offsetget.php 114 | * 115 | * @param mixed $offset

116 | * The offset to retrieve. 117 | *

118 | * 119 | * @return mixed can return all value types 120 | * 121 | * @since 5.0.0 122 | */ 123 | public function offsetGet(mixed $offset): mixed 124 | { 125 | return $this->options[$offset] ?? null; 126 | } 127 | 128 | /** 129 | * Offset to set. 130 | * 131 | * @see https://php.net/manual/en/arrayaccess.offsetset.php 132 | * 133 | * @param mixed $offset

134 | * The offset to assign the value to. 135 | *

136 | * @param mixed $value

137 | * The value to set. 138 | *

139 | * 140 | * @return void 141 | * 142 | * @since 5.0.0 143 | */ 144 | public function offsetSet(mixed $offset, mixed $value): void 145 | { 146 | $this->options[$offset] = $value; 147 | } 148 | 149 | /** 150 | * Offset to unset. 151 | * 152 | * @see https://php.net/manual/en/arrayaccess.offsetunset.php 153 | * 154 | * @param mixed $offset

155 | * The offset to unset. 156 | *

157 | * 158 | * @return void 159 | * 160 | * @since 5.0.0 161 | */ 162 | public function offsetUnset(mixed $offset): void 163 | { 164 | unset($this->options[$offset]); 165 | } 166 | 167 | private static function sanitize(mixed $input): mixed 168 | { 169 | if (is_array($input)) { 170 | foreach ($input as $key => $value) { 171 | if (null === $value) { 172 | unset($input[$key]); 173 | } 174 | if (is_array($value)) { 175 | $input[$key] = self::sanitize($input[$key]); 176 | } 177 | } 178 | } 179 | 180 | return $input; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bentools/webpush-bundle", 3 | "type": "symfony-bundle", 4 | "description": "Send push notifications through Web Push Protocol to your Symfony users.", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Beno!t POLASZEK", 9 | "email": "bpolaszek@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=8.1", 14 | "ext-curl": "*", 15 | "ext-json": "*", 16 | "ext-mbstring": "*", 17 | "ext-openssl": "*", 18 | "guzzlehttp/guzzle": "^6.5.8 || ^7.4", 19 | "minishlink/web-push": "^6.0.7 || ^7.0 || ^8.0 || ^9.0", 20 | "symfony/http-kernel": "^5.4.20 || ^6.0 || ^7.0" 21 | }, 22 | "require-dev": { 23 | "bentools/doctrine-static": "1.0.x-dev", 24 | "doctrine/dbal": "^2.9 || ^3.0", 25 | "phpunit/phpunit": "^9.0", 26 | "symfony/config": "^5.4 || ^6.0 || ^7.0", 27 | "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", 28 | "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0", 29 | "symfony/http-foundation": "^5.4 || ^6.0 || ^7.0", 30 | "symfony/routing": "^5.4 || ^6.0 || ^7.0", 31 | "symfony/security-core": "^5.4 || ^6.0 || ^7.0", 32 | "symfony/var-dumper": "^5.4 || ^6.0 || ^7.0", 33 | "symfony/yaml": "^5.4 || ^6.0 || ^7.0", 34 | "twig/twig": "^2.0 || ^3.0" 35 | }, 36 | "autoload": { 37 | "psr-4": { 38 | "BenTools\\WebPushBundle\\": "src" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "BenTools\\WebPushBundle\\Tests\\": "tests" 44 | } 45 | }, 46 | "config": { 47 | "sort-packages": true 48 | }, 49 | "extra": { 50 | "branch-alias": { 51 | "dev-master": "1.0.x-dev" 52 | } 53 | }, 54 | "keywords": [ 55 | "PushNotification", 56 | "PushNotification bundle", 57 | "PushSubsciption", 58 | "PushSubsciption bundle", 59 | "notification", 60 | "notification bundle", 61 | "notifications", 62 | "notifications bundle", 63 | "subscription", 64 | "subscription bundle", 65 | "subscriptions", 66 | "subscriptions bundle", 67 | "push", 68 | "push bundle", 69 | "push notification", 70 | "push notification bundle", 71 | "push notifications", 72 | "push notifications bundle", 73 | "push subscription", 74 | "push subscription bundle", 75 | "push subscriptions", 76 | "push subscriptions bundle", 77 | "webpush", 78 | "webpush protocol", 79 | "webpush bundle", 80 | "webpush notification", 81 | "webpush notification bundle", 82 | "webpush notifications", 83 | "webpush notifications bundle", 84 | "webpush subscription", 85 | "webpush subscription bundle", 86 | "webpush subscriptions", 87 | "webpush subscriptions bundle", 88 | "web push", 89 | "web push protocol", 90 | "web push bundle", 91 | "web push notification", 92 | "web push notification bundle", 93 | "web push notifications", 94 | "web push notifications bundle", 95 | "web push subscription", 96 | "web push subscription bundle", 97 | "web push subscriptions", 98 | "web push subscriptions bundle", 99 | "symfony PushNotification", 100 | "symfony PushNotification bundle", 101 | "symfony PushSubsciption", 102 | "symfony PushSubsciption bundle", 103 | "symfony notification", 104 | "symfony notification bundle", 105 | "symfony notifications", 106 | "symfony notifications bundle", 107 | "symfony subscription", 108 | "symfony subscription bundle", 109 | "symfony subscriptions", 110 | "symfony subscriptions bundle", 111 | "symfony push", 112 | "symfony push bundle", 113 | "symfony push notification", 114 | "symfony push notification bundle", 115 | "symfony push notifications", 116 | "symfony push notifications bundle", 117 | "symfony push subscription", 118 | "symfony push subscription bundle", 119 | "symfony push subscriptions", 120 | "symfony push subscriptions bundle", 121 | "symfony webpush", 122 | "symfony webpush bundle", 123 | "symfony webpush notification", 124 | "symfony webpush notification bundle", 125 | "symfony webpush notifications", 126 | "symfony webpush notifications bundle", 127 | "symfony webpush subscription", 128 | "symfony webpush subscription bundle", 129 | "symfony webpush subscriptions", 130 | "symfony webpush subscriptions bundle", 131 | "symfony web push", 132 | "symfony web push bundle", 133 | "symfony web push notification", 134 | "symfony web push notification bundle", 135 | "symfony web push notifications", 136 | "symfony web push notifications bundle", 137 | "symfony web push subscription", 138 | "symfony web push subscription bundle", 139 | "symfony web push subscriptions", 140 | "symfony web push subscriptions bundle", 141 | "flex", 142 | "vapid", 143 | "fcm", 144 | "browser", 145 | "chrome", 146 | "firefox" 147 | ] 148 | } 149 | -------------------------------------------------------------------------------- /src/Sender/RequestBuilder.php: -------------------------------------------------------------------------------- 1 | getEndpoint()); 33 | $request = $this->withOptionalHeaders($request, $message); 34 | $request = $request->withHeader('TTL', $ttl); 35 | 36 | if (null !== $message->getPayload() && null !== $subscription->getPublicKey() && null !== $subscription->getAuthToken()) { 37 | $request = $request 38 | ->withHeader('Content-Type', 'application/octet-stream') 39 | ->withHeader('Content-Encoding', $subscription->getContentEncoding()); 40 | 41 | $payload = $this->getNormalizedPayload($message->getPayload(), $subscription->getContentEncoding(), $maxPaddingLength); 42 | 43 | $encrypted = Encryption::encrypt( 44 | $payload, 45 | $subscription->getPublicKey(), 46 | $subscription->getAuthToken(), 47 | $subscription->getContentEncoding() 48 | ); 49 | 50 | if ('aesgcm' === $subscription->getContentEncoding()) { 51 | $request = $request->withHeader('Encryption', 'salt='.Base64Url::encode($encrypted['salt'])) 52 | ->withHeader('Crypto-Key', 'dh='.Base64Url::encode($encrypted['localPublicKey'])); 53 | } 54 | 55 | $encryptionContentCodingHeader = Encryption::getContentCodingHeader($encrypted['salt'], $encrypted['localPublicKey'], $subscription->getContentEncoding()); 56 | $content = $encryptionContentCodingHeader.$encrypted['cipherText']; 57 | 58 | return $request 59 | ->withBody(GuzzleUtils::streamFor($content)) 60 | ->withHeader('Content-Length', Utils::safeStrlen($content)); 61 | } 62 | 63 | return $request 64 | ->withHeader('Content-Length', 0); 65 | } 66 | 67 | /** 68 | * @throws \ErrorException 69 | * @throws \InvalidArgumentException 70 | */ 71 | public function withVAPIDAuthentication(RequestInterface $request, array $vapid, UserSubscriptionInterface $subscription): RequestInterface 72 | { 73 | $endpoint = $subscription->getEndpoint(); 74 | $audience = parse_url($endpoint, PHP_URL_SCHEME).'://'.parse_url($endpoint, PHP_URL_HOST); 75 | 76 | if (!parse_url($audience)) { 77 | throw new \ErrorException('Audience "'.$audience.'"" could not be generated.'); 78 | } 79 | 80 | $vapidHeaders = VAPID::getVapidHeaders($audience, $vapid['subject'], $vapid['publicKey'], $vapid['privateKey'], $subscription->getContentEncoding()); 81 | 82 | $request = $request->withHeader('Authorization', $vapidHeaders['Authorization']); 83 | 84 | if ('aesgcm' === $subscription->getContentEncoding()) { 85 | if ($request->hasHeader('Crypto-Key')) { 86 | $request = $request->withHeader('Crypto-Key', $request->getHeaderLine('Crypto-Key').';'.$vapidHeaders['Crypto-Key']); 87 | } else { 88 | $headers['Crypto-Key'] = $vapidHeaders['Crypto-Key']; 89 | $request->withHeader('Crypto-Key', $vapidHeaders['Crypto-Key']); 90 | } 91 | } elseif ('aes128gcm' === $subscription->getContentEncoding() && self::FCM_BASE_URL === substr($endpoint, 0, strlen(self::FCM_BASE_URL))) { 92 | $request = $request->withUri(new Uri(str_replace('fcm/send', 'wp', $endpoint))); 93 | } 94 | 95 | return $request; 96 | } 97 | 98 | /** 99 | * @throws \InvalidArgumentException 100 | */ 101 | public function withGCMAuthentication(RequestInterface $request, string $apiKey): RequestInterface 102 | { 103 | return $request->withHeader('Authorization', 'key='.$apiKey); 104 | } 105 | 106 | /** 107 | * @throws \InvalidArgumentException 108 | */ 109 | private function withOptionalHeaders(RequestInterface $request, PushMessage $message): RequestInterface 110 | { 111 | foreach (['urgency', 'topic'] as $option) { 112 | if (null !== $message->getOption($option)) { 113 | $request = $request->withHeader($option, $message->getOption($option)); 114 | } 115 | } 116 | 117 | return $request; 118 | } 119 | 120 | /** 121 | * @param mixed $automaticPadding 122 | * 123 | * @throws \ErrorException 124 | */ 125 | private function getNormalizedPayload(?string $payload, string $contentEncoding, $automaticPadding): ?string 126 | { 127 | if (null === $payload) { 128 | return null; 129 | } 130 | if (Utils::safeStrlen($payload) > Encryption::MAX_PAYLOAD_LENGTH) { 131 | throw new \ErrorException('Size of payload must not be greater than '.Encryption::MAX_PAYLOAD_LENGTH.' bytes.'); 132 | } 133 | 134 | return Encryption::padPayload($payload, $automaticPadding, $contentEncoding); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Sender/PushMessageSender.php: -------------------------------------------------------------------------------- 1 | auth = $auth; 57 | $this->setDefaultOptions($defaultOptions); 58 | $this->client = $client ?? new Client(); 59 | $this->requestBuilder = new RequestBuilder(); 60 | } 61 | 62 | /** 63 | * @return PushResponse[] 64 | * 65 | * @throws \ErrorException 66 | * @throws \InvalidArgumentException 67 | * @throws \LogicException 68 | */ 69 | public function push(PushMessage $message, iterable $subscriptions): iterable 70 | { 71 | /** @var UserSubscriptionInterface[] $subscriptions */ 72 | $promises = []; 73 | 74 | if (isset($this->auth['VAPID']) && empty($this->auth['VAPID']['validated'])) { 75 | $this->auth['VAPID'] = VAPID::validate($this->auth['VAPID']) + ['validated' => true]; 76 | } 77 | 78 | foreach ($subscriptions as $subscription) { 79 | $subscriptionHash = $subscription->getSubscriptionHash(); 80 | $auth = $message->getAuth() + $this->auth; 81 | 82 | $request = $this->requestBuilder->createRequest( 83 | $message, 84 | $subscription, 85 | $message->getOption('TTL') ?? $this->defaultOptions['TTL'], 86 | $this->maxPaddingLength 87 | ); 88 | 89 | if (isset($auth['VAPID'])) { 90 | $request = $this->requestBuilder->withVAPIDAuthentication($request, $auth['VAPID'], $subscription); 91 | } elseif (isset($auth['GCM'])) { 92 | $request = $this->requestBuilder->withGCMAuthentication($request, $auth['GCM']); 93 | } 94 | 95 | $promises[$subscriptionHash] = $this->client->sendAsync($request, ['timeout' => self::DEFAULT_TIMEOUT]) 96 | ->then(function (ResponseInterface $response) use ($subscription) { 97 | return new PushResponse($subscription, $response->getStatusCode()); 98 | }) 99 | ->otherwise(function (\Throwable $reason) use ($subscription) { 100 | if ($reason instanceof RequestException && $reason->hasResponse()) { 101 | return new PushResponse($subscription, $reason->getResponse()->getStatusCode()); 102 | } 103 | 104 | throw $reason; 105 | }) 106 | ; 107 | } 108 | 109 | $promise = Promise\Utils::settle($promises) 110 | ->then(function ($results) { 111 | foreach ($results as $subscriptionHash => $promise) { 112 | yield $subscriptionHash => $promise['value'] ?? $promise['reason']; 113 | } 114 | }) 115 | ; 116 | 117 | return $promise->wait(); 118 | } 119 | 120 | public function isAutomaticPadding(): bool 121 | { 122 | return 0 !== $this->maxPaddingLength; 123 | } 124 | 125 | /** 126 | * @return int 127 | */ 128 | public function getMaxPaddingLength() 129 | { 130 | return $this->maxPaddingLength; 131 | } 132 | 133 | /** 134 | * @param int|bool $maxPaddingLength Max padding length 135 | * 136 | * @throws \Exception 137 | */ 138 | public function setMaxPaddingLength($maxPaddingLength): self 139 | { 140 | if ($maxPaddingLength > Encryption::MAX_PAYLOAD_LENGTH) { 141 | 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).'); 142 | } elseif ($maxPaddingLength < 0) { 143 | throw new \Exception('Padding length should be positive or zero.'); 144 | } elseif (true === $maxPaddingLength) { 145 | $this->maxPaddingLength = Encryption::MAX_COMPATIBILITY_PAYLOAD_LENGTH; 146 | } elseif (false === $maxPaddingLength) { 147 | $this->maxPaddingLength = 0; 148 | } else { 149 | $this->maxPaddingLength = $maxPaddingLength; 150 | } 151 | 152 | return $this; 153 | } 154 | 155 | public function getDefaultOptions(): array 156 | { 157 | return $this->defaultOptions; 158 | } 159 | 160 | /** 161 | * @param array $defaultOptions Keys 'TTL' (Time To Live, defaults 0), 'urgency', 'topic', 'batchSize' 162 | */ 163 | public function setDefaultOptions(array $defaultOptions) 164 | { 165 | $this->defaultOptions['TTL'] = $defaultOptions['TTL'] ?? 0; 166 | $this->defaultOptions['urgency'] = $defaultOptions['urgency'] ?? null; 167 | $this->defaultOptions['topic'] = $defaultOptions['topic'] ?? null; 168 | $this->defaultOptions['batchSize'] = $defaultOptions['batchSize'] ?? 1000; 169 | } 170 | } 171 | --------------------------------------------------------------------------------