├── LICENSE ├── README.md ├── SECURITY.md ├── composer.json └── src ├── bundle ├── DependencyInjection │ ├── Compiler │ │ ├── ExtensionCompilerPass.php │ │ ├── LoggerSetterCompilerPass.php │ │ ├── PayloadCacheCompilerPass.php │ │ ├── PayloadContentEncodingCompilerPass.php │ │ ├── PayloadPaddingCompilerPass.php │ │ └── SymfonyServiceCompilerPass.php │ ├── Configuration.php │ └── WebPushExtension.php ├── Exception │ └── InitializationException.php ├── LICENSE ├── Resources │ └── config │ │ ├── services.php │ │ ├── vapid.lcobucci.php │ │ ├── vapid.php │ │ └── vapid.web_token.php ├── Service │ ├── StatusReport.php │ └── WebPush.php ├── WebPushBundle.php └── composer.json └── library ├── Action.php ├── Base64Url.php ├── Cachable.php ├── Exception ├── AbstractWebPushException.php ├── OperationException.php └── WebPushException.php ├── Extension.php ├── ExtensionManager.php ├── LICENSE ├── Loggable.php ├── Message.php ├── Notification.php ├── NotificationInterface.php ├── Payload ├── AES128GCM.php ├── AESGCM.php ├── AbstractAESGCM.php ├── ContentEncoding.php ├── PayloadExtension.php └── ServerKey.php ├── PreferAsyncExtension.php ├── RequestData.php ├── StatusReport.php ├── StatusReportInterface.php ├── Subscription.php ├── SubscriptionInterface.php ├── TTLExtension.php ├── TopicExtension.php ├── UrgencyExtension.php ├── Utils.php ├── VAPID ├── Header.php ├── JWSProvider.php ├── LcobucciProvider.php ├── VAPIDExtension.php └── WebTokenProvider.php ├── WebPush.php ├── WebPushService.php └── composer.json /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020-2021 Spomky-Labs 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Web-Push Framework 2 | ================== 3 | 4 | This framework contains PHP libraries and Symfony bundle to allow developers to integrate web-push notifications into their web applications. 5 | 6 | # Status 7 | 8 | ![Build Status](https://github.com/Spomky-Labs/web-push/workflows/Integrate/badge.svg) 9 | 10 | [![Latest Stable Version](https://poser.pugx.org/Spomky-Labs/web-push/v)](//packagist.org/packages/Spomky-Labs/web-push) 11 | [![Total Downloads](https://poser.pugx.org/Spomky-Labs/web-push/downloads)](//packagist.org/packages/Spomky-Labs/web-push) 12 | [![Latest Unstable Version](https://poser.pugx.org/Spomky-Labs/web-push/v/unstable)](//packagist.org/packages/Spomky-Labs/web-push) 13 | [![License](https://poser.pugx.org/Spomky-Labs/web-push/license)](//packagist.org/packages/Spomky-Labs/web-push) 14 | 15 | # Documentation 16 | 17 | The documentation can be read on the following website: https://web-push.spomky-labs.com/ 18 | 19 | # Support 20 | 21 | I bring solutions to your problems and answer your questions. 22 | 23 | If you really love that project, and the work I have done or if you want I prioritize your issues, then you can help me out for a couple of :beers: or more! 24 | 25 | [Become a sponsor](https://github.com/sponsors/Spomky) 26 | 27 | Or 28 | 29 | [![Become a Patreon](https://c5.patreon.com/external/logo/become_a_patron_button.png)](https://www.patreon.com/FlorentMorselli) 30 | 31 | # Contributing 32 | 33 | Requests for new features, bug fixed and all other ideas to make this framework useful are welcome. 34 | If you feel comfortable writing code, you could try to fix [opened issues where help is wanted](https://github.com/Spomky-Labs/web-push?q=label%3A%22help+wanted%22) or [those that are easy to fix](https://github.com/Spomky-Labs/web-push/easy-pick). 35 | 36 | Do not forget to [follow these best practices](.github/CONTRIBUTING.md). 37 | 38 | **If you think you have found a security issue, DO NOT open an issue**. [You MUST submit your issue here](https://gitter.im/Spomky/). 39 | 40 | # Licence 41 | 42 | This software is release under [MIT licence](LICENSE). 43 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | |---------| ------------------ | 7 | | 3.1.x+ | :white_check_mark: | 8 | | < 3.1.x | :x: | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | If you think you have found a security issue, **DO NOT open an issue**. 13 | You MUST use the GitHub Security Advisories tool at https://github.com/Spomky-Labs/web-push/security/advisories. 14 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spomky-labs/web-push", 3 | "type": "bundle", 4 | "description": "Web-Push framework for PHP", 5 | "keywords": ["push", "notifications", "web", "WebPush", "Push API", "symfony", "bundle"], 6 | "homepage": "https://github.com/spomky-labs/web-push", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Spomky-Labs", 11 | "homepage": "https://github.com/spomky-labs" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=8.2", 16 | "ext-json": "*", 17 | "psr/cache": "^1.0|^2.0|^3.0", 18 | "psr/clock": "^1.0", 19 | "psr/log": "^1.1|^2.0|^3.0", 20 | "symfony/config": "^6.2|^7.0", 21 | "symfony/dependency-injection": "^6.2|^7.0", 22 | "symfony/framework-bundle": "^6.2|^7.0", 23 | "symfony/http-kernel": "^6.2|^7.0" 24 | }, 25 | "require-dev": { 26 | "doctrine/dbal": "^3.0|^4.0", 27 | "doctrine/doctrine-bundle": "^2.0", 28 | "doctrine/doctrine-fixtures-bundle": "^3.4", 29 | "doctrine/orm": "^2.6|^3.0", 30 | "ekino/phpstan-banned-code": "^1.0", 31 | "infection/infection": "^0.28", 32 | "lcobucci/jwt": "^4.3|^5.0", 33 | "matthiasnoback/symfony-config-test": "^4.2|^5.0", 34 | "matthiasnoback/symfony-dependency-injection-test": "^4.2|^5.0", 35 | "php-parallel-lint/php-parallel-lint": "^1.3", 36 | "phpbench/phpbench": "^1.0", 37 | "phpstan/extension-installer": "^1.3", 38 | "phpstan/phpstan": "^1.8", 39 | "phpstan/phpstan-deprecation-rules": "^1.0", 40 | "phpstan/phpstan-phpunit": "^1.1", 41 | "phpstan/phpstan-strict-rules": "^1.4", 42 | "phpunit/phpunit": "^10.1|^11.0", 43 | "qossmic/deptrac-shim": "^1.0", 44 | "rector/rector": "^1.0", 45 | "roave/security-advisories": "dev-latest", 46 | "symfony/cache": "^6.2|^7.0", 47 | "symfony/clock": "^6.2|^7.0", 48 | "symfony/http-client": "^6.2|^7.0", 49 | "symfony/monolog-bundle": "^3.5", 50 | "symfony/var-dumper": "^6.2|^7.0", 51 | "symfony/yaml": "^6.2|^7.0", 52 | "symplify/easy-coding-standard": "^12.0", 53 | "web-token/jwt-library": "^3.0" 54 | }, 55 | "autoload": { 56 | "psr-4" : { 57 | "WebPush\\" : "src/library/", 58 | "WebPush\\Bundle\\": "src/bundle/" 59 | } 60 | }, 61 | "autoload-dev": { 62 | "psr-4" : { 63 | "WebPush\\Tests\\" : "tests/" 64 | } 65 | }, 66 | "config": { 67 | "sort-packages": true, 68 | "allow-plugins": { 69 | "infection/extension-installer": true, 70 | "phpstan/extension-installer": true, 71 | "php-http/discovery": true 72 | } 73 | }, 74 | "replace": { 75 | "spomky-labs/web-push-lib": "self.version", 76 | "spomky-labs/web-push-bundle": "self.version" 77 | }, 78 | "suggest": { 79 | "ext-mbstring": "Mandatory when using Payload or VAPID extensions", 80 | "ext-openssl": "Mandatory when using Payload or VAPID extensions", 81 | "web-token/jwt-library": "Mandatory if you want to use VAPID using web-token/jwt-framework", 82 | "lcobucci/jwt": "Mandatory if you want to use VAPID using lcobucci/jwt", 83 | "psr/log-implementation": "Recommended to receive logs from the library" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/bundle/DependencyInjection/Compiler/ExtensionCompilerPass.php: -------------------------------------------------------------------------------- 1 | hasDefinition(ExtensionManager::class)) { 19 | return; 20 | } 21 | 22 | $definition = $container->getDefinition(ExtensionManager::class); 23 | $taggedServices = $container->findTaggedServiceIds(self::TAG); 24 | foreach ($taggedServices as $id => $attributes) { 25 | $definition->addMethodCall('add', [new Reference($id)]); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/bundle/DependencyInjection/Compiler/LoggerSetterCompilerPass.php: -------------------------------------------------------------------------------- 1 | hasAlias(self::SERVICE)) { 20 | return; 21 | } 22 | 23 | $taggedServices = $container->findTaggedServiceIds(self::TAG); 24 | foreach ($taggedServices as $id => $attributes) { 25 | $definition = $container->getDefinition($id); 26 | $definition->addMethodCall('setLogger', [new Reference(self::SERVICE)]); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/bundle/DependencyInjection/Compiler/PayloadCacheCompilerPass.php: -------------------------------------------------------------------------------- 1 | processForService( 18 | $container, 19 | AES128GCM::class, 20 | 'webpush.payload.aes128gcm.cache', 21 | 'webpush.payload.aes128gcm.cache_lifetime' 22 | ); 23 | $this->processForService( 24 | $container, 25 | AESGCM::class, 26 | 'webpush.payload.aesgcm.cache', 27 | 'webpush.payload.aesgcm.cache_lifetime' 28 | ); 29 | } 30 | 31 | private function processForService( 32 | ContainerBuilder $container, 33 | string $class, 34 | string $cache, 35 | string $parameter 36 | ): void { 37 | if (! $container->hasDefinition($class) || ! $container->hasAlias($cache)) { 38 | return; 39 | } 40 | 41 | $cacheLifetime = $container->getParameter($parameter); 42 | $definition = $container->getDefinition($class); 43 | $definition->addMethodCall('setCache', [new Reference($cache), $cacheLifetime]); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/bundle/DependencyInjection/Compiler/PayloadContentEncodingCompilerPass.php: -------------------------------------------------------------------------------- 1 | hasDefinition(PayloadExtension::class)) { 19 | return; 20 | } 21 | 22 | $definition = $container->getDefinition(PayloadExtension::class); 23 | $taggedServices = $container->findTaggedServiceIds(self::TAG); 24 | foreach ($taggedServices as $id => $attributes) { 25 | $definition->addMethodCall('addContentEncoding', [new Reference($id)]); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/bundle/DependencyInjection/Compiler/PayloadPaddingCompilerPass.php: -------------------------------------------------------------------------------- 1 | processForService($container, AES128GCM::class, 'webpush.payload.aes128gcm.padding'); 18 | $this->processForService($container, AESGCM::class, 'webpush.payload.aesgcm.padding'); 19 | } 20 | 21 | private function processForService(ContainerBuilder $container, string $class, string $parameter): void 22 | { 23 | if (! $container->hasDefinition($class)) { 24 | return; 25 | } 26 | 27 | $padding = $container->getParameter($parameter); 28 | $definition = $container->getDefinition($class); 29 | switch (true) { 30 | case $padding === 'none': 31 | $definition->addMethodCall('noPadding'); 32 | break; 33 | case $padding === 'recommended': 34 | $definition->addMethodCall('recommendedPadding'); 35 | break; 36 | case $padding === 'max': 37 | $definition->addMethodCall('maxPadding'); 38 | break; 39 | case is_int($padding): 40 | $definition->addMethodCall('customPadding', [$padding]); 41 | break; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/bundle/DependencyInjection/Compiler/SymfonyServiceCompilerPass.php: -------------------------------------------------------------------------------- 1 | hasDefinition('http_client')) { 19 | return; 20 | } 21 | 22 | $definition = new Definition(WebPush::class, [ 23 | new Reference('http_client'), 24 | new Reference(ExtensionManager::class), 25 | ]); 26 | $definition->setPublic(true); 27 | $container->setDefinition('web_push.service', $definition); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/bundle/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | alias); 26 | $treeBuilder->getRootNode() 27 | ->addDefaultsIfNotSet() 28 | ->children() 29 | ->scalarNode('logger') 30 | ->defaultNull() 31 | ->info('A PSR3 logger to receive logs') 32 | ->end() 33 | ->scalarNode('http_client') 34 | ->defaultValue(HttpClientInterface::class) 35 | ->info('PSR18 client to send notification to Web Push Services') 36 | ->end() 37 | ->arrayNode('vapid') 38 | ->canBeEnabled() 39 | ->validate() 40 | ->ifTrue(static function (array $conf): bool { 41 | $wt = $conf['web_token']['enabled'] ? 1 : 0; 42 | $lc = $conf['lcobucci']['enabled'] ? 1 : 0; 43 | $cu = $conf['custom']['enabled'] ? 1 : 0; 44 | 45 | return $wt + $lc + $cu !== 1; 46 | }) 47 | ->thenInvalid('One, and only one, JWS Provider shall be set') 48 | ->end() 49 | ->children() 50 | ->scalarNode('subject') 51 | ->isRequired() 52 | ->info('The URL of the service or an email address') 53 | ->end() 54 | ->scalarNode('token_lifetime') 55 | ->defaultValue('now +1hour') 56 | ->info('A PSR6 cache pool to enable caching feature') 57 | ->end() 58 | ->arrayNode('web_token') 59 | ->canBeEnabled() 60 | ->children() 61 | ->scalarNode('private_key') 62 | ->isRequired() 63 | ->info('The VAPID private key') 64 | ->end() 65 | ->scalarNode('public_key') 66 | ->isRequired() 67 | ->info('The VAPID public key') 68 | ->end() 69 | ->end() 70 | ->end() 71 | ->arrayNode('lcobucci') 72 | ->canBeEnabled() 73 | ->children() 74 | ->scalarNode('private_key') 75 | ->isRequired() 76 | ->info('The VAPID private key') 77 | ->end() 78 | ->scalarNode('public_key') 79 | ->isRequired() 80 | ->info('The VAPID public key') 81 | ->end() 82 | ->end() 83 | ->end() 84 | ->arrayNode('custom') 85 | ->canBeEnabled() 86 | ->children() 87 | ->scalarNode('id') 88 | ->isRequired() 89 | ->info('The custom JWS Provider service ID') 90 | ->end() 91 | ->end() 92 | ->end() 93 | ->end() 94 | ->end() 95 | ->arrayNode('payload') 96 | ->addDefaultsIfNotSet() 97 | ->children() 98 | ->arrayNode('aes128gcm') 99 | ->addDefaultsIfNotSet() 100 | ->children() 101 | ->scalarNode('padding') 102 | ->defaultValue('recommended') 103 | ->info('Length of the padding: none, recommended, max or and integer') 104 | ->validate() 105 | ->ifTrue(static function ($conf): bool { 106 | if (in_array($conf, ['none', 'max', 'recommended'], true)) { 107 | return false; 108 | } 109 | if (! is_int($conf)) { 110 | return true; 111 | } 112 | 113 | return $conf < 0 || $conf > AES128GCM::PADDING_MAX; 114 | }) 115 | ->thenInvalid( 116 | sprintf( 117 | 'The padding must have one of the following value: none, recommended, max or an integer between 0 and %d', 118 | AES128GCM::PADDING_MAX 119 | ) 120 | ) 121 | ->end() 122 | ->end() 123 | ->scalarNode('cache') 124 | ->defaultNull() 125 | ->info('A PSR6 cache pool to enable caching feature') 126 | ->end() 127 | ->scalarNode('cache_lifetime') 128 | ->defaultValue('now + 30min') 129 | ->info('A PSR6 cache pool to enable caching feature') 130 | ->end() 131 | ->end() 132 | ->end() 133 | ->arrayNode('aesgcm') 134 | ->addDefaultsIfNotSet() 135 | ->children() 136 | ->scalarNode('padding') 137 | ->defaultValue('recommended') 138 | ->info('Length of the padding: none, recommended, max or and integer') 139 | ->validate() 140 | ->ifTrue(static function ($conf): bool { 141 | if (in_array($conf, ['none', 'max', 'recommended'], true)) { 142 | return false; 143 | } 144 | if (! is_int($conf)) { 145 | return true; 146 | } 147 | 148 | return $conf < 0 || $conf > AESGCM::PADDING_MAX; 149 | }) 150 | ->thenInvalid( 151 | sprintf( 152 | 'The padding must have one of the following value: none, recommended, max or an integer between 0 and %d', 153 | AESGCM::PADDING_MAX 154 | ) 155 | ) 156 | ->end() 157 | ->end() 158 | ->scalarNode('cache') 159 | ->defaultNull() 160 | ->info('A PSR6 cache pool to enable caching feature') 161 | ->end() 162 | ->scalarNode('cache_lifetime') 163 | ->defaultValue('now + 30min') 164 | ->info('A PSR6 cache pool to enable caching feature') 165 | ->end() 166 | ->end() 167 | ->end() 168 | ->end() 169 | ->end() 170 | ->end() 171 | ; 172 | 173 | return $treeBuilder; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/bundle/DependencyInjection/WebPushExtension.php: -------------------------------------------------------------------------------- 1 | alias; 31 | } 32 | 33 | public function load(array $configs, ContainerBuilder $container): void 34 | { 35 | $processor = new Processor(); 36 | $config = $processor->processConfiguration($this->getConfiguration($configs, $container), $configs); 37 | 38 | $container->registerForAutoconfiguration(\WebPush\Extension::class)->addTag(ExtensionCompilerPass::TAG); 39 | $container->registerForAutoconfiguration(Loggable::class)->addTag(LoggerSetterCompilerPass::TAG); 40 | $container->registerForAutoconfiguration(ContentEncoding::class)->addTag( 41 | PayloadContentEncodingCompilerPass::TAG 42 | ); 43 | 44 | $loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config/')); 45 | $loader->load('services.php'); 46 | 47 | $container->setAlias('webpush.http_client', $config['http_client']); 48 | if ($config['logger'] !== null) { 49 | $container->setAlias(LoggerSetterCompilerPass::SERVICE, $config['logger']); 50 | } 51 | 52 | $this->configureVapidSection($container, $loader, $config['vapid']); 53 | $this->configurePayloadSection($container, $config['payload']); 54 | } 55 | 56 | public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface 57 | { 58 | return new Configuration($this->alias); 59 | } 60 | 61 | /** 62 | * @param array $config 63 | */ 64 | private function configureVapidSection(ContainerBuilder $container, LoaderInterface $loader, array $config): void 65 | { 66 | if (! $config['enabled']) { 67 | return; 68 | } 69 | 70 | $container->setParameter('webpush.vapid.subject', $config['subject']); 71 | $container->setParameter('webpush.vapid.token_lifetime', $config['token_lifetime']); 72 | $loader->load('vapid.php'); 73 | 74 | switch (true) { 75 | case $config['web_token']['enabled']: 76 | $loader->load('vapid.web_token.php'); 77 | $container->setParameter('webpush.vapid.web_token.private_key', $config['web_token']['private_key']); 78 | $container->setParameter('webpush.vapid.web_token.public_key', $config['web_token']['public_key']); 79 | 80 | break; 81 | case $config['lcobucci']['enabled']: 82 | $loader->load('vapid.lcobucci.php'); 83 | $container->setParameter('webpush.vapid.lcobucci.private_key', $config['lcobucci']['private_key']); 84 | $container->setParameter('webpush.vapid.lcobucci.public_key', $config['lcobucci']['public_key']); 85 | 86 | break; 87 | case $config['custom']['enabled']: 88 | $container->setAlias(JWSProvider::class, $config['custom']['id']); 89 | 90 | break; 91 | } 92 | } 93 | 94 | private function configurePayloadSection(ContainerBuilder $container, array $config): void 95 | { 96 | $container->setParameter('webpush.payload.aesgcm.cache_lifetime', $config['aesgcm']['cache_lifetime']); 97 | $container->setParameter('webpush.payload.aesgcm.padding', $config['aesgcm']['padding']); 98 | if ($config['aesgcm']['cache'] !== null) { 99 | $container->setAlias('webpush.payload.aesgcm.cache', $config['aesgcm']['cache']); 100 | } 101 | 102 | $container->setParameter('webpush.payload.aes128gcm.cache_lifetime', $config['aes128gcm']['cache_lifetime']); 103 | $container->setParameter('webpush.payload.aes128gcm.padding', $config['aes128gcm']['padding']); 104 | if ($config['aes128gcm']['cache'] !== null) { 105 | $container->setAlias('webpush.payload.aes128gcm.cache', $config['aes128gcm']['cache']); 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/bundle/Exception/InitializationException.php: -------------------------------------------------------------------------------- 1 | services() 19 | ->defaults() 20 | ->private() 21 | ->autoconfigure() 22 | ->autowire() 23 | ; 24 | 25 | $container->set(ExtensionManager::class); 26 | $container->set(UrgencyExtension::class); 27 | $container->set(TTLExtension::class); 28 | $container->set(TopicExtension::class); 29 | $container->set(PreferAsyncExtension::class); 30 | $container->set(PayloadExtension::class); 31 | $container->set(AESGCM::class); 32 | $container->set(AES128GCM::class); 33 | 34 | $container->set(WebPush::class) 35 | ->args([service('webpush.http_client'), service(ExtensionManager::class)]) 36 | ->public() 37 | ; 38 | }; 39 | -------------------------------------------------------------------------------- /src/bundle/Resources/config/vapid.lcobucci.php: -------------------------------------------------------------------------------- 1 | services() 12 | ->defaults() 13 | ->private() 14 | ->autoconfigure() 15 | ->autowire() 16 | ; 17 | 18 | $container->set(JWSProvider::class) 19 | ->class(LcobucciProvider::class) 20 | ->args([param('webpush.vapid.lcobucci.public_key'), param('webpush.vapid.lcobucci.private_key')]) 21 | ; 22 | }; 23 | -------------------------------------------------------------------------------- /src/bundle/Resources/config/vapid.php: -------------------------------------------------------------------------------- 1 | services() 13 | ->defaults() 14 | ->private() 15 | ->autoconfigure() 16 | ->autowire() 17 | ; 18 | 19 | $container->set(VAPIDExtension::class) 20 | ->args([param('webpush.vapid.subject'), service(JWSProvider::class)]) 21 | ->call('setTokenExpirationTime', [param('webpush.vapid.token_lifetime')]) 22 | ; 23 | }; 24 | -------------------------------------------------------------------------------- /src/bundle/Resources/config/vapid.web_token.php: -------------------------------------------------------------------------------- 1 | services() 12 | ->defaults() 13 | ->private() 14 | ->autoconfigure() 15 | ->autowire() 16 | ; 17 | 18 | $container->set(JWSProvider::class) 19 | ->class(WebTokenProvider::class) 20 | ->args([param('webpush.vapid.web_token.public_key'), param('webpush.vapid.web_token.private_key')]) 21 | ; 22 | }; 23 | -------------------------------------------------------------------------------- /src/bundle/Service/StatusReport.php: -------------------------------------------------------------------------------- 1 | subscription; 33 | } 34 | 35 | public function getNotification(): NotificationInterface 36 | { 37 | return $this->notification; 38 | } 39 | 40 | public function isSuccess(): bool 41 | { 42 | $code = $this->prepareStatusCode(); 43 | 44 | return $code >= 200 && $code < 300; 45 | } 46 | 47 | public function isSubscriptionExpired(): bool 48 | { 49 | $code = $this->prepareStatusCode(); 50 | 51 | return $code === 404 || $code === 410; 52 | } 53 | 54 | public function getLocation(): string 55 | { 56 | if ($this->location === null) { 57 | $headers = $this->response->getHeaders(false); 58 | $this->location = implode(', ', $headers['location'] ?? ['']); 59 | } 60 | 61 | return $this->location; 62 | } 63 | 64 | /** 65 | * @return string[] 66 | */ 67 | public function getLinks(): array 68 | { 69 | if ($this->links === null) { 70 | $headers = $this->response->getHeaders(false); 71 | $this->links = $headers['link'] ?? []; 72 | } 73 | 74 | return $this->links; 75 | } 76 | 77 | private function prepareStatusCode(): int 78 | { 79 | if ($this->code === null) { 80 | $this->code = $this->response->getStatusCode(); 81 | } 82 | 83 | return $this->code; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/bundle/Service/WebPush.php: -------------------------------------------------------------------------------- 1 | logger = new NullLogger(); 25 | } 26 | 27 | public function setLogger(LoggerInterface $logger): self 28 | { 29 | $this->logger = $logger; 30 | 31 | return $this; 32 | } 33 | 34 | public function send(NotificationInterface $notification, SubscriptionInterface $subscription): StatusReport 35 | { 36 | $this->logger->debug('Sending notification', [ 37 | 'notification' => $notification, 38 | 'subscription' => $subscription, 39 | ]); 40 | $requestData = $this->extensionManager->process($notification, $subscription); 41 | $response = $this->client->request('POST', $subscription->getEndpoint(), [ 42 | 'body' => $requestData->getBody(), 43 | 'headers' => $requestData->getHeaders(), 44 | ]); 45 | $this->logger->debug('Response received', [ 46 | 'response' => $response, 47 | ]); 48 | 49 | return new StatusReport($subscription, $notification, $response); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/bundle/WebPushBundle.php: -------------------------------------------------------------------------------- 1 | addCompilerPass(new ExtensionCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); 30 | $container->addCompilerPass(new LoggerSetterCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); 31 | $container->addCompilerPass(new PayloadContentEncodingCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); 32 | $container->addCompilerPass(new PayloadCacheCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); 33 | $container->addCompilerPass(new PayloadPaddingCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); 34 | $container->addCompilerPass(new SymfonyServiceCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/bundle/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spomky-labs/web-push-bundle", 3 | "type": "symfony-bundle", 4 | "description": "Web-Push bundle for Symfony", 5 | "keywords": ["push", "notifications", "web", "WebPush", "Push API"], 6 | "homepage": "https://github.com/Spomky-Labs/web-push", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Spomky-Labs", 11 | "homepage": "https://github.com/Spomky-Labs" 12 | }, 13 | { 14 | "name": "All contributors", 15 | "homepage": "https://github.com/Spomky-Labs/web-push-bundle/contributors" 16 | } 17 | ], 18 | "require": { 19 | "spomky-labs/web-push-lib": "^3.0", 20 | "symfony/config": "^6.2|^7.0", 21 | "symfony/dependency-injection": "^6.2|^7.0", 22 | "symfony/framework-bundle": "^6.2|^7.0" 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "WebPush\\Bundle\\": "" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/library/Action.php: -------------------------------------------------------------------------------- 1 | icon = $icon; 39 | 40 | return $this; 41 | } 42 | 43 | public function getAction(): string 44 | { 45 | return $this->action; 46 | } 47 | 48 | public function getTitle(): string 49 | { 50 | return $this->title; 51 | } 52 | 53 | public function getIcon(): ?string 54 | { 55 | return $this->icon; 56 | } 57 | 58 | /** 59 | * @return array 60 | */ 61 | public function jsonSerialize(): array 62 | { 63 | return array_filter(get_object_vars($this), static fn ($v): bool => $v !== null); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/library/Base64Url.php: -------------------------------------------------------------------------------- 1 | logger = new NullLogger(); 22 | } 23 | 24 | public static function create(): self 25 | { 26 | return new self(); 27 | } 28 | 29 | public function setLogger(LoggerInterface $logger): self 30 | { 31 | $this->logger = $logger; 32 | 33 | return $this; 34 | } 35 | 36 | public function add(Extension $extension): self 37 | { 38 | $this->extensions[] = $extension; 39 | $this->logger->debug('Extension added', [ 40 | 'extension' => $extension, 41 | ]); 42 | 43 | return $this; 44 | } 45 | 46 | public function process( 47 | NotificationInterface $notification, 48 | SubscriptionInterface $subscription 49 | ): RequestData { 50 | $this->logger->debug('Processing the request'); 51 | $requestData = new RequestData(); 52 | foreach ($this->extensions as $extension) { 53 | $extension->process($requestData, $notification, $subscription); 54 | } 55 | $this->logger->debug('Processing done'); 56 | 57 | return $requestData; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/library/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020-2021 Spomky-Labs 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. 22 | -------------------------------------------------------------------------------- /src/library/Loggable.php: -------------------------------------------------------------------------------- 1 | |null 53 | */ 54 | private ?array $vibrate = null; 55 | 56 | public function __construct( 57 | private string $title, 58 | private ?string $body = null 59 | ) { 60 | } 61 | 62 | public function toString(): string 63 | { 64 | return json_encode($this, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 65 | } 66 | 67 | public static function create(string $title, ?string $body = null): self 68 | { 69 | return new self($title, $body); 70 | } 71 | 72 | /** 73 | * @return array 74 | */ 75 | public function getActions(): array 76 | { 77 | return $this->actions; 78 | } 79 | 80 | public function getBody(): ?string 81 | { 82 | return $this->body; 83 | } 84 | 85 | public function getData(): mixed 86 | { 87 | return $this->data; 88 | } 89 | 90 | public function getDir(): ?string 91 | { 92 | return $this->dir; 93 | } 94 | 95 | public function getBadge(): ?string 96 | { 97 | return $this->badge; 98 | } 99 | 100 | public function getIcon(): ?string 101 | { 102 | return $this->icon; 103 | } 104 | 105 | public function getImage(): ?string 106 | { 107 | return $this->image; 108 | } 109 | 110 | public function getLang(): ?string 111 | { 112 | return $this->lang; 113 | } 114 | 115 | public function getRenotify(): ?bool 116 | { 117 | return $this->renotify; 118 | } 119 | 120 | public function isInteractionRequired(): ?bool 121 | { 122 | return $this->requireInteraction; 123 | } 124 | 125 | public function isSilent(): ?bool 126 | { 127 | return $this->silent; 128 | } 129 | 130 | public function getTag(): ?string 131 | { 132 | return $this->tag; 133 | } 134 | 135 | public function getTitle(): string 136 | { 137 | return $this->title; 138 | } 139 | 140 | public function getTimestamp(): ?int 141 | { 142 | return $this->timestamp; 143 | } 144 | 145 | /** 146 | * @return array|null 147 | */ 148 | public function getVibrate(): ?array 149 | { 150 | return $this->vibrate; 151 | } 152 | 153 | public function addAction(Action $action): self 154 | { 155 | $this->actions[] = $action; 156 | 157 | return $this; 158 | } 159 | 160 | public function withData(mixed $data): self 161 | { 162 | $this->data = $data; 163 | 164 | return $this; 165 | } 166 | 167 | public function auto(): self 168 | { 169 | $this->dir = 'auto'; 170 | 171 | return $this; 172 | } 173 | 174 | public function withTitle(string $title): self 175 | { 176 | $this->title = $title; 177 | 178 | return $this; 179 | } 180 | 181 | public function withBody(string $body): self 182 | { 183 | $this->body = $body; 184 | 185 | return $this; 186 | } 187 | 188 | public function ltr(): self 189 | { 190 | $this->dir = 'ltr'; 191 | 192 | return $this; 193 | } 194 | 195 | public function rtl(): self 196 | { 197 | $this->dir = 'rtl'; 198 | 199 | return $this; 200 | } 201 | 202 | public function withBadge(string $badge): self 203 | { 204 | $this->badge = $badge; 205 | 206 | return $this; 207 | } 208 | 209 | public function withIcon(string $icon): self 210 | { 211 | $this->icon = $icon; 212 | 213 | return $this; 214 | } 215 | 216 | public function withImage(string $image): self 217 | { 218 | $this->image = $image; 219 | 220 | return $this; 221 | } 222 | 223 | public function withLang(string $lang): self 224 | { 225 | $this->lang = $lang; 226 | 227 | return $this; 228 | } 229 | 230 | public function renotify(): self 231 | { 232 | $this->renotify = true; 233 | 234 | return $this; 235 | } 236 | 237 | public function doNotRenotify(): self 238 | { 239 | $this->renotify = false; 240 | 241 | return $this; 242 | } 243 | 244 | public function interactionRequired(): self 245 | { 246 | $this->requireInteraction = true; 247 | 248 | return $this; 249 | } 250 | 251 | public function noInteraction(): self 252 | { 253 | $this->requireInteraction = false; 254 | 255 | return $this; 256 | } 257 | 258 | public function mute(): self 259 | { 260 | $this->silent = true; 261 | 262 | return $this; 263 | } 264 | 265 | public function unmute(): self 266 | { 267 | $this->silent = false; 268 | 269 | return $this; 270 | } 271 | 272 | public function withTag(string $tag): self 273 | { 274 | $this->tag = $tag; 275 | 276 | return $this; 277 | } 278 | 279 | public function withTimestamp(int $timestamp): self 280 | { 281 | $this->timestamp = $timestamp; 282 | 283 | return $this; 284 | } 285 | 286 | public function vibrate(int ...$vibrations): self 287 | { 288 | $this->vibrate = array_values($vibrations); 289 | 290 | return $this; 291 | } 292 | 293 | /** 294 | * @return array 295 | */ 296 | public function jsonSerialize(): array 297 | { 298 | $properties = get_object_vars($this); 299 | unset($properties['title']); 300 | 301 | return [ 302 | 'title' => $this->title, 303 | 'options' => $this->getOptions($properties), 304 | ]; 305 | } 306 | 307 | /** 308 | * @param array $properties 309 | * 310 | * @return array 311 | */ 312 | private function getOptions(array $properties): array 313 | { 314 | return array_filter($properties, static function ($v): bool { 315 | if (is_array($v) && count($v) === 0) { 316 | return false; 317 | } 318 | 319 | return $v !== null; 320 | }); 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /src/library/Notification.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | private array $metadata = []; 27 | 28 | public static function create(): self 29 | { 30 | return new self(); 31 | } 32 | 33 | public function veryLowUrgency(): self 34 | { 35 | $this->urgency = self::URGENCY_VERY_LOW; 36 | 37 | return $this; 38 | } 39 | 40 | public function lowUrgency(): self 41 | { 42 | $this->urgency = self::URGENCY_LOW; 43 | 44 | return $this; 45 | } 46 | 47 | public function normalUrgency(): self 48 | { 49 | $this->urgency = self::URGENCY_NORMAL; 50 | 51 | return $this; 52 | } 53 | 54 | public function highUrgency(): self 55 | { 56 | $this->urgency = self::URGENCY_HIGH; 57 | 58 | return $this; 59 | } 60 | 61 | public function withUrgency(string $urgency): self 62 | { 63 | in_array($urgency, [ 64 | self::URGENCY_VERY_LOW, 65 | self::URGENCY_LOW, 66 | self::URGENCY_NORMAL, 67 | self::URGENCY_HIGH, 68 | ], true) || throw new OperationException('Invalid urgency parameter'); 69 | $this->urgency = $urgency; 70 | 71 | return $this; 72 | } 73 | 74 | public function getUrgency(): string 75 | { 76 | return $this->urgency; 77 | } 78 | 79 | public function withPayload(string $payload): self 80 | { 81 | $this->payload = $payload; 82 | 83 | return $this; 84 | } 85 | 86 | public function getPayload(): ?string 87 | { 88 | return $this->payload; 89 | } 90 | 91 | public function withTopic(string $topic): self 92 | { 93 | $topic !== '' || throw new OperationException('Invalid topic'); 94 | $this->topic = $topic; 95 | 96 | return $this; 97 | } 98 | 99 | public function getTopic(): ?string 100 | { 101 | return $this->topic; 102 | } 103 | 104 | public function withTTL(int $ttl): self 105 | { 106 | $ttl >= 0 || throw new OperationException('Invalid TTL'); 107 | $this->ttl = $ttl; 108 | 109 | return $this; 110 | } 111 | 112 | public function getTTL(): int 113 | { 114 | return $this->ttl; 115 | } 116 | 117 | public function sync(): self 118 | { 119 | $this->respondAsync = false; 120 | 121 | return $this; 122 | } 123 | 124 | public function async(): self 125 | { 126 | $this->respondAsync = true; 127 | 128 | return $this; 129 | } 130 | 131 | public function isAsync(): bool 132 | { 133 | return $this->respondAsync; 134 | } 135 | 136 | /** 137 | * @return array 138 | */ 139 | public function getMetadata(): array 140 | { 141 | return $this->metadata; 142 | } 143 | 144 | public function add(string $key, mixed $data): self 145 | { 146 | $this->metadata[$key] = $data; 147 | 148 | return $this; 149 | } 150 | 151 | public function has(string $key): bool 152 | { 153 | return array_key_exists($key, $this->metadata); 154 | } 155 | 156 | public function get(string $key): mixed 157 | { 158 | $this->has($key) === true || throw new OperationException('Missing metadata'); 159 | 160 | return $this->metadata[$key]; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/library/NotificationInterface.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | public function getMetadata(): array; 31 | 32 | public function has(string $key): bool; 33 | 34 | public function get(string $key): mixed; 35 | } 36 | -------------------------------------------------------------------------------- /src/library/Payload/AES128GCM.php: -------------------------------------------------------------------------------- 1 | = self::PADDING_NONE && $padding <= self::PADDING_MAX) || throw new OperationException( 27 | 'Invalid padding size' 28 | ); 29 | $this->padding = $padding; 30 | 31 | return $this; 32 | } 33 | 34 | public function maxPadding(): self 35 | { 36 | $this->padding = self::PADDING_MAX; 37 | 38 | return $this; 39 | } 40 | 41 | public function name(): string 42 | { 43 | return self::ENCODING; 44 | } 45 | 46 | protected function getKeyInfo(string $userAgentPublicKey, ServerKey $serverKey): string 47 | { 48 | return 'WebPush: info' . "\0" . $userAgentPublicKey . $serverKey->getPublicKey(); 49 | } 50 | 51 | protected function getContext(string $userAgentPublicKey, ServerKey $serverKey): string 52 | { 53 | return ''; 54 | } 55 | 56 | protected function addPadding(string $payload): string 57 | { 58 | return str_pad($payload . "\2", $this->padding, "\0", STR_PAD_RIGHT); 59 | } 60 | 61 | protected function prepareHeaders(RequestData $requestData, ServerKey $serverKey, string $salt): void 62 | { 63 | //Nothing to do 64 | } 65 | 66 | protected function prepareBody(string $encryptedText, ServerKey $serverKey, string $tag, string $salt): string 67 | { 68 | return $salt . 69 | pack('N*', 4096) . 70 | pack('C*', mb_strlen($serverKey->getPublicKey(), '8bit')) . 71 | $serverKey->getPublicKey() . 72 | $encryptedText . 73 | $tag 74 | ; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/library/Payload/AESGCM.php: -------------------------------------------------------------------------------- 1 | = self::PADDING_NONE && $padding <= self::PADDING_MAX) || throw new OperationException( 29 | 'Invalid padding size' 30 | ); 31 | $this->padding = $padding; 32 | 33 | return $this; 34 | } 35 | 36 | public function maxPadding(): self 37 | { 38 | $this->padding = self::PADDING_MAX; 39 | 40 | return $this; 41 | } 42 | 43 | public function name(): string 44 | { 45 | return self::ENCODING; 46 | } 47 | 48 | protected function getKeyInfo(string $userAgentPublicKey, ServerKey $serverKey): string 49 | { 50 | return "Content-Encoding: auth\0"; 51 | } 52 | 53 | protected function getContext(string $userAgentPublicKey, ServerKey $serverKey): string 54 | { 55 | return sprintf('%s%s%s%s', "P-256\0\0A", $userAgentPublicKey, "\0A", $serverKey->getPublicKey()); 56 | } 57 | 58 | protected function addPadding(string $payload): string 59 | { 60 | $payloadLength = mb_strlen($payload, '8bit'); 61 | $paddingLength = max(self::PADDING_NONE, $this->padding - $payloadLength); 62 | 63 | return pack('n*', $paddingLength) . str_pad($payload, $this->padding, "\0", STR_PAD_LEFT); 64 | } 65 | 66 | protected function prepareHeaders(RequestData $requestData, ServerKey $serverKey, string $salt): void 67 | { 68 | $requestData 69 | ->addHeader('Crypto-Key', sprintf('dh=%s', Base64Url::encode($serverKey->getPublicKey()))) 70 | ->addHeader('Encryption', 'salt=' . Base64Url::encode($salt)) 71 | ; 72 | } 73 | 74 | protected function prepareBody(string $encryptedText, ServerKey $serverKey, string $tag, string $salt): string 75 | { 76 | return $encryptedText . $tag; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/library/Payload/AbstractAESGCM.php: -------------------------------------------------------------------------------- 1 | logger = new NullLogger(); 56 | } 57 | 58 | public function setLogger(LoggerInterface $logger): self 59 | { 60 | $this->logger = $logger; 61 | 62 | return $this; 63 | } 64 | 65 | public function setCache(CacheItemPoolInterface $cache, string $cacheExpirationTime = 'now + 30min'): self 66 | { 67 | $this->cache = $cache; 68 | $this->cacheExpirationTime = $cacheExpirationTime; 69 | 70 | return $this; 71 | } 72 | 73 | public function noPadding(): self 74 | { 75 | $this->padding = self::PADDING_NONE; 76 | 77 | return $this; 78 | } 79 | 80 | public function recommendedPadding(): self 81 | { 82 | $this->padding = self::PADDING_RECOMMENDED; 83 | 84 | return $this; 85 | } 86 | 87 | abstract public function maxPadding(): self; 88 | 89 | public function encode(string $payload, RequestData $requestData, SubscriptionInterface $subscription): void 90 | { 91 | $this->logger->debug('Trying to encode the following payload.'); 92 | $subscription->hasKey('p256dh') === true || throw new OperationException( 93 | 'The user-agent public key is missing' 94 | ); 95 | $userAgentPublicKey = Base64Url::decode($subscription->getKey('p256dh')); 96 | $this->logger->debug(sprintf('User-agent public key: %s', Base64Url::encode($userAgentPublicKey))); 97 | 98 | $subscription->hasKey('auth') === true || throw new OperationException( 99 | 'The user-agent authentication token is missing' 100 | ); 101 | $userAgentAuthToken = Base64Url::decode($subscription->getKey('auth')); 102 | $this->logger->debug(sprintf('User-agent auth token: %s', Base64Url::encode($userAgentAuthToken))); 103 | 104 | $salt = random_bytes(self::SALT_SIZE); 105 | $this->logger->debug(sprintf('Salt: %s', Base64Url::encode($salt))); 106 | 107 | $serverKey = $this->getServerKey(); 108 | 109 | //IKM 110 | $keyInfo = $this->getKeyInfo($userAgentPublicKey, $serverKey); 111 | $ikm = Utils::computeIKM( 112 | $keyInfo, 113 | $userAgentAuthToken, 114 | $userAgentPublicKey, 115 | $serverKey->getPrivateKey(), 116 | $serverKey->getPublicKey() 117 | ); 118 | $this->logger->debug(sprintf('IKM: %s', Base64Url::encode($ikm))); 119 | 120 | //PRK 121 | $prk = hash_hmac('sha256', $ikm, $salt, true); 122 | $this->logger->debug(sprintf('PRK: %s', Base64Url::encode($prk))); 123 | 124 | // Context 125 | $context = $this->getContext($userAgentPublicKey, $serverKey); 126 | 127 | // Derive the Content Encryption Key 128 | $contentEncryptionKeyInfo = $this->createInfo($this->name(), $context); 129 | $contentEncryptionKey = mb_substr( 130 | hash_hmac('sha256', $contentEncryptionKeyInfo . "\1", $prk, true), 131 | 0, 132 | self::CEK_SIZE, 133 | '8bit' 134 | ); 135 | $this->logger->debug(sprintf('CEK: %s', Base64Url::encode($contentEncryptionKey))); 136 | 137 | // Derive the Nonce 138 | $nonceInfo = $this->createInfo('nonce', $context); 139 | $nonce = mb_substr(hash_hmac('sha256', $nonceInfo . "\1", $prk, true), 0, self::NONCE_SIZE, '8bit'); 140 | $this->logger->debug(sprintf('NONCE: %s', Base64Url::encode($nonce))); 141 | 142 | // Padding 143 | $paddedPayload = $this->addPadding($payload); 144 | $this->logger->debug('Payload with padding', [ 145 | 'padded_payload' => $paddedPayload, 146 | ]); 147 | 148 | // Encryption 149 | $tag = ''; 150 | $encryptedText = openssl_encrypt( 151 | $paddedPayload, 152 | 'aes-128-gcm', 153 | $contentEncryptionKey, 154 | OPENSSL_RAW_DATA, 155 | $nonce, 156 | $tag 157 | ); 158 | if ($encryptedText === false) { 159 | throw new OperationException('Unable to encrypt the payload'); 160 | } 161 | $this->logger->debug(sprintf('Encrypted payload: %s', Base64Url::encode($encryptedText))); 162 | $this->logger->debug(sprintf('Tag: %s', Base64Url::encode($tag))); 163 | 164 | // Body to be sent 165 | $body = $this->prepareBody($encryptedText, $serverKey, $tag, $salt); 166 | $requestData->setBody($body); 167 | 168 | $bodyLength = mb_strlen($body, '8bit'); 169 | $bodyLength <= 4096 || throw new OperationException('The size of payload must not be greater than 4096 bytes.'); 170 | 171 | $requestData->addHeader('Content-Length', (string) $bodyLength); 172 | $this->prepareHeaders($requestData, $serverKey, $salt); 173 | } 174 | 175 | abstract protected function getKeyInfo(string $userAgentPublicKey, ServerKey $serverKey): string; 176 | 177 | abstract protected function getContext(string $userAgentPublicKey, ServerKey $serverKey): string; 178 | 179 | abstract protected function addPadding(string $payload): string; 180 | 181 | abstract protected function prepareBody( 182 | string $encryptedText, 183 | ServerKey $serverKey, 184 | string $tag, 185 | string $salt 186 | ): string; 187 | 188 | abstract protected function prepareHeaders(RequestData $requestData, ServerKey $serverKey, string $salt): void; 189 | 190 | private function createInfo(string $type, string $context): string 191 | { 192 | $info = 'Content-Encoding: '; 193 | $info .= $type; 194 | $info .= "\0"; 195 | 196 | return $info . $context; 197 | } 198 | 199 | private function getServerKey(): ServerKey 200 | { 201 | $this->logger->debug('Getting key from the cache'); 202 | if ($this->cache === null) { 203 | $this->logger->debug('No cache'); 204 | 205 | return $this->generateServerKey(); 206 | } 207 | $item = $this->cache->getItem($this->cacheKey); 208 | if ($item->isHit()) { 209 | $this->logger->debug('The key is available from the cache.'); 210 | $serverKey = $item->get(); 211 | $serverKey instanceof ServerKey || throw new OperationException('Invalid cache value'); 212 | 213 | return $serverKey; 214 | } 215 | $this->logger->debug('No key from the cache'); 216 | $serverKey = $this->generateServerKey(); 217 | $item = $item 218 | ->set($serverKey) 219 | ->expiresAt($this->clock->now()->modify($this->cacheExpirationTime)) 220 | ; 221 | $this->cache->save($item); 222 | $this->logger->debug('Key saved'); 223 | 224 | return $serverKey; 225 | } 226 | 227 | private function generateServerKey(): ServerKey 228 | { 229 | $this->logger->debug('Generating new key pair'); 230 | $keyResource = openssl_pkey_new([ 231 | 'curve_name' => 'prime256v1', 232 | 'private_key_type' => OPENSSL_KEYTYPE_EC, 233 | ]); 234 | if ($keyResource === false) { 235 | throw new OperationException('Unable to generate a server key'); 236 | } 237 | 238 | $details = openssl_pkey_get_details($keyResource); 239 | 240 | is_array($details) || throw new OperationException('Unable to get the key details'); 241 | 242 | $publicKey = "\4"; 243 | $publicKey .= str_pad((string) $details['ec']['x'], self::SIZE, "\0", STR_PAD_LEFT); 244 | $publicKey .= str_pad((string) $details['ec']['y'], self::SIZE, "\0", STR_PAD_LEFT); 245 | $privateKey = str_pad((string) $details['ec']['d'], self::SIZE, "\0", STR_PAD_LEFT); 246 | $key = ServerKey::create($publicKey, $privateKey); 247 | 248 | $this->logger->debug('The key has been created.'); 249 | 250 | return $key; 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/library/Payload/ContentEncoding.php: -------------------------------------------------------------------------------- 1 | logger = new NullLogger(); 30 | } 31 | 32 | public static function create(): self 33 | { 34 | return new self(); 35 | } 36 | 37 | public function setLogger(LoggerInterface $logger): self 38 | { 39 | $this->logger = $logger; 40 | 41 | return $this; 42 | } 43 | 44 | public function addContentEncoding(ContentEncoding $contentEncoding): self 45 | { 46 | $this->contentEncodings[$contentEncoding->name()] = $contentEncoding; 47 | 48 | return $this; 49 | } 50 | 51 | public function process( 52 | RequestData $requestData, 53 | NotificationInterface $notification, 54 | SubscriptionInterface $subscription 55 | ): void { 56 | $this->logger->debug('Processing with payload'); 57 | $payload = $notification->getPayload(); 58 | if ($payload === null || $payload === '') { 59 | $this->logger->debug('No payload'); 60 | $requestData->addHeader('Content-Length', '0'); 61 | 62 | return; 63 | } 64 | 65 | $encoder = $this->findEncoder($subscription); 66 | $this->logger->debug(sprintf('Encoder found: %s. Processing with the encoder.', $encoder->name())); 67 | 68 | $requestData 69 | ->addHeader('Content-Type', 'application/octet-stream') 70 | ->addHeader('Content-Encoding', $encoder->name()) 71 | ; 72 | 73 | $encoder->encode($payload, $requestData, $subscription); 74 | } 75 | 76 | private function findEncoder(SubscriptionInterface $subscription): ContentEncoding 77 | { 78 | $supportedContentEncodings = $subscription->getSupportedContentEncodings(); 79 | foreach ($supportedContentEncodings as $supportedContentEncoding) { 80 | if (array_key_exists($supportedContentEncoding, $this->contentEncodings)) { 81 | return $this->contentEncodings[$supportedContentEncoding]; 82 | } 83 | } 84 | throw new InvalidArgumentException(sprintf( 85 | 'No content encoding found. Supported content encodings for the subscription are: %s', 86 | implode(', ', $supportedContentEncodings) 87 | )); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/library/Payload/ServerKey.php: -------------------------------------------------------------------------------- 1 | publicKey = $publicKey; 28 | $this->privateKey = $privateKey; 29 | } 30 | 31 | public static function create(string $publicKey, string $privateKey): self 32 | { 33 | return new self($publicKey, $privateKey); 34 | } 35 | 36 | public function getPublicKey(): string 37 | { 38 | return $this->publicKey; 39 | } 40 | 41 | public function getPrivateKey(): string 42 | { 43 | return $this->privateKey; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/library/PreferAsyncExtension.php: -------------------------------------------------------------------------------- 1 | logger = new NullLogger(); 17 | } 18 | 19 | public static function create(): self 20 | { 21 | return new self(); 22 | } 23 | 24 | public function setLogger(LoggerInterface $logger): self 25 | { 26 | $this->logger = $logger; 27 | 28 | return $this; 29 | } 30 | 31 | public function process( 32 | RequestData $requestData, 33 | NotificationInterface $notification, 34 | SubscriptionInterface $subscription 35 | ): void { 36 | if (! $notification->isAsync()) { 37 | $this->logger->debug('Sending synchronous notification'); 38 | 39 | return; 40 | } 41 | 42 | $this->logger->debug('Sending asynchronous notification'); 43 | 44 | $requestData 45 | ->addHeader('Prefer', 'respond-async') 46 | ; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/library/RequestData.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | private array $headers = []; 13 | 14 | private ?string $body = null; 15 | 16 | public function getBody(): ?string 17 | { 18 | return $this->body; 19 | } 20 | 21 | public function setBody(?string $body): static 22 | { 23 | $this->body = $body; 24 | 25 | return $this; 26 | } 27 | 28 | /** 29 | * @return array 30 | */ 31 | public function getHeaders(): array 32 | { 33 | return $this->headers; 34 | } 35 | 36 | public function addHeader(string $key, string $value): static 37 | { 38 | $this->headers[$key] = $value; 39 | 40 | return $this; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/library/StatusReport.php: -------------------------------------------------------------------------------- 1 | getStatusCode(); 42 | $headers = $response->getHeaders(false); 43 | $location = implode(', ', $headers['location'] ?? ['']); 44 | $links = $headers['link'] ?? []; 45 | 46 | return new self($subscription, $notification, $code, $location, $links); 47 | } 48 | 49 | public function getSubscription(): SubscriptionInterface 50 | { 51 | return $this->subscription; 52 | } 53 | 54 | public function getNotification(): NotificationInterface 55 | { 56 | return $this->notification; 57 | } 58 | 59 | public function isSuccess(): bool 60 | { 61 | return $this->code >= 200 && $this->code < 300; 62 | } 63 | 64 | public function isSubscriptionExpired(): bool 65 | { 66 | return $this->code === 404 || $this->code === 410; 67 | } 68 | 69 | public function getLocation(): string 70 | { 71 | return $this->location; 72 | } 73 | 74 | /** 75 | * @return string[] 76 | */ 77 | public function getLinks(): array 78 | { 79 | return $this->links; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/library/StatusReportInterface.php: -------------------------------------------------------------------------------- 1 | keys = []; 36 | } 37 | 38 | public static function create(string $endpoint): self 39 | { 40 | return new self($endpoint); 41 | } 42 | 43 | /** 44 | * @param string[] $contentEncodings 45 | */ 46 | public function withContentEncodings(array $contentEncodings): self 47 | { 48 | $this->supportedContentEncodings = $contentEncodings; 49 | 50 | return $this; 51 | } 52 | 53 | public function getKeys(): array 54 | { 55 | return $this->keys; 56 | } 57 | 58 | public function hasKey(string $key): bool 59 | { 60 | return isset($this->keys[$key]); 61 | } 62 | 63 | public function getKey(string $key): string 64 | { 65 | array_key_exists($key, $this->keys) || throw new OperationException('The key does not exist'); 66 | 67 | return $this->keys[$key]; 68 | } 69 | 70 | public function setKey(string $key, string $value): self 71 | { 72 | $this->keys[$key] = $value; 73 | 74 | return $this; 75 | } 76 | 77 | public function getExpirationTime(): ?int 78 | { 79 | return $this->expirationTime; 80 | } 81 | 82 | public function setExpirationTime(?int $expirationTime): self 83 | { 84 | $this->expirationTime = $expirationTime; 85 | 86 | return $this; 87 | } 88 | 89 | public function expiresAt(): ?DateTimeInterface 90 | { 91 | return $this->expirationTime === null ? null : (new DateTimeImmutable())->setTimestamp($this->expirationTime); 92 | } 93 | 94 | public function getEndpoint(): string 95 | { 96 | return $this->endpoint; 97 | } 98 | 99 | /** 100 | * @return string[] 101 | */ 102 | public function getSupportedContentEncodings(): array 103 | { 104 | return $this->supportedContentEncodings; 105 | } 106 | 107 | public static function createFromString(string $input): self 108 | { 109 | $data = json_decode($input, true, 512, JSON_THROW_ON_ERROR); 110 | 111 | is_array($data) || throw new OperationException('Invalid input'); 112 | array_walk($data, static function (mixed $item, string|int $key): void { 113 | is_string($key) || throw new OperationException('Invalid input'); 114 | }, ARRAY_FILTER_USE_KEY); 115 | 116 | return self::createFromAssociativeArray($data); 117 | } 118 | 119 | /** 120 | * @return array 121 | */ 122 | public function jsonSerialize(): array 123 | { 124 | return [ 125 | 'endpoint' => $this->endpoint, 126 | 'supportedContentEncodings' => $this->supportedContentEncodings, 127 | 'keys' => $this->keys, 128 | ]; 129 | } 130 | 131 | /** 132 | * @param array $input 133 | */ 134 | private static function createFromAssociativeArray(array $input): self 135 | { 136 | array_key_exists('endpoint', $input) || throw new OperationException('Invalid input'); 137 | is_string($input['endpoint']) || throw new OperationException('Invalid input'); 138 | 139 | $object = new self($input['endpoint']); 140 | if (array_key_exists('supportedContentEncodings', $input)) { 141 | $encodings = $input['supportedContentEncodings']; 142 | is_array($encodings) || throw new OperationException('Invalid input'); 143 | array_walk($encodings, static function (mixed $item): void { 144 | is_string($item) || throw new OperationException('Invalid input'); 145 | }); 146 | $object->withContentEncodings($encodings); 147 | } 148 | if (array_key_exists('expirationTime', $input)) { 149 | $input['expirationTime'] === null || is_int($input['expirationTime']) || throw new OperationException( 150 | 'Invalid input' 151 | ); 152 | $object->setExpirationTime($input['expirationTime']); 153 | } 154 | if (array_key_exists('keys', $input)) { 155 | is_array($input['keys']) || throw new OperationException('Invalid input'); 156 | foreach ($input['keys'] as $k => $v) { 157 | is_string($k) || throw new OperationException('Invalid key name'); 158 | is_string($v) || throw new OperationException('Invalid key value'); 159 | $object->setKey($k, $v); 160 | } 161 | } 162 | 163 | return $object; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/library/SubscriptionInterface.php: -------------------------------------------------------------------------------- 1 | 31 | */ 32 | public function jsonSerialize(): array; 33 | } 34 | -------------------------------------------------------------------------------- /src/library/TTLExtension.php: -------------------------------------------------------------------------------- 1 | logger = new NullLogger(); 17 | } 18 | 19 | public static function create(): self 20 | { 21 | return new self(); 22 | } 23 | 24 | public function setLogger(LoggerInterface $logger): self 25 | { 26 | $this->logger = $logger; 27 | 28 | return $this; 29 | } 30 | 31 | public function process( 32 | RequestData $requestData, 33 | NotificationInterface $notification, 34 | SubscriptionInterface $subscription 35 | ): void { 36 | $ttl = (string) $notification->getTTL(); 37 | $this->logger->debug('Processing with the TTL extension', [ 38 | 'TTL' => $ttl, 39 | ]); 40 | 41 | $requestData 42 | ->addHeader('TTL', $ttl) 43 | ; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/library/TopicExtension.php: -------------------------------------------------------------------------------- 1 | logger = new NullLogger(); 17 | } 18 | 19 | public static function create(): self 20 | { 21 | return new self(); 22 | } 23 | 24 | public function setLogger(LoggerInterface $logger): self 25 | { 26 | $this->logger = $logger; 27 | 28 | return $this; 29 | } 30 | 31 | public function process( 32 | RequestData $requestData, 33 | NotificationInterface $notification, 34 | SubscriptionInterface $subscription 35 | ): void { 36 | $topic = $notification->getTopic(); 37 | $this->logger->debug('Processing with the Topic extension', [ 38 | 'Topic' => $topic, 39 | ]); 40 | if ($topic === null) { 41 | return; 42 | } 43 | 44 | $requestData 45 | ->addHeader('Topic', $topic) 46 | ; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/library/UrgencyExtension.php: -------------------------------------------------------------------------------- 1 | logger = new NullLogger(); 17 | } 18 | 19 | public static function create(): self 20 | { 21 | return new self(); 22 | } 23 | 24 | public function setLogger(LoggerInterface $logger): self 25 | { 26 | $this->logger = $logger; 27 | 28 | return $this; 29 | } 30 | 31 | public function process( 32 | RequestData $requestData, 33 | NotificationInterface $notification, 34 | SubscriptionInterface $subscription 35 | ): void { 36 | $urgency = $notification->getUrgency(); 37 | $this->logger->debug('Processing with the Urgency extension', [ 38 | 'Urgency' => $urgency, 39 | ]); 40 | 41 | $requestData 42 | ->addHeader('Urgency', $urgency) 43 | ; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/library/Utils.php: -------------------------------------------------------------------------------- 1 | token; 23 | } 24 | 25 | public function getKey(): string 26 | { 27 | return $this->key; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/library/VAPID/JWSProvider.php: -------------------------------------------------------------------------------- 1 | $claims 11 | */ 12 | public function computeHeader(array $claims): Header; 13 | } 14 | -------------------------------------------------------------------------------- /src/library/VAPID/LcobucciProvider.php: -------------------------------------------------------------------------------- 1 | publicKey = $publicKey; 50 | $pem = Utils::privateKeyToPEM(Base64Url::decode($privateKey), Base64Url::decode($publicKey)); 51 | $pem !== '' || throw new OperationException('Invalid PEM'); 52 | $this->key = InMemory::plainText($pem); 53 | $this->logger = new NullLogger(); 54 | } 55 | 56 | public static function create(string $publicKey, string $privateKey): self 57 | { 58 | return new self($publicKey, $privateKey); 59 | } 60 | 61 | public function setLogger(LoggerInterface $logger): self 62 | { 63 | $this->logger = $logger; 64 | 65 | return $this; 66 | } 67 | 68 | public function computeHeader(array $claims): Header 69 | { 70 | $this->logger->debug('Computing the JWS'); 71 | $signer = new Sha256(); 72 | $header = json_encode([ 73 | 'typ' => 'JWT', 74 | 'alg' => 'ES256', 75 | ], JSON_THROW_ON_ERROR | self::JSON_OPTIONS); 76 | $payload = json_encode($claims, JSON_THROW_ON_ERROR | self::JSON_OPTIONS); 77 | $dataToSign = sprintf('%s.%s', Base64Url::encode($header), Base64Url::encode($payload)); 78 | $signature = $signer->sign($dataToSign, $this->key); 79 | $token = sprintf('%s.%s', $dataToSign, Base64Url::encode($signature)); 80 | 81 | $this->logger->debug('JWS computed', [ 82 | 'token' => $token, 83 | 'key' => $this->publicKey, 84 | ]); 85 | 86 | return Header::create($token, $this->publicKey); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/library/VAPID/VAPIDExtension.php: -------------------------------------------------------------------------------- 1 | logger = new NullLogger(); 32 | } 33 | 34 | public static function create(string $subject, JWSProvider $jwsProvider, ClockInterface $clock): self 35 | { 36 | return new self($subject, $jwsProvider, $clock); 37 | } 38 | 39 | public function setLogger(LoggerInterface $logger): self 40 | { 41 | $this->logger = $logger; 42 | 43 | return $this; 44 | } 45 | 46 | public function setTokenExpirationTime(string $tokenExpirationTime): self 47 | { 48 | $this->tokenExpirationTime = $tokenExpirationTime; 49 | 50 | return $this; 51 | } 52 | 53 | public function process( 54 | RequestData $requestData, 55 | NotificationInterface $notification, 56 | SubscriptionInterface $subscription 57 | ): void { 58 | $this->logger->debug('Processing with VAPID header'); 59 | $endpoint = $subscription->getEndpoint(); 60 | $expiresAt = $this->clock->now() 61 | ->modify($this->tokenExpirationTime); 62 | $parsedEndpoint = parse_url($endpoint); 63 | if (! is_array($parsedEndpoint) || ! isset($parsedEndpoint['host'], $parsedEndpoint['scheme'])) { 64 | throw new OperationException('Invalid subscription endpoint'); 65 | } 66 | $origin = $parsedEndpoint['scheme'] . '://' . $parsedEndpoint['host'] . (isset($parsedEndpoint['port']) ? ':' . $parsedEndpoint['port'] : ''); 67 | $claims = [ 68 | 'aud' => $origin, 69 | 'sub' => $this->subject, 70 | 'exp' => $expiresAt->getTimestamp(), 71 | ]; 72 | 73 | $this->logger->debug('Trying to get the header from the cache'); 74 | $header = $this->jwsProvider->computeHeader($claims); 75 | $this->logger->debug('Header from cache', [ 76 | 'header' => $header, 77 | ]); 78 | 79 | $requestData 80 | ->addHeader('Authorization', sprintf('vapid t=%s, k=%s', $header->getToken(), $header->getKey())) 81 | ; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/library/VAPID/WebTokenProvider.php: -------------------------------------------------------------------------------- 1 | signatureKey = new JWK([ 52 | 'kty' => 'EC', 53 | 'crv' => 'P-256', 54 | 'd' => $privateKey, 55 | 'x' => Base64Url::encode($x), 56 | 'y' => Base64Url::encode($y), 57 | ]); 58 | $algorithmManager = new AlgorithmManager([new ES256()]); 59 | $this->serializer = new CompactSerializer(); 60 | $this->jwsBuilder = new JWSBuilder($algorithmManager); 61 | $this->logger = new NullLogger(); 62 | } 63 | 64 | public static function create(string $publicKey, string $privateKey): self 65 | { 66 | return new self($publicKey, $privateKey); 67 | } 68 | 69 | public function setLogger(LoggerInterface $logger): self 70 | { 71 | $this->logger = $logger; 72 | 73 | return $this; 74 | } 75 | 76 | public function computeHeader(array $claims): Header 77 | { 78 | $this->logger->debug('Computing the JWS'); 79 | $payload = json_encode($claims, JSON_THROW_ON_ERROR); 80 | $jws = $this->jwsBuilder->create() 81 | ->withPayload($payload) 82 | ->addSignature($this->signatureKey, [ 83 | 'typ' => 'JWT', 84 | 'alg' => 'ES256', 85 | ]) 86 | ->build() 87 | ; 88 | $token = $this->serializer->serialize($jws); 89 | $key = $this->serializePublicKey(); 90 | $this->logger->debug('JWS computed', [ 91 | 'token' => $token, 92 | 'key' => $key, 93 | ]); 94 | 95 | return Header::create($token, $key); 96 | } 97 | 98 | private function serializePublicKey(): string 99 | { 100 | $x = $this->signatureKey->get('x'); 101 | is_string($x) || throw new OperationException('Invalid key'); 102 | $y = $this->signatureKey->get('y'); 103 | is_string($y) || throw new OperationException('Invalid key'); 104 | 105 | $hexString = '04'; 106 | $hexString .= bin2hex(Base64Url::decode($x)); 107 | $hexString .= bin2hex(Base64Url::decode($y)); 108 | $bin = hex2bin($hexString); 109 | if ($bin === false) { 110 | throw new OperationException('Unable to encode the public key'); 111 | } 112 | 113 | return Base64Url::encode($bin); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/library/WebPush.php: -------------------------------------------------------------------------------- 1 | logger = new NullLogger(); 20 | } 21 | 22 | public static function create(HttpClientInterface $client, ExtensionManager $extensionManager): self 23 | { 24 | return new self($client, $extensionManager); 25 | } 26 | 27 | public function setLogger(LoggerInterface $logger): self 28 | { 29 | $this->logger = $logger; 30 | 31 | return $this; 32 | } 33 | 34 | public function send( 35 | NotificationInterface $notification, 36 | SubscriptionInterface $subscription 37 | ): StatusReportInterface { 38 | $this->logger->debug('Sending notification', [ 39 | 'notification' => $notification, 40 | 'subscription' => $subscription, 41 | ]); 42 | $requestData = $this->extensionManager->process($notification, $subscription); 43 | $this->logger->debug('Request data ready', [ 44 | 'requestData' => $requestData, 45 | ]); 46 | 47 | $response = $this->client->request( 48 | 'POST', 49 | $subscription->getEndpoint(), 50 | [ 51 | 'headers' => $requestData->getHeaders(), 52 | 'body' => $requestData->getBody(), 53 | ] 54 | ); 55 | $this->logger->debug('Response received', [ 56 | 'response' => $response, 57 | ]); 58 | 59 | return StatusReport::createFromResponse($subscription, $notification, $response); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/library/WebPushService.php: -------------------------------------------------------------------------------- 1 | =8.2", 20 | "ext-json": "*", 21 | "psr/cache": "^1.0|^2.0|^3.0", 22 | "psr/clock": "^1.0", 23 | "psr/log": "^1.1|^2.0|^3.0", 24 | "symfony/http-kernel": "^6.2|^7.0" 25 | }, 26 | "autoload": { 27 | "psr-4" : { 28 | "WebPush\\" : "" 29 | } 30 | }, 31 | "suggest": { 32 | "ext-mbstring": "Mandatory when using Payload or VAPID extensions", 33 | "ext-openssl": "Mandatory when using Payload or VAPID extensions", 34 | "web-token/jwt-library": "Mandatory if you want to use VAPID using web-token/jwt-framework", 35 | "lcobucci/jwt": "Mandatory if you want to use VAPID using lcobucci/jwt", 36 | "psr/log-implementation": "Recommended to receive logs from the library" 37 | } 38 | } 39 | --------------------------------------------------------------------------------