├── .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 | [](https://packagist.org/packages/bentools/webpush-bundle)
2 | [](https://packagist.org/packages/bentools/webpush-bundle)
3 | [](https://github.com/bpolaszek/webpush-bundle/actions/workflows/ci.yml)
4 | [](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 |
--------------------------------------------------------------------------------