diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c37da9f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/.idea
+/vendor
+composer.lock
\ No newline at end of file
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..4048205
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,28 @@
+language: php
+
+cache:
+ directories:
+ - $HOME/.composer/cache
+
+php:
+ - '7.1'
+ - '7.2'
+ - '7.3'
+ - nightly
+
+env:
+ - COMPOSER_FLAGS="--prefer-lowest --prefer-stable"
+ - COMPOSER_FLAGS="--prefer-stable"
+
+matrix:
+ fast_finish: true
+ allow_failures:
+ - php: nightly
+
+
+install:
+ - composer update $COMPOSER_FLAGS --no-interaction --prefer-dist --no-progress --no-suggest --ansi
+
+script:
+ - vendor/bin/phpunit
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..30b7948
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2018 Beno!t POLASZEK
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
index a65b589..82969d9 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,23 @@
+[![Latest Stable Version](https://poser.pugx.org/bentools/webpush-bundle/v/stable)](https://packagist.org/packages/bentools/webpush-bundle)
+[![License](https://poser.pugx.org/bentools/webpush-bundle/license)](https://packagist.org/packages/bentools/webpush-bundle)
+[![Build Status](https://img.shields.io/travis/bpolaszek/webpush-bundle/master.svg?style=flat-square)](https://travis-ci.org/bpolaszek/webpush-bundle)
+[![Quality Score](https://img.shields.io/scrutinizer/g/bpolaszek/webpush-bundle.svg?style=flat-square)](https://scrutinizer-ci.com/g/bpolaszek/webpush-bundle)
+[![Total Downloads](https://poser.pugx.org/bentools/webpush-bundle/downloads)](https://packagist.org/packages/bentools/webpush-bundle)
+
# Webpush Bundle
-This bundle leverages [minishlink/web-push](https://github.com/web-push-libs/web-push-php) library to associate your Symfony users with Webpush subscriptions.
+This bundle allows your app to leverage [the Web Push protocol](https://developers.google.com/web/fundamentals/push-notifications/web-push-protocol) to send notifications to your users' devices, whether they're online or not.
+
+With a small amount of code, you'll be able to associate your [Symfony users](https://symfony.com/doc/current/security.html#a-create-your-user-class) to WebPush Subscriptions:
-This way you can integrate push messages into your app to send notifications.
+* A single user can subscribe from multiple browsers/devices
+* Multiple users can subscribe from a single browser/device
+
+This bundle uses your own persistence system (Doctrine or anything else) to manage these associations.
We assume you have a minimum knowledge of how Push Notifications work, otherwise we highly recommend you to read [Matt Gaunt's Web Push Book](https://web-push-book.gauntface.com/).
-## Use cases
+**Example Use cases**
* You have a todolist app - notify users they're assigned a task
* You have an eCommerce app:
@@ -25,51 +36,20 @@ We assume you have a minimum knowledge of how Push Notifications work, otherwise
## Getting started
-Because there can be different User implementations, and that some front-end is implied, there are several steps to follow to get started:
-1. Install the bundle and its assets
-2. Create your own `UserSubscription` class and its associated manager
-3. Update your `config.yml` and `routing.yml`
-4. Insert a JS snippet in your twig views.
-
-Let's go!
-
--------------
+This bundle is just the back-end part of the subscription process. For the front-end part, have a look at the [webpush-client](https://www.npmjs.com/package/webpush-client) package.
### Composer is your friend:
PHP7.1+ is required.
```bash
-composer require bentools/webpush-bundle 0.3.*
+composer require bentools/webpush-bundle 0.4.*
```
-_We aren't on stable version yet - expect some changes._
-
+If you're using Symfony 3, add the bundle to your kernel. With Symfony Flex, this should be done automatically.
-### Add the bundle to your kernel:
-```php
-# app/AppKernel.php
+⚠️ _We aren't on stable version yet - expect some changes._
-class AppKernel extends Kernel
-{
- public function registerBundles()
- {
- $bundles = [
- // ...
- new BenTools\WebPushBundle\WebPushBundle(),
- ];
-
- return $bundles;
- }
-}
-```
-
-### Install assets:
-
-```bash
-php bin/console assets:install --symlink
-```
-_We provide a service worker and a JS client._
### Generate your VAPID keys:
@@ -78,13 +58,19 @@ _We provide a service worker and a JS client._
php bin/console webpush:generate:keys
```
+You'll have to update your config with the given keys. We encourage you to store them in environment variables or in `parameters.yml`.
+
Next: [Create your UserSubscription class](doc/01%20-%20The%20UserSubscription%20Class.md)
## Tests
-We mostly need functionnal tests. Contributions are very welcome!
+> ./vendor/bin/phpunit
## License
-MIT
\ No newline at end of file
+MIT
+
+## Credits
+
+This bundle leverages the [minishlink/web-push](https://github.com/web-push-libs/web-push-php) library.
\ No newline at end of file
diff --git a/composer.json b/composer.json
index e5d7b04..5d1f61a 100644
--- a/composer.json
+++ b/composer.json
@@ -10,11 +10,26 @@
],
"require": {
"php": ">=7.1",
- "minishlink/web-push": "^2.0"
+ "ext-json": "*",
+ "ext-curl": "*",
+ "ext-mbstring": "*",
+ "ext-openssl": "*",
+ "minishlink/web-push": "^4.0",
+ "symfony/http-kernel": "^3.0|^4.0"
},
"require-dev": {
- "symfony/var-dumper": "^3.3",
- "symfony/symfony": "^2.7|^3.0|^4.0"
+ "bentools/doctrine-static": "1.0.x-dev",
+ "nyholm/symfony-bundle-test": "^1.4",
+ "phpunit/phpunit": "^5.0|^6.0|^7.0",
+ "symfony/config": "^4.1",
+ "symfony/dependency-injection": "^3.0|^4.0",
+ "symfony/framework-bundle": "^3.0|^4.0",
+ "symfony/http-foundation": "^3.0|^4.0",
+ "symfony/routing": "^3.0|^4.0",
+ "symfony/security": "^3.0|^4.0",
+ "symfony/var-dumper": "^3.0|^4.0",
+ "symfony/yaml": "^3.0|^4.0",
+ "twig/twig": "~1.0|~2.0"
},
"autoload": {
"psr-4": {
@@ -22,8 +37,11 @@
}
},
"autoload-dev": {
+ "psr-4": {
+ "BenTools\\WebPushBundle\\Tests\\": "tests"
+ },
"files": [
- "vendor/symfony/symfony/src/Symfony/Component/VarDumper/Resources/functions/dump.php"
+ "vendor/symfony/var-dumper/Resources/functions/dump.php"
]
},
"config": {
diff --git a/doc/01 - The UserSubscription Class.md b/doc/01 - The UserSubscription Class.md
index 679ae43..2e4f968 100644
--- a/doc/01 - The UserSubscription Class.md
+++ b/doc/01 - The UserSubscription Class.md
@@ -1,26 +1,19 @@
-Basically:
-
-* A user can have several subscriptions (you can log in from several browsers)
-* A single subscription can be shared among multiple users (you can log in with several accounts on the same browser).
-
-We need to store these associations.
-
## Create your UserSubscription class
First, you have to implement `BenTools\WebPushBundle\Model\Subscription\UserSubscriptionInterface`.
It's a simple entity which associates:
1. Your user entity
-2. The subscription details - it will store the JSON representation of the `Subscription` javascript object.
+2. The subscription details - it will store the JSON representation of the `PushSubscription` javascript object.
3. A hash of the endpoint (or any string that could help in retrieving it).
You're free to use Doctrine or anything else.
Example class:
```php
-# src/AppBundle/Entity/UserSubscription.php
+# src/Entity/UserSubscription.php
-namespace AppBundle\Entity;
+namespace App\Entity;
use BenTools\WebPushBundle\Model\Subscription\UserSubscriptionInterface;
use Doctrine\ORM\Mapping as ORM;
@@ -43,7 +36,7 @@ class UserSubscription implements UserSubscriptionInterface
/**
* @var User
- * @ORM\ManyToOne(targetEntity="AppBundle\Entity\User")
+ * @ORM\ManyToOne(targetEntity="App\Entity\User")
* @ORM\JoinColumn(nullable=false)
*/
private $user;
@@ -104,7 +97,7 @@ class UserSubscription implements UserSubscriptionInterface
*/
public function getEndpoint(): string
{
- return $this->subscription['endpoint'] ?? null;
+ return $this->subscription['endpoint'];
}
/**
@@ -112,7 +105,7 @@ class UserSubscription implements UserSubscriptionInterface
*/
public function getPublicKey(): string
{
- return $this->subscription['keys']['p256dh'] ?? null;
+ return $this->subscription['keys']['p256dh'];
}
/**
@@ -120,7 +113,18 @@ class UserSubscription implements UserSubscriptionInterface
*/
public function getAuthToken(): string
{
- return $this->subscription['keys']['auth'] ?? null;
+ return $this->subscription['keys']['auth'];
+ }
+
+
+ /**
+ * Content-encoding (default: aesgcm).
+ *
+ * @return string
+ */
+ public function getContentEncoding(): string
+ {
+ return $this->subscription['content-encoding'] ?? 'aesgcm';
}
}
diff --git a/doc/02 - The UserSubscription Manager.md b/doc/02 - The UserSubscription Manager.md
index ed856f4..0f9daa1 100644
--- a/doc/02 - The UserSubscription Manager.md
+++ b/doc/02 - The UserSubscription Manager.md
@@ -8,11 +8,11 @@ Then, create a class that implements `BenTools\WebPushBundle\Model\Subscription\
Example with Doctrine:
```php
-# src/AppBundle/Services/UserSubscriptionManager.php
+# src/Services/UserSubscriptionManager.php
-namespace AppBundle\Services;
+namespace App\Services;
-use AppBundle\Entity\UserSubscription;
+use App\Entity\UserSubscription;
use BenTools\WebPushBundle\Model\Subscription\UserSubscriptionInterface;
use BenTools\WebPushBundle\Model\Subscription\UserSubscriptionManagerInterface;
use Doctrine\Common\Persistence\ManagerRegistry;
@@ -37,15 +37,17 @@ class UserSubscriptionManager implements UserSubscriptionManagerInterface
/**
* @inheritDoc
*/
- public function factory(UserInterface $user, string $subscriptionHash, array $subscription): UserSubscriptionInterface
+ public function factory(UserInterface $user, string $subscriptionHash, array $subscription, array $options): UserSubscriptionInterface
{
+ // $options is an arbitrary array that can be provided through the front-end code.
+ // You can use it to store meta-data about the subscription: the user agent, the referring domain, ...
return new UserSubscription($user, $subscriptionHash, $subscription);
}
/**
* @inheritDoc
*/
- public function hash(string $endpoint): string {
+ public function hash(string $endpoint, UserInterface $user): string {
return md5($endpoint); // Encode it as you like
}
@@ -101,6 +103,21 @@ class UserSubscriptionManager implements UserSubscriptionManagerInterface
}
```
+Now, register your `UserSubscriptionManager` in your `services.yaml`:
+
+```yaml
+# app/config/services.yml (SF3)
+# config/services.yaml (SF4)
+
+services:
+ App\Services\UserSubscriptionManager:
+ class: App\Services\UserSubscriptionManager
+ arguments:
+ - '@doctrine'
+ tags:
+ - { name: bentools_webpush.subscription_manager, user_class: 'App\Entity\User' }
+```
+
Previous: [The UserSubscription Class](01%20-%20The%20UserSubscription%20Class.md)
Next: [Configuration](03%20-%20Configuration.md)
\ No newline at end of file
diff --git a/doc/03 - Configuration.md b/doc/03 - Configuration.md
index c8d37dc..7d95835 100644
--- a/doc/03 - Configuration.md
+++ b/doc/03 - Configuration.md
@@ -3,48 +3,28 @@
#### Configure the bundle:
```yaml
-# app/config/config.yml
+# app/config/config.yml (SF3)
+# config/packages/bentools_webpush.yaml (SF4)
bentools_webpush:
settings:
- public_key: '%bentools_webpush.public_key%'
- private_key: '%bentools_webpush.private_key%'
- associations:
- my_users:
- user_class: AppBundle\Entity\User
- user_subscription_class: AppBundle\Entity\UserSubscription
- manager: '@AppBundle\Services\UserSubscriptionManager' # Manager service id
+ public_key: 'your_public_key'
+ private_key: 'your_private_key'
```
#### Update your router:
```yaml
-# app/config/routing.yml
+# app/config/routing.yml (SF3)
+# config/routing.yaml (SF4)
bentools_webpush:
resource: '@WebPushBundle/Resources/config/routing.xml'
prefix: /webpush
```
-#### Update your services file:
-```yaml
-# app/config/services.yml
-services:
- AppBundle\Services\UserSubscriptionManager:
- arguments: ["@doctrine"]
-```
-#### Update your templates:
+You will have a new route called `bentools_webpush` which will be the Ajax endpoint for handling subscriptions (POST requests) / unsubscriptions (DELETE requests).
-Insert this snippet in the templates where your user is logged in:
-
-```twig
-
-
-```
+The global variable `bentools_webpush.public_key` is now exposed in Twig.
-_This will install the service worker and prompt your users to accept notifications._
+To handle subscriptions/unsubscriptions on the front-end side, have a look at [webpush-client](https://www.npmjs.com/package/webpush-client).
Previous: [The UserSubscription Manager](02%20-%20The%20UserSubscription%20Manager.md)
diff --git a/doc/04 - Usage.md b/doc/04 - Usage.md
index 06dae71..fdd1053 100644
--- a/doc/04 - Usage.md
+++ b/doc/04 - Usage.md
@@ -1,54 +1,113 @@
## Now, send notifications!
+Here's a sample example of an e-commerce app which will notify both the customer and the related category managers when an order has been placed.
```php
-# src/AppBundle/Services/NotificationSender.php
+namespace App\Services;
-namespace AppBundle\Services;
+use App\Entity\Employee;
+use App\Entity\Order;
+use App\Events\OrderEvent;
+use App\Events\OrderEvents;
+use BenTools\WebPushBundle\Model\Message\PushNotification;
+use BenTools\WebPushBundle\Model\Subscription\UserSubscriptionManagerRegistry;
+use BenTools\WebPushBundle\Sender\GuzzleClientSender;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use AppBundle\Entity\User;
-use BenTools\WebPushBundle\Model\Message\Notification;
-use BenTools\WebPushBundle\Registry\WebPushManagerRegistry;
-use Minishlink\WebPush\WebPush;
-use Symfony\Component\DependencyInjection\ContainerAwareInterface;
-use Symfony\Component\DependencyInjection\ContainerAwareTrait;
-
-class NotificationSender implements ContainerAwareInterface
+class NotificationSenderListener implements EventSubscriberInterface
{
- use ContainerAwareTrait;
+ /**
+ * @var UserSubscriptionManagerRegistry
+ */
+ private $userSubscriptionManager;
+
+ /**
+ * @var GuzzleClientSender
+ */
+ private $sender;
+
+ /**
+ * NotificationSender constructor.
+ * @param UserSubscriptionManagerRegistry $userSubscriptionManager
+ * @param GuzzleClientSender $sender
+ */
+ public function __construct(
+ UserSubscriptionManagerRegistry $userSubscriptionManager,
+ GuzzleClientSender $sender
+ ) {
+ $this->userSubscriptionManager = $userSubscriptionManager;
+ $this->sender = $sender;
+ }
+
+ public static function getSubscribedEvents()
+ {
+ return [
+ OrderEvents::PLACED => 'onOrderPlaced',
+ ];
+ }
+
+ /**
+ * @param OrderEvent $event
+ */
+ public function onOrderPlaced(OrderEvent $event): void
+ {
+ $order = $event->getOrder();
+ $this->notifyCustomer($order);
+ $this->notifyCategoryManagers($order);
+ }
/**
- * @param User $user
+ * @param Order $order
*/
- public function sendAwesomeNotification(User $user)
+ private function notifyCustomer(Order $order): void
{
- $sender = $this->container->get(WebPush::class);
- $managers = $this->container->get(WebPushManagerRegistry::class);
- $myUserManager = $managers->getManager($user);
- foreach ($myUserManager->findByUser($user) as $subscription) {
- $sender->sendNotification(
- $subscription->getEndpoint(),
- $this->createAwesomeNotification(),
- $subscription->getPublicKey(),
- $subscription->getAuthToken()
- );
+ $customer = $order->getCustomer();
+ $subscriptions = $this->userSubscriptionManager->findByUser($customer);
+ $notification = new PushNotification('Congratulations!', [
+ PushNotification::BODY => 'Your order has been placed.',
+ PushNotification::ICON => '/assets/icon_success.png',
+ ]);
+ $responses = $this->sender->push($notification->createMessage(), $subscriptions);
+
+ foreach ($responses as $response) {
+ if ($response->isExpired()) {
+ $this->userSubscriptionManager->delete($response->getSubscription());
+ }
}
- $sender->flush();
}
/**
- * @return Notification
+ * @param Order $order
*/
- private function createAwesomeNotification(): Notification
+ private function notifyCategoryManagers(Order $order): void
{
- return new Notification([
- 'title' => 'Awesome title',
- 'body' => 'Symfony is great!',
- 'icon' => 'https://symfony.com/logos/symfony_black_03.png',
- 'data' => [
- 'link' => 'https://www.symfony.com',
- ],
+ $products = $order->getProducts();
+ $employees = [];
+ foreach ($products as $product) {
+ $employees[] = $product->getCategoryManager();
+ }
+
+ $employees = array_unique($employees);
+
+ $subscriptions = [];
+ foreach ($employees as $employee) {
+ foreach ($this->userSubscriptionManager->findByUser($employee) as $subscription) {
+ $subscriptions[] = $subscription;
+ }
+ }
+
+ $notification = new PushNotification('A new order has been placed!', [
+ PushNotification::BODY => 'A customer just bought some of your products.',
+ PushNotification::ICON => '/assets/icon_success.png',
]);
+
+ $responses = $this->sender->push($notification->createMessage(), $subscriptions);
+
+ foreach ($responses as $response) {
+ if ($response->isExpired()) {
+ $this->userSubscriptionManager->delete($response->getSubscription());
+ }
+ }
}
}
```
diff --git a/doc/05 - FAQ.md b/doc/05 - FAQ.md
index 1992888..44790cc 100644
--- a/doc/05 - FAQ.md
+++ b/doc/05 - FAQ.md
@@ -14,20 +14,23 @@ It's OK. You can subscribe separately your `Employees` and your `Customers`, for
Example config:
```yaml
-# app/config/config.yml
-bentools_webpush:
- settings:
- public_key: '%bentools_webpush.public_key%'
- private_key: '%bentools_webpush.private_key%'
- associations:
- employees:
- user_class: AppBundle\Entity\Employee
- user_subscription_class: AppBundle\Entity\EmployeeSubscription
- manager: '@AppBundle\Services\EmployeeSubscriptionManager'
- customers:
- user_class: AppBundle\Entity\Customer
- user_subscription_class: AppBundle\Entity\CustomerSubscription
- manager: '@AppBundle\Services\CustomerSubscriptionManager'
+# app/config/services.yml (SF3)
+# config/services.yaml (SF4)
+
+services:
+ App\Services\EmployeeSubscriptionManager:
+ class: App\Services\EmployeeSubscriptionManager
+ arguments:
+ - '@doctrine'
+ tags:
+ - { name: bentools_webpush.subscription_manager, user_class: 'App\Entity\Employee' }
+
+ App\Services\CustomerSubscriptionManager:
+ class: App\Services\CustomerSubscriptionManager
+ arguments:
+ - '@doctrine'
+ tags:
+ - { name: bentools_webpush.subscription_manager, user_class: 'App\Entity\Customer' }
```
@@ -39,48 +42,7 @@ You can control subscriptions on the client-side.
**How do I manage subscriptions / unsubscriptions from an UI point of view?**
-```twig
-
-```
+For the front-end part of the subscription / unsubscription process, check-out the [WebPush Client Javascript Library](https://www.npmjs.com/package/webpush-client) that has been designed to work with this bundle.
-**How do I handle expired subscriptions?**
-
-When you push a notification, you can know which endpoints failed.
-After pushing, you can retrieve the corresponding recipients and manage their deletion, for instance with Doctrine:
-
-```php
-foreach ($manager->findByUser($user) as $subscription) {
- $webpush->sendNotification(
- $subscription->getEndpoint(),
- 'ho hi',
- $subscription->getPublicKey(),
- $subscription->getAuthToken()
- );
-}
-
-$results = $webpush->flush();
-
-if (is_array($results)) {
- foreach ($results as $result) {
- if (!empty($result['expired'])) {
- foreach ($manager->findByHash($manager->hash($result['endpoint'])) as $subscription) {
- $manager->delete($subscription);
- }
- }
- }
-}
-```
Previous: [Usage](04%20-%20Usage.md)
\ No newline at end of file
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..4e9f6b1
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tests
+
+
+
+
+
+ src
+
+
+
diff --git a/src/Action/SubscriptionAction.php b/src/Action/RegisterSubscriptionAction.php
similarity index 54%
rename from src/Action/SubscriptionAction.php
rename to src/Action/RegisterSubscriptionAction.php
index 7185a15..f64b240 100644
--- a/src/Action/SubscriptionAction.php
+++ b/src/Action/RegisterSubscriptionAction.php
@@ -2,33 +2,26 @@
namespace BenTools\WebPushBundle\Action;
-use BenTools\WebPushBundle\Registry\WebPushManagerRegistry;
+use BenTools\WebPushBundle\Model\Subscription\UserSubscriptionManagerRegistry;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
-use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
-use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\User\UserInterface;
-final class SubscriptionAction
+final class RegisterSubscriptionAction
{
/**
- * @var WebPushManagerRegistry
+ * @var UserSubscriptionManagerRegistry
*/
private $registry;
/**
* RegisterSubscriptionAction constructor.
- * @param TokenStorageInterface $tokenStorage
- * @param WebPushManagerRegistry $registry
+ * @param UserSubscriptionManagerRegistry $registry
*/
- public function __construct(
- TokenStorageInterface $tokenStorage,
- WebPushManagerRegistry $registry
- ) {
-
- $this->tokenStorage = $tokenStorage;
+ public function __construct(UserSubscriptionManagerRegistry $registry)
+ {
$this->registry = $registry;
}
@@ -36,13 +29,17 @@ public function __construct(
* @param UserInterface $user
* @param string $subscriptionHash
* @param array $subscription
+ * @param array $options
+ * @throws \InvalidArgumentException
* @throws \RuntimeException
+ * @throws \Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException
+ * @throws \Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException
*/
- private function processSubscription(UserInterface $user, string $subscriptionHash, array $subscription)
+ private function subscribe(UserInterface $user, string $subscriptionHash, array $subscription, array $options = [])
{
$manager = $this->registry->getManager($user);
$userSubscription = $manager->getUserSubscription($user, $subscriptionHash)
- or $userSubscription = $manager->factory($user, $subscriptionHash, $subscription);
+ or $userSubscription = $manager->factory($user, $subscriptionHash, $subscription, $options);
$manager->save($userSubscription);
}
@@ -52,7 +49,7 @@ private function processSubscription(UserInterface $user, string $subscriptionHa
* @throws BadRequestHttpException
* @throws \RuntimeException
*/
- private function processUnsubscription(UserInterface $user, string $subscriptionHash)
+ private function unsubscribe(UserInterface $user, string $subscriptionHash)
{
$manager = $this->registry->getManager($user);
$subscription = $manager->getUserSubscription($user, $subscriptionHash);
@@ -63,32 +60,20 @@ private function processUnsubscription(UserInterface $user, string $subscription
}
/**
- * @param Request $request
+ * @param Request $request
+ * @param UserInterface $user
* @return Response
- * @throws BadRequestHttpException
- * @throws MethodNotAllowedHttpException
- * @throws \LogicException
- * @throws \RuntimeException
*/
- public function __invoke(Request $request): Response
+ public function __invoke(Request $request, UserInterface $user): Response
{
if (!in_array($request->getMethod(), ['POST', 'DELETE'])) {
throw new MethodNotAllowedHttpException(['POST', 'DELETE']);
}
- $token = $this->tokenStorage->getToken();
- if (null === $token) {
- throw new AccessDeniedHttpException('No authentication information available.');
- }
-
- $user = $token->getUser();
-
- if (null === $user) {
- throw new BadRequestHttpException("User is not logged in.");
- }
-
- $subscription = json_decode($request->getContent(), true);
+ $data = json_decode($request->getContent(), true);
+ $subscription = $data['subscription'] ?? [];
+ $options = $data['options'] ?? [];
if (JSON_ERROR_NONE !== json_last_error()) {
throw new BadRequestHttpException(json_last_error_msg());
@@ -98,17 +83,13 @@ public function __invoke(Request $request): Response
throw new BadRequestHttpException('Invalid subscription object.');
}
- if (!$user instanceof UserInterface) {
- throw new \RuntimeException('This bundle only works with user object that implement ' . UserInterface::class);
- }
-
$manager = $this->registry->getManager($user);
- $subscriptionHash = $manager->hash($subscription['endpoint']);
+ $subscriptionHash = $manager->hash($subscription['endpoint'], $user);
if ('DELETE' === $request->getMethod()) {
- $this->processUnsubscription($user, $subscriptionHash);
+ $this->unsubscribe($user, $subscriptionHash);
} else {
- $this->processSubscription($user, $subscriptionHash, $subscription);
+ $this->subscribe($user, $subscriptionHash, $subscription, $options);
}
return new Response('', Response::HTTP_NO_CONTENT);
diff --git a/src/Command/WebPushGenerateKeysCommand.php b/src/Command/WebPushGenerateKeysCommand.php
index 25359a3..433faaa 100644
--- a/src/Command/WebPushGenerateKeysCommand.php
+++ b/src/Command/WebPushGenerateKeysCommand.php
@@ -7,6 +7,7 @@
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\HttpKernel\Kernel;
final class WebPushGenerateKeysCommand extends ContainerAwareCommand
{
@@ -17,7 +18,7 @@ protected function configure()
{
$this
->setName('webpush:generate:keys')
- ->setDescription('Generate your VAPID keys for BenTools/WebPush.');
+ ->setDescription('Generate your VAPID keys for bentools/webpush.');
}
/**
@@ -31,34 +32,22 @@ protected function execute(InputInterface $input, OutputInterface $output)
$io->writeln(sprintf('Your public key is: %s ', $keys['publicKey']));
$io->writeln(sprintf('Your private key is: %s', $keys['privateKey']));
$io->newLine(2);
- $io->writeln('Store them in your app/config/parameters.yml:');
- $io->newLine(1);
- $io->writeln(<<# app/config/parameters.yml.dist
-parameters:
- bentools_webpush.public_key: ~
- bentools_webpush.private_key: ~
-EOF
- );
- $io->newLine(1);
- $io->writeln(<<# app/config/parameters.yml
-parameters:
- bentools_webpush.public_key: '{$keys['publicKey']}'
- bentools_webpush.private_key: '{$keys['privateKey']}'
-EOF
- );
+ if (-1 === version_compare(Kernel::VERSION, 4)) {
+ $io->writeln('Update app/config/config.yml:');
+ $io->newLine(1);
+ $io->writeln('# app/config/config.yml');
+ } else {
+ $io->writeln('Update config/packages/bentools_webpush.yaml:');
+ $io->newLine(1);
+ $io->writeln('# config/packages/bentools_webpush.yaml');
+ }
- $io->newLine(2);
- $io->writeln('Then update your app/config/config.yml:');
- $io->newLine(1);
$io->writeln(<<# app/config/config.yml
bentools_webpush:
- settings:
- public_key: '%bentools_webpush.public_key%'
- private_key: '%bentools_webpush.private_key%'
+ settings:
+ public_key: '{$keys['publicKey']}'
+ private_key: '{$keys['privateKey']}'
EOF
);
}
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
index 2faec75..01802fd 100644
--- a/src/DependencyInjection/Configuration.php
+++ b/src/DependencyInjection/Configuration.php
@@ -17,6 +17,8 @@ public function getConfigTreeBuilder()
->arrayNode('settings')
->children()
+ ->scalarNode('subject')
+ ->end()
->scalarNode('public_key')
->isRequired()
->end()
@@ -25,22 +27,6 @@ public function getConfigTreeBuilder()
->end()
->end()
->end()
-
- ->arrayNode('associations')
- ->useAttributeAsKey('name')
- ->prototype('array')
- ->children()
- ->scalarNode('user_class')
- ->isRequired()
- ->end()
- ->scalarNode('user_subscription_class')
- ->isRequired()
- ->end()
- ->scalarNode('manager')
- ->isRequired()
- ->end()
- ->end()
- ->end()
->end()
;
diff --git a/src/DependencyInjection/WebPushCompilerPass.php b/src/DependencyInjection/WebPushCompilerPass.php
new file mode 100644
index 0000000..8bb1c88
--- /dev/null
+++ b/src/DependencyInjection/WebPushCompilerPass.php
@@ -0,0 +1,24 @@
+getDefinition(UserSubscriptionManagerRegistry::class);
+ $taggedSubscriptionManagers = $container->findTaggedServiceIds('bentools_webpush.subscription_manager');
+ foreach ($taggedSubscriptionManagers as $id => $tag) {
+ if (!isset($tag[0]['user_class'])) {
+ throw new \InvalidArgumentException(sprintf('Missing user_class attribute in tag for service %s', $id));
+ }
+ $registry->addMethodCall('register', [$tag[0]['user_class'], new Reference($id)]);
+ }
+ }
+}
diff --git a/src/DependencyInjection/WebPushExtension.php b/src/DependencyInjection/WebPushExtension.php
index e519699..45f13af 100644
--- a/src/DependencyInjection/WebPushExtension.php
+++ b/src/DependencyInjection/WebPushExtension.php
@@ -22,9 +22,9 @@ public function load(array $configs, ContainerBuilder $container)
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
- $container->setParameter('bentools_webpush.config.associations', $config['associations'] ?? []);
- $container->setParameter('bentools_webpush.public_key', $config['settings']['public_key'] ?? null);
- $container->setParameter('bentools_webpush.private_key', $config['settings']['private_key'] ?? null);
+ $container->setParameter('bentools_webpush.vapid_subject', $config['settings']['subject'] ?? $container->getParameter('router.request_context.host'));
+ $container->setParameter('bentools_webpush.vapid_public_key', $config['settings']['public_key'] ?? null);
+ $container->setParameter('bentools_webpush.vapid_private_key', $config['settings']['private_key'] ?? null);
$loader = new XmlFileLoader($container, new FileLocator([__DIR__ . '/../Resources/config/']));
$loader->load('services.xml');
}
diff --git a/src/Model/Message/JsonPayloadInterface.php b/src/Model/Message/JsonPayloadInterface.php
deleted file mode 100644
index 90eae73..0000000
--- a/src/Model/Message/JsonPayloadInterface.php
+++ /dev/null
@@ -1,19 +0,0 @@
-data = $data;
- }
-
- /**
- * @inheritDoc
- */
- public function jsonSerialize(): array
- {
- return $this->data;
- }
-
- /**
- * @inheritDoc
- */
- public function __toString(): string
- {
- return json_encode(['notification' => $this]);
- }
-
- /**
- * @inheritDoc
- */
- public function offsetExists($offset)
- {
- return array_key_exists($offset, $this->data);
- }
-
- /**
- * @inheritDoc
- */
- public function offsetGet($offset)
- {
- return $this->data[$offset] ?? null;
- }
-
- /**
- * @inheritDoc
- */
- public function offsetSet($offset, $value)
- {
- $this->data[$offset] = $value;
- }
-
- /**
- * @inheritDoc
- */
- public function offsetUnset($offset)
- {
- unset($this->data[$offset]);
- }
-}
diff --git a/src/Model/Message/PayloadInterface.php b/src/Model/Message/PayloadInterface.php
deleted file mode 100644
index 8ebc838..0000000
--- a/src/Model/Message/PayloadInterface.php
+++ /dev/null
@@ -1,14 +0,0 @@
-payload = $payload;
+ $this->options = $options;
+ $this->auth = $auth;
+ }
+
+ /**
+ * @param null|string $payload
+ */
+ public function setPayload(?string $payload): void
+ {
+ $this->payload = $payload;
+ }
+
+ /**
+ * @return null|string
+ */
+ public function getPayload(): ?string
+ {
+ return $this->payload;
+ }
+
+ /**
+ * @param int $ttl
+ */
+ public function setTTL(int $ttl): void
+ {
+ $this->options['TTL'] = $ttl;
+ }
+
+ /**
+ * @param null|string $topic
+ */
+ public function setTopic(?string $topic): void
+ {
+ $this->options['topic'] = $topic;
+ }
+
+ /**
+ * @param null|string $urgency
+ * @throws \InvalidArgumentException
+ */
+ public function setUrgency(?string $urgency): void
+ {
+ if (null === $urgency) {
+ unset($this->options['urgency']);
+ return;
+ }
+
+ if (!in_array($urgency, ['very-low', 'low', 'normal', 'high'])) {
+ throw new \InvalidArgumentException('Urgency must be one of: very-low | low | normal | high');
+ }
+
+ $this->options['urgency'] = $urgency;
+ }
+
+ /**
+ * @return array
+ */
+ public function getOptions(): array
+ {
+ return array_diff($this->options, array_filter($this->options, 'is_null'));
+ }
+
+ public function getOption(string $key)
+ {
+ return $this->options[$key] ?? null;
+ }
+
+ /**
+ * @return array
+ */
+ public function getAuth(): array
+ {
+ return $this->auth;
+ }
+}
diff --git a/src/Model/Message/PushNotification.php b/src/Model/Message/PushNotification.php
new file mode 100644
index 0000000..6d639b1
--- /dev/null
+++ b/src/Model/Message/PushNotification.php
@@ -0,0 +1,187 @@
+title = $title;
+ $this->options = $options;
+ }
+
+ /**
+ * @return null|string
+ */
+ public function getTitle(): ?string
+ {
+ return $this->title;
+ }
+
+
+ /**
+ * @param null|string $title
+ */
+ public function setTitle(?string $title): void
+ {
+ $this->title = $title;
+ }
+
+ /**
+ * @return array
+ */
+ public function getOptions(): array
+ {
+ return $this->options;
+ }
+
+ /**
+ * @param array $options
+ */
+ public function setOptions(array $options): void
+ {
+ $this->options = $options;
+ }
+
+ /**
+ * @param $key
+ * @param $value
+ */
+ public function setOption($key, $value): void
+ {
+ if (null === $value) {
+ unset($this->options[$key]);
+ return;
+ }
+
+ $this->options[$key] = $value;
+ }
+
+ public function getOption($key)
+ {
+ return $this->options[$key] ?? null;
+ }
+
+ /**
+ * @param array $options
+ * @param array $auth
+ * @return PushMessage
+ */
+ public function createMessage(array $options = [], array $auth = []): PushMessage
+ {
+ return new PushMessage((string) $this, $options, $auth);
+ }
+
+ /**
+ * @return array
+ */
+ public function jsonSerialize(): array
+ {
+ return [
+ 'title' => $this->title,
+ 'options' => array_diff($this->options, array_filter($this->options, 'is_null')),
+ ];
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString(): string
+ {
+ return (string) json_encode($this);
+ }
+
+ /**
+ * Whether a offset exists
+ * @link https://php.net/manual/en/arrayaccess.offsetexists.php
+ * @param mixed $offset
+ * An offset to check for.
+ *
+ * @return boolean true on success or false on failure.
+ *
+ *
+ * The return value will be casted to boolean if non-boolean was returned.
+ * @since 5.0.0
+ */
+ public function offsetExists($offset)
+ {
+ return array_key_exists($offset, $this->options);
+ }
+
+ /**
+ * Offset to retrieve
+ * @link https://php.net/manual/en/arrayaccess.offsetget.php
+ * @param mixed $offset
+ * The offset to retrieve.
+ *
+ * @return mixed Can return all value types.
+ * @since 5.0.0
+ */
+ public function offsetGet($offset)
+ {
+ return $this->options[$offset] ?? null;
+ }
+
+ /**
+ * Offset to set
+ * @link https://php.net/manual/en/arrayaccess.offsetset.php
+ * @param mixed $offset
+ * The offset to assign the value to.
+ *
+ * @param mixed $value
+ * The value to set.
+ *
+ * @return void
+ * @since 5.0.0
+ */
+ public function offsetSet($offset, $value)
+ {
+ $this->options[$offset] = $value;
+ }
+
+ /**
+ * Offset to unset
+ * @link https://php.net/manual/en/arrayaccess.offsetunset.php
+ * @param mixed $offset
+ * The offset to unset.
+ *
+ * @return void
+ * @since 5.0.0
+ */
+ public function offsetUnset($offset)
+ {
+ unset($this->options[$offset]);
+ }
+}
diff --git a/src/Model/Response/PushResponse.php b/src/Model/Response/PushResponse.php
new file mode 100644
index 0000000..09dc499
--- /dev/null
+++ b/src/Model/Response/PushResponse.php
@@ -0,0 +1,68 @@
+subscription = $subscription;
+ $this->statusCode = $statusCode;
+ }
+
+ /**
+ * @return UserSubscriptionInterface
+ */
+ public function getSubscription(): UserSubscriptionInterface
+ {
+ return $this->subscription;
+ }
+
+ /**
+ * @return int
+ */
+ public function getStatusCode(): int
+ {
+ return $this->statusCode;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isExpired(): bool
+ {
+ return in_array($this->statusCode, [self::NOT_FOUND, self::GONE]);
+ }
+
+ /**
+ * @return bool
+ */
+ public function isSuccessFul(): bool
+ {
+ return self::SUCCESS === $this->statusCode;
+ }
+}
diff --git a/src/Model/Subscription/UserSubscriptionInterface.php b/src/Model/Subscription/UserSubscriptionInterface.php
index acbb2cd..92c449e 100644
--- a/src/Model/Subscription/UserSubscriptionInterface.php
+++ b/src/Model/Subscription/UserSubscriptionInterface.php
@@ -41,4 +41,11 @@ public function getPublicKey(): string;
* @return string
*/
public function getAuthToken(): string;
+
+ /**
+ * Content-encoding (default: aesgcm).
+ *
+ * @return string
+ */
+ public function getContentEncoding(): string;
}
diff --git a/src/Model/Subscription/UserSubscriptionManagerInterface.php b/src/Model/Subscription/UserSubscriptionManagerInterface.php
index 5bc5593..66a6156 100644
--- a/src/Model/Subscription/UserSubscriptionManagerInterface.php
+++ b/src/Model/Subscription/UserSubscriptionManagerInterface.php
@@ -13,18 +13,20 @@ interface UserSubscriptionManagerInterface
* @param UserInterface $user
* @param string $subscriptionHash
* @param array $subscription
+ * @param array $options
* @return UserSubscriptionInterface
*/
- public function factory(UserInterface $user, string $subscriptionHash, array $subscription): UserSubscriptionInterface;
+ public function factory(UserInterface $user, string $subscriptionHash, array $subscription, array $options = []): UserSubscriptionInterface;
/**
* Return a string representation of the subscription's endpoint.
* Example: md5($endpoint).
*
- * @param array $subscription
+ * @param string $endpoint
+ * @param UserInterface $user
* @return string
*/
- public function hash(string $endpoint): string;
+ public function hash(string $endpoint, UserInterface $user): string;
/**
* Return the subscription attached to this user.
diff --git a/src/Model/Subscription/UserSubscriptionManagerRegistry.php b/src/Model/Subscription/UserSubscriptionManagerRegistry.php
new file mode 100644
index 0000000..d4f174e
--- /dev/null
+++ b/src/Model/Subscription/UserSubscriptionManagerRegistry.php
@@ -0,0 +1,134 @@
+registry)) {
+ throw new \InvalidArgumentException(sprintf('User class %s is already registered.', $userClass));
+ }
+
+ if (self::class === get_class($userSubscriptionManager)) {
+ throw new \InvalidArgumentException(sprintf('You must define your own user subscription manager for %s.', $userClass));
+ }
+
+ $this->registry[$userClass] = $userSubscriptionManager;
+ }
+
+ /**
+ * @param UserInterface|string $userClass
+ * @return UserSubscriptionManagerInterface
+ * @throws RuntimeException
+ * @throws ServiceNotFoundException
+ * @throws \InvalidArgumentException
+ * @throws \Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException
+ */
+ public function getManager($userClass): UserSubscriptionManagerInterface
+ {
+ if (!is_a($userClass, UserInterface::class, true)) {
+ throw new \InvalidArgumentException(
+ sprintf(
+ 'Expected class or object that implements %s, %s given',
+ UserInterface::class,
+ is_object($userClass) ? get_class($userClass) : gettype($userClass)
+ )
+ );
+ }
+
+ if (is_object($userClass)) {
+ $userClass = get_class($userClass);
+ }
+
+ // Case of a doctrine proxied class
+ if (0 === strpos($userClass, 'Proxies\__CG__') && class_exists('Doctrine\Common\Util\ClassUtils')) {
+ return $this->getManager(ClassUtils::getRealClass($userClass));
+ }
+
+ if (!isset($this->registry[$userClass])) {
+ throw new \InvalidArgumentException(sprintf('There is no user subscription manager configured for class %s.', $userClass));
+ }
+
+ return $this->registry[$userClass];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function factory(UserInterface $user, string $subscriptionHash, array $subscription, array $options = []): UserSubscriptionInterface
+ {
+ return $this->getManager($user)->factory($user, $subscriptionHash, $subscription, $options);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function hash(string $endpoint, UserInterface $user): string
+ {
+ return $this->getManager($user)->hash($endpoint, $user);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getUserSubscription(UserInterface $user, string $subscriptionHash): ?UserSubscriptionInterface
+ {
+ return $this->getManager($user)->getUserSubscription($user, $subscriptionHash);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function findByUser(UserInterface $user): iterable
+ {
+ return $this->getManager($user)->findByUser($user);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function findByHash(string $subscriptionHash): iterable
+ {
+ foreach ($this->registry as $manager) {
+ foreach ($manager->findByHash($subscriptionHash) as $userSubscription) {
+ yield $userSubscription;
+ }
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function save(UserSubscriptionInterface $userSubscription): void
+ {
+ $this->getManager($userSubscription->getUser())->save($userSubscription);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function delete(UserSubscriptionInterface $userSubscription): void
+ {
+ $this->getManager($userSubscription->getUser())->delete($userSubscription);
+ }
+}
diff --git a/src/Registry/WebPushManagerRegistry.php b/src/Registry/WebPushManagerRegistry.php
deleted file mode 100644
index cd5601d..0000000
--- a/src/Registry/WebPushManagerRegistry.php
+++ /dev/null
@@ -1,94 +0,0 @@
-setContainer($container);
- foreach ($associations as $key => $values) {
- $this->register($key, $values);
- }
- }
-
- /**
- * @param string $key
- * @param array $values
- * @throws ServiceNotFoundException
- * @throws RuntimeException
- */
- private function register(string $key, array $values): void
- {
- if (!is_a($values['user_class'], UserInterface::class, true)) {
- throw new RuntimeException(sprintf('User class %s must implement %s', $values['user_class'], UserInterface::class));
- }
- if (!is_a($values['user_subscription_class'], UserSubscriptionInterface::class, true)) {
- throw new RuntimeException(sprintf('User subscription class %s must implement %s', $values['user_subscription_class'], UserSubscriptionInterface::class));
- }
- if (!$this->container->has(ltrim($values['manager'], '@'))) {
- throw new ServiceNotFoundException(sprintf('Service %s not found - or make sure it is public.', $values['manager']));
- }
- $this->associations[$key] = $values;
- }
-
- /**
- * @param UserInterface|string $userClass
- * @return UserSubscriptionManagerInterface
- * @throws RuntimeException
- * @throws ServiceNotFoundException
- * @throws \InvalidArgumentException
- * @throws \Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException
- */
- public function getManager($userClass): UserSubscriptionManagerInterface
- {
- if (!is_a($userClass, UserInterface::class, true)) {
- throw new \InvalidArgumentException(
- sprintf(
- 'Expected class or object that implements %s, %s given',
- UserInterface::class,
- is_object($userClass) ? get_class($userClass) : gettype($userClass)
- )
- );
- }
-
- if (is_object($userClass)) {
- $userClass = get_class($userClass);
- }
-
- foreach ($this->associations as $association) {
- if ($association['user_class'] === $userClass) {
- $service = $this->container->get(ltrim($association['manager'], '@'));
- if (!$service instanceof UserSubscriptionManagerInterface) {
- throw new RuntimeException(sprintf('Service %s must implement %s', $association['manager'], UserSubscriptionManagerInterface::class));
- }
- return $service;
- }
- }
-
- // Case of a doctrine proxied class
- if (0 === strpos($userClass, 'Proxies\__CG__') && class_exists('Doctrine\Common\Util\ClassUtils')) {
- return $this->getManager(ClassUtils::getRealClass($userClass));
- }
-
- throw new \InvalidArgumentException(sprintf('Webpush service not found for class %s', $userClass));
- }
-}
diff --git a/src/Resources/config/routing.xml b/src/Resources/config/routing.xml
index 359d77c..171c432 100644
--- a/src/Resources/config/routing.xml
+++ b/src/Resources/config/routing.xml
@@ -5,7 +5,7 @@
xsi:schemaLocation="http://symfony.com/schema/routing http://symfony.com/schema/routing/routing-1.0.xsd">
- BenTools\WebPushBundle\Action\SubscriptionAction::__invoke
+ BenTools\WebPushBundle\Action\RegisterSubscriptionAction
diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml
index c5cb3d0..1b2aebd 100644
--- a/src/Resources/config/services.xml
+++ b/src/Resources/config/services.xml
@@ -4,41 +4,36 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ %bentools_webpush.vapid_public_key%
+
-
-
- %bentools_webpush.config.associations%
+
+
+
+
+
+
-
+
- %router.request_context.host%
- %bentools_webpush.public_key%
- %bentools_webpush.private_key%
+ %bentools_webpush.vapid_subject%
+ %bentools_webpush.vapid_public_key%
+ %bentools_webpush.vapid_private_key%
-
-
-
-
-
-
diff --git a/src/Resources/public/js/webpush_client.js b/src/Resources/public/js/webpush_client.js
deleted file mode 100644
index f1e8135..0000000
--- a/src/Resources/public/js/webpush_client.js
+++ /dev/null
@@ -1,176 +0,0 @@
-var thisScript = document.querySelector('script[data-webpushclient]');
-if (null === thisScript) {
- console.log('Do not forget to add "data-webpushclient", i.e. ');
- throw Error("Cannot find where webpush_client.js is.");
-}
-var BenToolsWebPushClient = function BenToolsWebPushClient(options) {
-
- return {
-
- options: {},
- worker: null,
- registration: null,
- subscription: null,
-
- init: function init(options) {
- this.options = options || {};
-
- if (!options.url) {
- throw Error('Url has not been defined.');
- }
-
- this.options.url = options.url;
- this.options.swPath = this.options.swPath || thisScript.src.replace('/webpush_client.js', '/webpush_sw.js');
- this.options.promptIfNotSubscribed = 'boolean' === typeof options.promptIfNotSubscribed ? options.promptIfNotSubscribed : true;
- return this.initSW();
- },
-
- initSW: function initSW() {
- var that = this;
-
- if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
- return;
- }
-
- that.registerServiceWorker(this.options.swPath).then(function (registration) {
- that.registration = registration;
-
- that.getSubscription(registration).then(function (subscription) {
-
- // If a subscription was found, return it.
- if (subscription) {
- that.subscription = subscription;
- return subscription;
- } else {
- if (true === that.options.promptIfNotSubscribed) {
- return that.subscribe();
- }
- }
- });
- });
- return this;
- },
-
- askPermission: function askPermission() {
- return new Promise(function (resolve, reject) {
- var permissionResult = Notification.requestPermission(function (result) {
- resolve(result);
- });
-
- if (permissionResult) {
- permissionResult.then(resolve, reject);
- }
- }).then(function (permissionResult) {
- if (permissionResult !== 'granted') {
- throw new Error('Permission was not granted.');
- }
- });
- },
-
- getNotificationPermissionState: function getNotificationPermissionState() {
- if (navigator.permissions) {
- return navigator.permissions.query({ name: 'notifications' }).then(function (result) {
- return result.state;
- });
- }
-
- return new Promise(function (resolve) {
- resolve(Notification.permission);
- });
- },
-
- subscribe: function subscribe() {
- var that = this;
- return that.registration.pushManager.subscribe({
- userVisibleOnly: true,
- applicationServerKey: that.encodeServerKey(that.options.serverKey)
- }).then(function (subscription) {
- that.subscription = subscription;
- return that.registerSubscription(subscription).then(function (subscription) {
- if ('function' === typeof that.options.onSubscribe) {
- return that.options.onSubscribe(subscription);
- }
- });
- });
- },
-
- unsubscribe: function unsubscribe() {
- var that = this;
- return this.getSubscription(this.registration).then(function (subscription) {
- that.unregisterSubscription(subscription);
- if ('function' === typeof that.options.onUnsubscribe) {
- return that.options.onUnsubscribe(subscription);
- }
- });
- },
-
- revoke: function unsubscribe() {
- var that = this;
- return this.getSubscription(this.registration).then(function (subscription) {
- subscription.unsubscribe().then(function () {
- that.unregisterSubscription(subscription);
- if ('function' === typeof that.options.onUnsubscribe) {
- return that.options.onUnsubscribe(subscription);
- }
- });
- });
- },
-
-
- registerServiceWorker: function registerServiceWorker(swPath) {
- var that = this;
- return navigator.serviceWorker.register(swPath).then(function (registration) {
- that.worker = registration.active || registration.installing;
- return registration;
- });
- },
-
- getSubscription: function getSubscription(registration) {
- return registration.pushManager.getSubscription();
- },
-
- registerSubscription: function registerSubscription(subscription) {
- return fetch(this.options.url, {
- method: 'POST',
- mode: 'cors',
- credentials: 'include',
- cache: 'default',
- headers: new Headers({
- 'Accept': 'application/json',
- 'Content-Type': 'application/json'
- }),
- body: JSON.stringify(subscription)
- }).then(function () {
- return subscription;
- });
- },
-
- unregisterSubscription: function unregisterSubscription(subscription) {
- return fetch(this.options.url, {
- method: 'DELETE',
- mode: 'cors',
- credentials: 'include',
- cache: 'default',
- headers: new Headers({
- 'Accept': 'application/json',
- 'Content-Type': 'application/json'
- }),
- body: JSON.stringify(subscription)
- });
- },
-
- encodeServerKey: function encodeServerKey(serverKey) {
- var padding = '='.repeat((4 - serverKey.length % 4) % 4);
- var base64 = (serverKey + padding).replace(/\-/g, '+').replace(/_/g, '/');
-
- var rawData = window.atob(base64);
- var outputArray = new Uint8Array(rawData.length);
-
- for (var i = 0; i < rawData.length; ++i) {
- outputArray[i] = rawData.charCodeAt(i);
- }
- return outputArray;
- }
-
- }.init(options);
-};
\ No newline at end of file
diff --git a/src/Resources/public/js/webpush_sw.js b/src/Resources/public/js/webpush_sw.js
deleted file mode 100644
index d69f029..0000000
--- a/src/Resources/public/js/webpush_sw.js
+++ /dev/null
@@ -1,52 +0,0 @@
-self.processMessage = payload => {
-
- try {
- const jsonData = JSON.parse(payload);
- const promises = [];
- for (key in jsonData) {
- if ('notification' === key) {
- promises.push(self.registration.showNotification(jsonData.notification.title, jsonData.notification));
- }
- }
- return Promise.race(promises);
- } catch (e) {
- return self.registration.showNotification('Notification', {
- body: payload
- });
- }
-
-};
-
-// Register event listener for the 'push' event.
-self.addEventListener('push', event => {
- console.log('SW received push event', event);
- const pushMessageData = event.data;
- const payload = pushMessageData ? pushMessageData.text() : undefined;
- event.waitUntil(self.processMessage(payload));
-});
-
-self.addEventListener('notificationclick', event => {
-
- event.notification.close();
- const url = event.notification.data.link;
-
- if (url.length > 0) {
- event.waitUntil(
- clients.matchAll({
- type: 'window'
- })
- .then(windowClients => {
- for (const client of windowClients) {
- if (client.url === url && 'focus' in client) {
- return client.focus();
- }
- }
-
- if (clients.openWindow) {
- return clients.openWindow(url);
- }
- })
- );
- }
-});
-
diff --git a/src/Sender/PushMessageSender.php b/src/Sender/PushMessageSender.php
new file mode 100644
index 0000000..672565c
--- /dev/null
+++ b/src/Sender/PushMessageSender.php
@@ -0,0 +1,183 @@
+auth = $auth;
+ $this->setDefaultOptions($defaultOptions);
+ $this->client = $client ?? new Client();
+ $this->requestBuilder = new RequestBuilder();
+ }
+
+ /**
+ * @param PushMessage $message
+ * @param iterable $subscriptions
+ * @return PushResponse[]
+ * @throws \ErrorException
+ * @throws \InvalidArgumentException
+ * @throws \LogicException
+ */
+ public function push(PushMessage $message, iterable $subscriptions): iterable
+ {
+ /** @var UserSubscriptionInterface[] $subscriptions */
+ $promises = [];
+
+ if (isset($this->auth['VAPID']) && empty($this->auth['VAPID']['validated'])) {
+ $this->auth['VAPID'] = VAPID::validate($this->auth['VAPID']) + ['validated' => true];
+ }
+
+ foreach ($subscriptions as $subscription) {
+ $subscriptionHash = $subscription->getSubscriptionHash();
+ $auth = $message->getAuth() + $this->auth;
+
+ $request = $this->requestBuilder->createRequest(
+ $message,
+ $subscription,
+ $message->getOption('TTL') ?? $this->defaultOptions['TTL'],
+ $this->maxPaddingLength
+ );
+
+ if (isset($auth['VAPID'])) {
+ $request = $this->requestBuilder->withVAPIDAuthentication($request, $auth['VAPID'], $subscription);
+ } elseif (isset($auth['GCM'])) {
+ $request = $this->requestBuilder->withGCMAuthentication($request, $auth['GCM']);
+ }
+
+ $promises[$subscriptionHash] = $this->client->sendAsync($request)
+ ->then(function (ResponseInterface $response) use ($subscription) {
+ return new PushResponse($subscription, $response->getStatusCode());
+ })
+ ->otherwise(function (\Throwable $reason) use ($subscription) {
+
+ if ($reason instanceof RequestException && $reason->hasResponse()) {
+ return new PushResponse($subscription, $reason->getResponse()->getStatusCode());
+ }
+
+ throw $reason;
+ })
+ ;
+ }
+
+ $promise = Promise\settle($promises)
+ ->then(function ($results) {
+ foreach ($results as $subscriptionHash => $promise) {
+ yield $subscriptionHash => $promise['value'] ?? $promise['reason'];
+ }
+ })
+ ;
+
+ return $promise->wait();
+ }
+
+ /**
+ * @return bool
+ */
+ public function isAutomaticPadding(): bool
+ {
+ return $this->maxPaddingLength !== 0;
+ }
+
+ /**
+ * @return int
+ */
+ public function getMaxPaddingLength()
+ {
+ return $this->maxPaddingLength;
+ }
+
+ /**
+ * @param int|bool $maxPaddingLength Max padding length
+ *
+ * @return self
+ *
+ * @throws \Exception
+ */
+ public function setMaxPaddingLength($maxPaddingLength): self
+ {
+ if ($maxPaddingLength > Encryption::MAX_PAYLOAD_LENGTH) {
+ throw new \Exception('Automatic padding is too large. Max is '.Encryption::MAX_PAYLOAD_LENGTH.'. Recommended max is '.Encryption::MAX_COMPATIBILITY_PAYLOAD_LENGTH.' for compatibility reasons (see README).');
+ } elseif ($maxPaddingLength < 0) {
+ throw new \Exception('Padding length should be positive or zero.');
+ } elseif ($maxPaddingLength === true) {
+ $this->maxPaddingLength = Encryption::MAX_COMPATIBILITY_PAYLOAD_LENGTH;
+ } elseif ($maxPaddingLength === false) {
+ $this->maxPaddingLength = 0;
+ } else {
+ $this->maxPaddingLength = $maxPaddingLength;
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getDefaultOptions(): array
+ {
+ return $this->defaultOptions;
+ }
+
+ /**
+ * @param array $defaultOptions Keys 'TTL' (Time To Live, defaults 0), 'urgency', 'topic', 'batchSize'
+ */
+ public function setDefaultOptions(array $defaultOptions)
+ {
+ $this->defaultOptions['TTL'] = $defaultOptions['TTL'] ?? 0;
+ $this->defaultOptions['urgency'] = $defaultOptions['urgency'] ?? null;
+ $this->defaultOptions['topic'] = $defaultOptions['topic'] ?? null;
+ $this->defaultOptions['batchSize'] = $defaultOptions['batchSize'] ?? 1000;
+ }
+}
diff --git a/src/Sender/PushMessagerSenderInterface.php b/src/Sender/PushMessagerSenderInterface.php
new file mode 100644
index 0000000..5ab3bda
--- /dev/null
+++ b/src/Sender/PushMessagerSenderInterface.php
@@ -0,0 +1,20 @@
+getEndpoint());
+ $request = $this->withOptionalHeaders($request, $message);
+ $request = $request->withHeader('TTL', $ttl);
+
+
+ if (null !== $message->getPayload() && null !== $subscription->getPublicKey() && null !== $subscription->getAuthToken()) {
+ $request = $request
+ ->withHeader('Content-Type', 'application/octet-stream')
+ ->withHeader('Content-Encoding', $subscription->getContentEncoding());
+
+
+ $payload = $this->getNormalizedPayload($message->getPayload(), $subscription->getContentEncoding(), $maxPaddingLength);
+
+ $encrypted = Encryption::encrypt(
+ $payload,
+ $subscription->getPublicKey(),
+ $subscription->getAuthToken(),
+ $subscription->getContentEncoding()
+ );
+
+ if ('aesgcm' === $subscription->getContentEncoding()) {
+ $request = $request->withHeader('Encryption', 'salt=' . Base64Url::encode($encrypted['salt']))
+ ->withHeader('Crypto-Key', 'dh=' . Base64Url::encode($encrypted['localPublicKey']));
+ }
+
+ $encryptionContentCodingHeader = Encryption::getContentCodingHeader($encrypted['salt'], $encrypted['localPublicKey'], $subscription->getContentEncoding());
+ $content = $encryptionContentCodingHeader . $encrypted['cipherText'];
+
+ return $request
+ ->withBody(stream_for($content))
+ ->withHeader('Content-Length', Utils::safeStrlen($content));
+ }
+
+
+ return $request
+ ->withHeader('Content-Length', 0);
+ }
+
+ /**
+ * @param RequestInterface $request
+ * @param array $vapid
+ * @param UserSubscriptionInterface $subscription
+ * @return RequestInterface
+ * @throws \ErrorException
+ * @throws \InvalidArgumentException
+ */
+ public function withVAPIDAuthentication(RequestInterface $request, array $vapid, UserSubscriptionInterface $subscription): RequestInterface
+ {
+
+ $endpoint = $subscription->getEndpoint();
+ $audience = parse_url($endpoint, PHP_URL_SCHEME) . '://' . parse_url($endpoint, PHP_URL_HOST);
+
+ if (!parse_url($audience)) {
+ throw new \ErrorException('Audience "' . $audience . '"" could not be generated.');
+ }
+
+ $vapidHeaders = VAPID::getVapidHeaders($audience, $vapid['subject'], $vapid['publicKey'], $vapid['privateKey'], $subscription->getContentEncoding());
+
+ $request = $request->withHeader('Authorization', $vapidHeaders['Authorization']);
+
+ if ('aesgcm' === $subscription->getContentEncoding()) {
+ if ($request->hasHeader('Crypto-Key')) {
+ $request = $request->withHeader('Crypto-Key', $request->getHeaderLine('Crypto-Key') . ';' . $vapidHeaders['Crypto-Key']);
+ } else {
+ $headers['Crypto-Key'] = $vapidHeaders['Crypto-Key'];
+ $request->withHeader('Crypto-Key', $vapidHeaders['Crypto-Key']);
+ }
+ } else if ('aes128gcm' === $subscription->getContentEncoding() && substr($endpoint, 0, strlen(self::FCM_BASE_URL)) === self::FCM_BASE_URL) {
+ $request = $request->withUri(new Uri(str_replace('fcm/send', 'wp', $endpoint)));
+ }
+
+ return $request;
+ }
+
+ /**
+ * @param RequestInterface $request
+ * @param string $apiKey
+ * @return RequestInterface
+ * @throws \InvalidArgumentException
+ */
+ public function withGCMAuthentication(RequestInterface $request, string $apiKey): RequestInterface
+ {
+ return $request->withHeader('Authorization', 'key=' . $apiKey);
+ }
+
+ /**
+ * @param RequestInterface $request
+ * @param PushMessage $message
+ * @return RequestInterface
+ * @throws \InvalidArgumentException
+ */
+ private function withOptionalHeaders(RequestInterface $request, PushMessage $message): RequestInterface
+ {
+ foreach (['urgency', 'topic'] as $option) {
+ if (null !== $message->getOption($option)) {
+ $request = $request->withHeader($option, $message->getOption($option));
+ }
+ }
+
+ return $request;
+ }
+
+ /**
+ * @param null|string $payload
+ * @param string $contentEncoding
+ * @param $automaticPadding
+ * @return null|string
+ * @throws \ErrorException
+ */
+ private function getNormalizedPayload(?string $payload, string $contentEncoding, $automaticPadding): ?string
+ {
+ if (null === $payload) {
+ return null;
+ }
+ if (Utils::safeStrlen($payload) > Encryption::MAX_PAYLOAD_LENGTH) {
+ throw new \ErrorException('Size of payload must not be greater than ' . Encryption::MAX_PAYLOAD_LENGTH . ' bytes.');
+ }
+
+ return Encryption::padPayload($payload, $automaticPadding, $contentEncoding);
+ }
+}
diff --git a/src/Twig/WebPushTwigExtension.php b/src/Twig/WebPushTwigExtension.php
index 7fb96e5..7dc0312 100644
--- a/src/Twig/WebPushTwigExtension.php
+++ b/src/Twig/WebPushTwigExtension.php
@@ -2,24 +2,38 @@
namespace BenTools\WebPushBundle\Twig;
-use Symfony\Component\DependencyInjection\ContainerAwareInterface;
-use Symfony\Component\DependencyInjection\ContainerAwareTrait;
-use Twig_Extension;
+use BenTools\WebPushBundle\Model\Subscription\UserSubscriptionManagerRegistry;
+use Twig\Extension\AbstractExtension;
use Twig_Extension_GlobalsInterface;
-final class WebPushTwigExtension extends Twig_Extension implements Twig_Extension_GlobalsInterface, ContainerAwareInterface
+final class WebPushTwigExtension extends AbstractExtension implements Twig_Extension_GlobalsInterface
{
- use ContainerAwareTrait;
+ /**
+ * @var string
+ */
+ private $publicKey;
+
+ /**
+ * @var UserSubscriptionManagerRegistry
+ */
+ private $registry;
+
+ public function __construct(
+ string $publicKey,
+ ?UserSubscriptionManagerRegistry $registry = null
+ ) {
+ $this->publicKey = $publicKey;
+ $this->registry = $registry;
+ }
/**
* @inheritDoc
*/
public function getGlobals()
{
- $publicKey = $this->container->getParameter('bentools_webpush.public_key');
return [
- 'bentools_pusher' => [
- 'server_key' => $publicKey ?? null,
+ 'bentools_webpush' => [
+ 'server_key' => $this->publicKey,
],
];
}
diff --git a/src/WebPushBundle.php b/src/WebPushBundle.php
index 6bf7443..efc59df 100644
--- a/src/WebPushBundle.php
+++ b/src/WebPushBundle.php
@@ -2,7 +2,9 @@
namespace BenTools\WebPushBundle;
+use BenTools\WebPushBundle\DependencyInjection\WebPushCompilerPass;
use BenTools\WebPushBundle\DependencyInjection\WebPushExtension;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class WebPushBundle extends Bundle
@@ -14,4 +16,9 @@ public function getContainerExtension()
{
return new WebPushExtension();
}
+
+ public function build(ContainerBuilder $container)
+ {
+ $container->addCompilerPass(new WebPushCompilerPass());
+ }
}
diff --git a/tests/BundleTest.php b/tests/BundleTest.php
new file mode 100644
index 0000000..87dc87b
--- /dev/null
+++ b/tests/BundleTest.php
@@ -0,0 +1,47 @@
+assertEquals('this_is_a_private_key', self::$kernel->getContainer()->getParameter('bentools_webpush.vapid_private_key'));
+ $this->assertEquals('this_is_a_public_key', self::$kernel->getContainer()->getParameter('bentools_webpush.vapid_public_key'));
+ $this->assertTrue(self::$kernel->getContainer()->has(UserSubscriptionManagerRegistry::class));
+ }
+
+ /**
+ * @test
+ */
+ public function manager_is_found()
+ {
+ // Find by class name
+ $this->assertInstanceOf(TestUserSubscriptionManager::class, self::$kernel->getContainer()->get(UserSubscriptionManagerRegistry::class)->getManager(TestUser::class));
+
+ // Find by object
+ $this->assertInstanceOf(TestUserSubscriptionManager::class, self::$kernel->getContainer()->get(UserSubscriptionManagerRegistry::class)->getManager(new TestUser('foo')));
+ }
+
+ /**
+ * @test
+ * @expectedException \InvalidArgumentException
+ */
+ public function unknown_manager_raises_exception()
+ {
+ self::$kernel->getContainer()->get(UserSubscriptionManagerRegistry::class)->getManager(Foo::class);
+ }
+}
diff --git a/tests/Classes/TestKernel.php b/tests/Classes/TestKernel.php
new file mode 100644
index 0000000..1fdab94
--- /dev/null
+++ b/tests/Classes/TestKernel.php
@@ -0,0 +1,67 @@
+cacheDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $uniqid . DIRECTORY_SEPARATOR . 'cache';
+ $this->logDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $uniqid . DIRECTORY_SEPARATOR . 'logs';
+ }
+
+ public function registerBundles()
+ {
+ return [
+ new FrameworkBundle(),
+ new WebPushBundle(),
+ ];
+ }
+
+ protected function configureRoutes(RouteCollectionBuilder $routes)
+ {
+ }
+
+ protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader)
+ {
+ $c->loadFromExtension('framework', [
+ 'secret' => getenv('APP_SECRET'),
+ ]);
+ $c->loadFromExtension('bentools_webpush', [
+ 'settings' => [
+ 'private_key' => 'this_is_a_private_key',
+ 'public_key' => 'this_is_a_public_key',
+ ]
+ ]);
+ $loader->load(dirname(__DIR__) . '/Resources/services.yaml');
+
+
+ $c->addCompilerPass(new PublicServicePass());
+ }
+
+ public function getCacheDir()
+ {
+ return $this->cacheDir;
+ }
+
+ public function getLogDir()
+ {
+ return $this->logDir;
+ }
+}
diff --git a/tests/Classes/TestUser.php b/tests/Classes/TestUser.php
new file mode 100644
index 0000000..8419013
--- /dev/null
+++ b/tests/Classes/TestUser.php
@@ -0,0 +1,39 @@
+userName = $userName;
+ }
+
+ public function getUsername()
+ {
+ return $this->userName;
+ }
+
+ public function getRoles()
+ {
+ }
+
+ public function getPassword()
+ {
+ }
+
+ public function getSalt()
+ {
+ }
+
+ public function eraseCredentials()
+ {
+ }
+}
diff --git a/tests/Classes/TestUserSubscription.php b/tests/Classes/TestUserSubscription.php
new file mode 100644
index 0000000..b85e56b
--- /dev/null
+++ b/tests/Classes/TestUserSubscription.php
@@ -0,0 +1,82 @@
+id = $user->getUsername();
+ $this->user = $user;
+ $this->endpoint = $endpoint;
+ $this->publicKey = $publicKey;
+ $this->authtoken = $authtoken;
+ $this->subscriptionHash = $subscriptionHash;
+ }
+
+ public function getUser(): UserInterface
+ {
+ return $this->user;
+ }
+
+ public function getSubscriptionHash(): string
+ {
+ return $this->subscriptionHash;
+ }
+
+ public function getEndpoint(): string
+ {
+ return $this->endpoint;
+ }
+
+ public function getPublicKey(): string
+ {
+ return $this->publicKey;
+ }
+
+ public function getAuthToken(): string
+ {
+ return $this->authtoken;
+ }
+
+ public function getContentEncoding(): string
+ {
+ return 'aesgcm';
+ }
+}
diff --git a/tests/Classes/TestUserSubscriptionManager.php b/tests/Classes/TestUserSubscriptionManager.php
new file mode 100644
index 0000000..f53f520
--- /dev/null
+++ b/tests/Classes/TestUserSubscriptionManager.php
@@ -0,0 +1,96 @@
+doctrine = $doctrine;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function factory(UserInterface $user, string $subscriptionHash, array $subscription, array $options = []): UserSubscriptionInterface
+ {
+ return new TestUserSubscription(
+ $user,
+ $subscription['endpoint'],
+ $subscription['keys']['p256dh'],
+ $subscription['keys']['auth'],
+ $subscriptionHash
+ );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function hash(string $endpoint, UserInterface $user): string
+ {
+ return md5($endpoint);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getUserSubscription(UserInterface $user, string $subscriptionHash): ?UserSubscriptionInterface
+ {
+ return $this->doctrine->getManagerForClass(TestUserSubscription::class)->getRepository(TestUserSubscription::class)->findOneBy([
+ 'user' => $user,
+ 'subscriptionHash' => $subscriptionHash,
+ ]);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function findByUser(UserInterface $user): iterable
+ {
+ return $this->doctrine->getManagerForClass(TestUserSubscription::class)->getRepository(TestUserSubscription::class)->findBy([
+ 'user' => $user,
+ ]);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function findByHash(string $subscriptionHash): iterable
+ {
+ return $this->doctrine->getManagerForClass(TestUserSubscription::class)->getRepository(TestUserSubscription::class)->findBy([
+ 'subscriptionHash' => $subscriptionHash,
+ ]);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function save(UserSubscriptionInterface $userSubscription): void
+ {
+ $this->doctrine->getManagerForClass(TestUserSubscription::class)->persist($userSubscription);
+ $this->doctrine->getManagerForClass(TestUserSubscription::class)->flush();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function delete(UserSubscriptionInterface $userSubscription): void
+ {
+ $this->doctrine->getManagerForClass(TestUserSubscription::class)->remove($userSubscription);
+ $this->doctrine->getManagerForClass(TestUserSubscription::class)->flush();
+ }
+}
diff --git a/tests/RegistrationTest.php b/tests/RegistrationTest.php
new file mode 100644
index 0000000..9d8eb70
--- /dev/null
+++ b/tests/RegistrationTest.php
@@ -0,0 +1,58 @@
+getContainer()->get('doctrine');
+ $registry = self::$kernel->getContainer()->get(UserSubscriptionManagerRegistry::class);
+ $em = $persistence->getManagerForClass(TestUser::class);
+ $bob = new TestUser('bob');
+ $em->persist($bob);
+ $em->flush();
+ $this->assertNotNull($em->find(TestUser::class, 'bob'));
+
+
+ $register = self::$kernel->getContainer()->get(RegisterSubscriptionAction::class);
+
+ $rawSubscriptionData = [
+ 'subscription' => [
+ 'endpoint' => 'http://foo.bar',
+ 'keys' => [
+ 'p256dh' => 'bob_public_key',
+ 'auth' => 'bob_private_key',
+ ]
+ ]
+ ];
+
+ $request = new Request([], [], [], [], [], ['REQUEST_METHOD' => 'POST'], json_encode($rawSubscriptionData));
+ $register($request, $bob);
+
+ $subscriptions = $registry->getManager($bob)->findByUser($bob);
+ $this->assertCount(1, $subscriptions);
+
+ $request = new Request([], [], [], [], [], ['REQUEST_METHOD' => 'DELETE'], json_encode($rawSubscriptionData));
+ $register($request, $bob);
+
+ $subscriptions = $registry->getManager($bob)->findByUser($bob);
+ $this->assertCount(0, $subscriptions);
+ }
+}
diff --git a/tests/Resources/services.yaml b/tests/Resources/services.yaml
new file mode 100644
index 0000000..a1f135e
--- /dev/null
+++ b/tests/Resources/services.yaml
@@ -0,0 +1,33 @@
+services:
+
+ doctrine:
+ class: BenTools\DoctrineStatic\ManagerRegistry
+ arguments:
+ -
+ - '@object_manager'
+
+ object_manager:
+ class: BenTools\DoctrineStatic\ObjectManager
+ arguments:
+ -
+ - '@test_user.repository'
+ - '@test_user_subscription.repository'
+
+ test_user.repository:
+ class: BenTools\DoctrineStatic\ObjectRepository
+ arguments:
+ - 'BenTools\WebPushBundle\Tests\Classes\TestUser'
+ - 'userName'
+
+ test_user_subscription.repository:
+ class: BenTools\DoctrineStatic\ObjectRepository
+ arguments:
+ - 'BenTools\WebPushBundle\Tests\Classes\TestUserSubscription'
+ - 'id'
+
+ BenTools\WebPushBundle\Tests\Classes\TestUserSubscriptionManager:
+ class: BenTools\WebPushBundle\Tests\Classes\TestUserSubscriptionManager
+ arguments:
+ - '@doctrine'
+ tags:
+ - { name: bentools_webpush.subscription_manager, user_class: 'BenTools\WebPushBundle\Tests\Classes\TestUser' }
\ No newline at end of file