Version 4.6.0
This commit is contained in:
1 parent
f79dcf067a
commit
517a5e1f70
2036 files changed
+110041
-26162
No files matched your search
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2015 Louis Lagrange
|
||||
|
||||
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.
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
# WebPush
|
||||
> Web Push library for PHP
|
||||
|
||||
[](https://travis-ci.org/web-push-libs/web-push-php)
|
||||
[](https://insight.sensiolabs.com/projects/d60e8eea-aea1-4739-8ce0-a3c3c12c6ccf)
|
||||
|
||||
WebPush can be used to send notifications to endpoints which server delivers Web Push notifications as described in
|
||||
the [Web Push protocol](https://tools.ietf.org/html/draft-thomson-webpush-protocol-00).
|
||||
As it is standardized, you don't have to worry about what server type it relies on.
|
||||
|
||||
## Requirements
|
||||
|
||||
PHP 7.2+ and the following extensions:
|
||||
* gmp (optional but better for performance)
|
||||
* mbstring
|
||||
* curl
|
||||
* openssl
|
||||
|
||||
There is no support and maintenance for older PHP versions, however you are free to use the following compatible versions:
|
||||
- PHP 5.6 or HHVM: `v1.x`
|
||||
- PHP 7.0: `v2.x`
|
||||
- PHP 7.1: `v3.x-v5.x`
|
||||
|
||||
## Installation
|
||||
Use [composer](https://getcomposer.org/) to download and install the library and its dependencies.
|
||||
|
||||
`composer require minishlink/web-push`
|
||||
|
||||
## Usage
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Minishlink\WebPush\WebPush;
|
||||
use Minishlink\WebPush\Subscription;
|
||||
|
||||
// array of notifications
|
||||
$notifications = [
|
||||
[
|
||||
'subscription' => Subscription::create([
|
||||
'endpoint' => 'https://updates.push.services.mozilla.com/push/abc...', // Firefox 43+,
|
||||
'publicKey' => 'BPcMbnWQL5GOYX/5LKZXT6sLmHiMsJSiEvIFvfcDvX7IZ9qqtq68onpTPEYmyxSQNiH7UD/98AUcQ12kBoxz/0s=', // base 64 encoded, should be 88 chars
|
||||
'authToken' => 'CxVX6QsVToEGEcjfYPqXQw==', // base 64 encoded, should be 24 chars
|
||||
]),
|
||||
'payload' => 'hello !',
|
||||
], [
|
||||
'subscription' => Subscription::create([
|
||||
'endpoint' => 'https://fcm.googleapis.com/fcm/send/abcdef...', // Chrome
|
||||
]),
|
||||
'payload' => null,
|
||||
], [
|
||||
'subscription' => Subscription::create([
|
||||
'endpoint' => 'https://example.com/other/endpoint/of/another/vendor/abcdef...',
|
||||
'publicKey' => '(stringOf88Chars)',
|
||||
'authToken' => '(stringOf24Chars)',
|
||||
'contentEncoding' => 'aesgcm', // one of PushManager.supportedContentEncodings
|
||||
]),
|
||||
'payload' => '{msg:"test"}',
|
||||
], [
|
||||
'subscription' => Subscription::create([ // this is the structure for the working draft from october 2018 (https://www.w3.org/TR/2018/WD-push-api-20181026/)
|
||||
"endpoint" => "https://example.com/other/endpoint/of/another/vendor/abcdef...",
|
||||
"keys" => [
|
||||
'p256dh' => '(stringOf88Chars)',
|
||||
'auth' => '(stringOf24Chars)'
|
||||
],
|
||||
]),
|
||||
'payload' => '{"msg":"Hello World!"}',
|
||||
],
|
||||
];
|
||||
|
||||
$webPush = new WebPush();
|
||||
|
||||
// send multiple notifications with payload
|
||||
foreach ($notifications as $notification) {
|
||||
$webPush->queueNotification(
|
||||
$notification['subscription'],
|
||||
$notification['payload'] // optional (defaults null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check sent results
|
||||
* @var MessageSentReport $report
|
||||
*/
|
||||
foreach ($webPush->flush() as $report) {
|
||||
$endpoint = $report->getRequest()->getUri()->__toString();
|
||||
|
||||
if ($report->isSuccess()) {
|
||||
echo "[v] Message sent successfully for subscription {$endpoint}.";
|
||||
} else {
|
||||
echo "[x] Message failed to sent for subscription {$endpoint}: {$report->getReason()}";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* send one notification and flush directly
|
||||
* @var MessageSentReport $report
|
||||
*/
|
||||
$report = $webPush->sendOneNotification(
|
||||
$notifications[0]['subscription'],
|
||||
$notifications[0]['payload'] // optional (defaults null)
|
||||
);
|
||||
```
|
||||
|
||||
### Full examples of Web Push implementations
|
||||
* An example with web-push-php: [Minishlink/web-push-php-example](https://github.com/Minishlink/web-push-php-example)
|
||||
* Matthew Gaunt's [Web Push Book](https://web-push-book.gauntface.com) - a must read
|
||||
* Mozilla's [ServiceWorker Cookbooks](https://serviceworke.rs/push-payload_index_doc.html) (don't mind the `server.js` file: it should be replaced by your PHP server code with this library)
|
||||
* Google's [introduction to push notifications](https://developers.google.com/web/fundamentals/getting-started/push-notifications/) (as of 03-20-2016, it doesn't mention notifications with payload)
|
||||
* you may want to take a look at my own implementation: [sw.js](https://github.com/Minishlink/physbook/blob/9bfcc2bbf7311a5de4628eb8f3ae56b6c3e74067/web/service-worker.js) and [app.js](https://github.com/Minishlink/physbook/blob/02a0d5d7ca0d5d2cc6d308a3a9b81244c63b3f14/app/Resources/public/js/app.js)
|
||||
|
||||
### Authentication (VAPID)
|
||||
Browsers need to verify your identity. A standard called VAPID can authenticate you for all browsers. You'll need to create and provide a public and private key for your server. These keys must be safely stored and should not change.
|
||||
|
||||
You can specify your authentication details when instantiating WebPush. The keys can be passed directly (recommended), or you can load a PEM file or its content:
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Minishlink\WebPush\WebPush;
|
||||
|
||||
$endpoint = 'https://fcm.googleapis.com/fcm/send/abcdef...'; // Chrome
|
||||
|
||||
$auth = [
|
||||
'VAPID' => [
|
||||
'subject' => 'mailto:me@website.com', // can be a mailto: or your website address
|
||||
'publicKey' => '~88 chars', // (recommended) uncompressed public key P-256 encoded in Base64-URL
|
||||
'privateKey' => '~44 chars', // (recommended) in fact the secret multiplier of the private key encoded in Base64-URL
|
||||
'pemFile' => 'path/to/pem', // if you have a PEM file and can link to it on your filesystem
|
||||
'pem' => 'pemFileContent', // if you have a PEM file and want to hardcode its content
|
||||
],
|
||||
];
|
||||
|
||||
$webPush = new WebPush($auth);
|
||||
$webPush->queueNotification(...);
|
||||
```
|
||||
|
||||
In order to generate the uncompressed public and secret key, encoded in Base64, enter the following in your Linux bash:
|
||||
```
|
||||
$ openssl ecparam -genkey -name prime256v1 -out private_key.pem
|
||||
$ openssl ec -in private_key.pem -pubout -outform DER|tail -c 65|base64|tr -d '=' |tr '/+' '_-' >> public_key.txt
|
||||
$ openssl ec -in private_key.pem -outform DER|tail -c +8|head -c 32|base64|tr -d '=' |tr '/+' '_-' >> private_key.txt
|
||||
```
|
||||
|
||||
If you can't access a Linux bash, you can print the output of the `createVapidKeys` function:
|
||||
```php
|
||||
var_dump(VAPID::createVapidKeys()); // store the keys afterwards
|
||||
```
|
||||
|
||||
On the client-side, don't forget to subscribe with the VAPID public key as the `applicationServerKey`: (`urlBase64ToUint8Array` source [here](https://github.com/Minishlink/physbook/blob/02a0d5d7ca0d5d2cc6d308a3a9b81244c63b3f14/app/Resources/public/js/app.js#L177))
|
||||
```js
|
||||
serviceWorkerRegistration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
|
||||
})
|
||||
```
|
||||
|
||||
#### Reusing VAPID headers
|
||||
|
||||
VAPID headers make use of a JSON Web Token (JWT) to verify your identity. That token payload includes the protocol and hostname of the endpoint included in the subscription and an expiration timestamp (usually between 12-24h), and it's signed using your public and private key. Given that, two notifications sent to the same push service will use the same token, so you can reuse them for the same flush session to boost performance using:
|
||||
|
||||
```php
|
||||
$webPush->setReuseVAPIDHeaders(true);
|
||||
```
|
||||
|
||||
### Notifications and default options
|
||||
Each notification can have a specific Time To Live, urgency, and topic.
|
||||
You can change the default options with `setDefaultOptions()` or in the constructor:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Minishlink\WebPush\WebPush;
|
||||
|
||||
$defaultOptions = [
|
||||
'TTL' => 300, // defaults to 4 weeks
|
||||
'urgency' => 'normal', // protocol defaults to "normal"
|
||||
'topic' => 'new_event', // not defined by default,
|
||||
'batchSize' => 200, // defaults to 1000
|
||||
];
|
||||
|
||||
// for every notifications
|
||||
$webPush = new WebPush([], $defaultOptions);
|
||||
$webPush->setDefaultOptions($defaultOptions);
|
||||
|
||||
// or for one notification
|
||||
$webPush->sendOneNotification($subscription, $payload, ['TTL' => 5000]);
|
||||
```
|
||||
|
||||
#### TTL
|
||||
Time To Live (TTL, in seconds) is how long a push message is retained by the push service (eg. Mozilla) in case the user browser
|
||||
is not yet accessible (eg. is not connected). You may want to use a very long time for important notifications. The default TTL is 4 weeks.
|
||||
However, if you send multiple nonessential notifications, set a TTL of 0: the push notification will be delivered only
|
||||
if the user is currently connected. For other cases, you should use a minimum of one day if your users have multiple time
|
||||
zones, and if they don't several hours will suffice.
|
||||
|
||||
#### urgency
|
||||
Urgency can be either "very-low", "low", "normal", or "high". If the browser vendor has implemented this feature, it will save battery life on mobile devices (cf. [protocol](https://tools.ietf.org/html/draft-ietf-webpush-protocol-08#section-5.3)).
|
||||
|
||||
#### topic
|
||||
Similar to the old `collapse_key` on legacy GCM servers, this string will make the vendor show to the user only the last notification of this topic (cf. [protocol](https://tools.ietf.org/html/draft-ietf-webpush-protocol-08#section-5.4)).
|
||||
|
||||
#### batchSize
|
||||
If you send tens of thousands notifications at a time, you may get memory overflows due to how endpoints are called in Guzzle.
|
||||
In order to fix this, WebPush sends notifications in batches. The default size is 1000. Depending on your server configuration (memory), you may want
|
||||
to decrease this number. Do this while instanciating WebPush or calling `setDefaultOptions`. Or, if you want to customize this for a specific flush, give
|
||||
it as a parameter : `$webPush->flush($batchSize)`.
|
||||
|
||||
### Server errors
|
||||
You can see what the browser vendor's server sends back in case it encountered an error (push subscription expiration, wrong parameters...).
|
||||
|
||||
* `sendOneNotification()` returns a [`MessageSentReport`](https://github.com/web-push-libs/web-push-php/blob/master/src/MessageSentReport.php)
|
||||
* `flush()` returns a [`\Generator`](http://php.net/manual/en/language.generators.php) with [`MessageSentReport`](https://github.com/web-push-libs/web-push-php/blob/master/src/MessageSentReport.php) objects. To loop through the results, just pass it into `foreach`. You can also use [`iterator_to_array`](http://php.net/manual/en/function.iterator-to-array.php) to check the contents while debugging.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
/** @var \Minishlink\WebPush\MessageSentReport $report */
|
||||
foreach ($webPush->flush() as $report) {
|
||||
$endpoint = $report->getEndpoint();
|
||||
|
||||
if ($report->isSuccess()) {
|
||||
echo "[v] Message sent successfully for subscription {$endpoint}.";
|
||||
} else {
|
||||
echo "[x] Message failed to sent for subscription {$endpoint}: {$report->getReason()}";
|
||||
|
||||
// also available (to get more info)
|
||||
|
||||
/** @var \Psr\Http\Message\RequestInterface $requestToPushService */
|
||||
$requestToPushService = $report->getRequest();
|
||||
|
||||
/** @var \Psr\Http\Message\ResponseInterface $responseOfPushService */
|
||||
$responseOfPushService = $report->getResponse();
|
||||
|
||||
/** @var string $failReason */
|
||||
$failReason = $report->getReason();
|
||||
|
||||
/** @var bool $isTheEndpointWrongOrExpired */
|
||||
$isTheEndpointWrongOrExpired = $report->isSubscriptionExpired();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**PLEASE NOTE:** You can only iterate **once** over the `\Generator` object.
|
||||
|
||||
Firefox errors are listed in the [autopush documentation](https://autopush.readthedocs.io/en/latest/http.html#errors).
|
||||
|
||||
### Payload length, security, and performance
|
||||
Payloads are encrypted by the library. The maximum payload length is theoretically 4078 bytes (or ASCII characters).
|
||||
For [compatibility reasons](mozilla-services/autopush/issues/748) though, your payload should be less than 3052 bytes long.
|
||||
|
||||
The library pads the payload by default. This is more secure but it decreases performance for both your server and your user's device.
|
||||
|
||||
#### Why is it more secure?
|
||||
When you encrypt a string of a certain length, the resulting string will always have the same length,
|
||||
no matter how many times you encrypt the initial string. This can make attackers guess the content of the payload.
|
||||
In order to circumvent this, this library adds some null padding to the initial payload, so that all the input of the encryption process
|
||||
will have the same length. This way, all the output of the encryption process will also have the same length and attackers won't be able to
|
||||
guess the content of your payload.
|
||||
|
||||
#### Why does it decrease performance?
|
||||
Encrypting more bytes takes more runtime on your server, and also slows down the user's device with decryption. Moreover, sending and receiving the packet will take more time.
|
||||
It's also not very friendly with users who have limited data plans.
|
||||
|
||||
#### How can I disable or customize automatic padding?
|
||||
You can customize automatic padding in order to better fit your needs.
|
||||
|
||||
Here are some ideas of settings:
|
||||
* (default) `Encryption::MAX_COMPATIBILITY_PAYLOAD_LENGTH` (3052 bytes) for compatibility purposes with Firefox for Android
|
||||
* `Encryption::MAX_PAYLOAD_LENGTH` (4078 bytes) for maximum security
|
||||
* `false` for maximum performance
|
||||
* If you know your payloads will not exceed `X` bytes, then set it to `X` for the best balance between security and performance.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Minishlink\WebPush\WebPush;
|
||||
|
||||
$webPush = new WebPush();
|
||||
$webPush->setAutomaticPadding(false); // disable automatic padding
|
||||
$webPush->setAutomaticPadding(512); // enable automatic padding to 512 bytes (you should make sure that your payload is less than 512 bytes, or else an attacker could guess the content)
|
||||
$webPush->setAutomaticPadding(true); // enable automatic padding to default maximum compatibility length
|
||||
```
|
||||
|
||||
### Customizing the HTTP client
|
||||
WebPush uses [Guzzle](https://github.com/guzzle/guzzle). It will use the most appropriate client it finds,
|
||||
and most of the time it will be `MultiCurl`, which allows to send multiple notifications in parallel.
|
||||
|
||||
You can customize the default request options and timeout when instantiating WebPush:
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Minishlink\WebPush\WebPush;
|
||||
|
||||
$timeout = 20; // seconds
|
||||
$clientOptions = [
|
||||
\GuzzleHttp\RequestOptions::ALLOW_REDIRECTS => false,
|
||||
]; // see \GuzzleHttp\RequestOptions
|
||||
$webPush = new WebPush([], [], $timeout, $clientOptions);
|
||||
```
|
||||
|
||||
## Common questions
|
||||
|
||||
### Is there any plugin/bundle/extension for my favorite PHP framework?
|
||||
The following are available:
|
||||
|
||||
- Symfony:
|
||||
- [MinishlinkWebPushBundle](https://github.com/Minishlink/web-push-bundle)
|
||||
- [bentools/webpush-bundle](https://github.com/bpolaszek/webpush-bundle) (associate your Symfony users to WebPush subscriptions)
|
||||
- Laravel: [laravel-notification-channels/webpush](https://github.com/laravel-notification-channels/webpush)
|
||||
- WordPress plugin: [Perfecty Push Notifications](https://github.com/rwngallego/perfecty-push-wp/)
|
||||
|
||||
Feel free to add your own!
|
||||
|
||||
### Is the API stable?
|
||||
Not until the [Push API spec](http://www.w3.org/TR/push-api/) is finished.
|
||||
|
||||
### What about security?
|
||||
Payload is encrypted according to the [Message Encryption for Web Push](https://tools.ietf.org/html/draft-ietf-webpush-encryption-01) standard,
|
||||
using the user public key and authentication secret that you can get by following the [Web Push API](http://www.w3.org/TR/push-api/) specification.
|
||||
|
||||
Internally, WebPush uses the [WebToken](https://github.com/web-token) framework or OpenSSL to handle encryption keys generation and encryption.
|
||||
|
||||
### How do I scale?
|
||||
Here are some ideas:
|
||||
|
||||
1. Upgrade to PHP 7.2
|
||||
2. Make sure MultiCurl is available on your server
|
||||
3. Find the right balance for your needs between security and performance (see above)
|
||||
4. Find the right batch size (set it in `defaultOptions` or as parameter to `flush()`)
|
||||
|
||||
### How to solve "SSL certificate problem: unable to get local issuer certificate"?
|
||||
Your installation lacks some certificates.
|
||||
|
||||
1. Download [cacert.pem](http://curl.haxx.se/ca/cacert.pem).
|
||||
2. Edit your `php.ini`: after `[curl]`, type `curl.cainfo = /path/to/cacert.pem`.
|
||||
|
||||
You can also force using a client without peer verification.
|
||||
|
||||
### How to solve "Bad key encryption key length" or "Unsupported key type"?
|
||||
Disable `mbstring.func_overload` in your `php.ini`.
|
||||
|
||||
### How to solve "Class 'Minishlink\WebPush\WebPush' not found"
|
||||
Make sure to require Composer's [autoloader](https://getcomposer.org/doc/01-basic-usage.md#autoloading).
|
||||
|
||||
```php
|
||||
require __DIR__ . '/path/to/vendor/autoload.php';
|
||||
```
|
||||
|
||||
### I must use PHP 5.4 or 5.5. What can I do?
|
||||
You won't be able to send any payload, so you'll only be able to use `sendOneNotification($subscription)` or `queueNotification($subscription)`.
|
||||
Install the library with `composer` using `--ignore-platform-reqs`.
|
||||
The workaround for getting the payload is to fetch it in the service worker ([example](https://github.com/Minishlink/physbook/blob/2ed8b9a8a217446c9747e9191a50d6312651125d/web/service-worker.js#L75)).
|
||||
|
||||
### I lost my VAPID keys!
|
||||
See [issue #58](https://github.com/web-push-libs/web-push-php/issues/58).
|
||||
|
||||
### I'm using Firebase push notifications, how do I use this library?
|
||||
This library is not designed for Firebase push notifications.
|
||||
You can still use it for your web projects (for standard WebPush notifications), but you should forget any link to Firebase while using the library.
|
||||
|
||||
### I need to send notifications to native apps. (eg. APNS for iOS)
|
||||
WebPush is for web apps.
|
||||
You need something like [RMSPushNotificationsBundle](https://github.com/richsage/RMSPushNotificationsBundle) (Symfony).
|
||||
|
||||
### This is PHP... I need Javascript!
|
||||
This library was inspired by the Node.js [marco-c/web-push](https://github.com/marco-c/web-push) library.
|
||||
|
||||
## Contributing
|
||||
See [CONTRIBUTING.md](https://github.com/Minishlink/web-push/blob/master/CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
[MIT](https://github.com/Minishlink/web-push/blob/master/LICENSE)
|
||||
|
||||
## Sponsors
|
||||
Thanks to [JetBrains](https://www.jetbrains.com/) for supporting the project through sponsoring some [All Products Packs](https://www.jetbrains.com/products.html) within their [Free Open Source License](https://www.jetbrains.com/buy/opensource/) program.
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the WebPush library.
|
||||
*
|
||||
* (c) Louis Lagrange <lagrange.louis@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Minishlink\WebPush;
|
||||
|
||||
use Base64Url\Base64Url;
|
||||
use Brick\Math\BigInteger;
|
||||
use Jose\Component\Core\JWK;
|
||||
use Jose\Component\Core\Util\Ecc\NistCurve;
|
||||
use Jose\Component\Core\Util\Ecc\PrivateKey;
|
||||
use Jose\Component\Core\Util\ECKey;
|
||||
|
||||
class Encryption
|
||||
{
|
||||
public const MAX_PAYLOAD_LENGTH = 4078;
|
||||
public const MAX_COMPATIBILITY_PAYLOAD_LENGTH = 3052;
|
||||
|
||||
/**
|
||||
* @param string $payload
|
||||
* @param int $maxLengthToPad
|
||||
* @param string $contentEncoding
|
||||
* @return string padded payload (plaintext)
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public static function padPayload(string $payload, int $maxLengthToPad, string $contentEncoding): string
|
||||
{
|
||||
$payloadLen = Utils::safeStrlen($payload);
|
||||
$padLen = $maxLengthToPad ? $maxLengthToPad - $payloadLen : 0;
|
||||
|
||||
if ($contentEncoding === "aesgcm") {
|
||||
return pack('n*', $padLen).str_pad($payload, $padLen + $payloadLen, chr(0), STR_PAD_LEFT);
|
||||
} elseif ($contentEncoding === "aes128gcm") {
|
||||
return str_pad($payload.chr(2), $padLen + $payloadLen, chr(0), STR_PAD_RIGHT);
|
||||
} else {
|
||||
throw new \ErrorException("This content encoding is not supported");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $payload With padding
|
||||
* @param string $userPublicKey Base 64 encoded (MIME or URL-safe)
|
||||
* @param string $userAuthToken Base 64 encoded (MIME or URL-safe)
|
||||
* @param string $contentEncoding
|
||||
* @return array
|
||||
*
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public static function encrypt(string $payload, string $userPublicKey, string $userAuthToken, string $contentEncoding): array
|
||||
{
|
||||
return self::deterministicEncrypt(
|
||||
$payload,
|
||||
$userPublicKey,
|
||||
$userAuthToken,
|
||||
$contentEncoding,
|
||||
self::createLocalKeyObject(),
|
||||
random_bytes(16)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $payload
|
||||
* @param string $userPublicKey
|
||||
* @param string $userAuthToken
|
||||
* @param string $contentEncoding
|
||||
* @param array $localKeyObject
|
||||
* @param string $salt
|
||||
* @return array
|
||||
*
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public static function deterministicEncrypt(string $payload, string $userPublicKey, string $userAuthToken, string $contentEncoding, array $localKeyObject, string $salt): array
|
||||
{
|
||||
$userPublicKey = Base64Url::decode($userPublicKey);
|
||||
$userAuthToken = Base64Url::decode($userAuthToken);
|
||||
|
||||
// get local key pair
|
||||
if (count($localKeyObject) === 1) {
|
||||
/** @var JWK $localJwk */
|
||||
$localJwk = current($localKeyObject);
|
||||
$localPublicKey = hex2bin(Utils::serializePublicKeyFromJWK($localJwk));
|
||||
} else {
|
||||
/** @var PrivateKey $localPrivateKeyObject */
|
||||
list($localPublicKeyObject, $localPrivateKeyObject) = $localKeyObject;
|
||||
$localPublicKey = hex2bin(Utils::serializePublicKey($localPublicKeyObject));
|
||||
$localJwk = new JWK([
|
||||
'kty' => 'EC',
|
||||
'crv' => 'P-256',
|
||||
'd' => Base64Url::encode($localPrivateKeyObject->getSecret()->toBytes(false)),
|
||||
'x' => Base64Url::encode($localPublicKeyObject[0]),
|
||||
'y' => Base64Url::encode($localPublicKeyObject[1]),
|
||||
]);
|
||||
}
|
||||
if (!$localPublicKey) {
|
||||
throw new \ErrorException('Failed to convert local public key from hexadecimal to binary');
|
||||
}
|
||||
|
||||
// get user public key object
|
||||
[$userPublicKeyObjectX, $userPublicKeyObjectY] = Utils::unserializePublicKey($userPublicKey);
|
||||
$userJwk = new JWK([
|
||||
'kty' => 'EC',
|
||||
'crv' => 'P-256',
|
||||
'x' => Base64Url::encode($userPublicKeyObjectX),
|
||||
'y' => Base64Url::encode($userPublicKeyObjectY),
|
||||
]);
|
||||
|
||||
// get shared secret from user public key and local private key
|
||||
|
||||
$sharedSecret = self::calculateAgreementKey($localJwk, $userJwk);
|
||||
|
||||
$sharedSecret = str_pad($sharedSecret, 32, chr(0), STR_PAD_LEFT);
|
||||
if (!$sharedSecret) {
|
||||
throw new \ErrorException('Failed to convert shared secret from hexadecimal to binary');
|
||||
}
|
||||
|
||||
// section 4.3
|
||||
$ikm = self::getIKM($userAuthToken, $userPublicKey, $localPublicKey, $sharedSecret, $contentEncoding);
|
||||
|
||||
// section 4.2
|
||||
$context = self::createContext($userPublicKey, $localPublicKey, $contentEncoding);
|
||||
|
||||
// derive the Content Encryption Key
|
||||
$contentEncryptionKeyInfo = self::createInfo($contentEncoding, $context, $contentEncoding);
|
||||
$contentEncryptionKey = self::hkdf($salt, $ikm, $contentEncryptionKeyInfo, 16);
|
||||
|
||||
// section 3.3, derive the nonce
|
||||
$nonceInfo = self::createInfo('nonce', $context, $contentEncoding);
|
||||
$nonce = self::hkdf($salt, $ikm, $nonceInfo, 12);
|
||||
|
||||
// encrypt
|
||||
// "The additional data passed to each invocation of AEAD_AES_128_GCM is a zero-length octet sequence."
|
||||
$tag = '';
|
||||
$encryptedText = openssl_encrypt($payload, 'aes-128-gcm', $contentEncryptionKey, OPENSSL_RAW_DATA, $nonce, $tag);
|
||||
|
||||
// return values in url safe base64
|
||||
return [
|
||||
'localPublicKey' => $localPublicKey,
|
||||
'salt' => $salt,
|
||||
'cipherText' => $encryptedText.$tag,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getContentCodingHeader(string $salt, string $localPublicKey, string $contentEncoding): string
|
||||
{
|
||||
if ($contentEncoding === "aes128gcm") {
|
||||
return $salt
|
||||
.pack('N*', 4096)
|
||||
.pack('C*', Utils::safeStrlen($localPublicKey))
|
||||
.$localPublicKey;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC-based Extract-and-Expand Key Derivation Function (HKDF).
|
||||
*
|
||||
* This is used to derive a secure encryption key from a mostly-secure shared
|
||||
* secret.
|
||||
*
|
||||
* This is a partial implementation of HKDF tailored to our specific purposes.
|
||||
* In particular, for us the value of N will always be 1, and thus T always
|
||||
* equals HMAC-Hash(PRK, info | 0x01).
|
||||
*
|
||||
* See {@link https://www.rfc-editor.org/rfc/rfc5869.txt}
|
||||
* From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}
|
||||
*
|
||||
* @param string $salt A non-secret random value
|
||||
* @param string $ikm Input keying material
|
||||
* @param string $info Application-specific context
|
||||
* @param int $length The length (in bytes) of the required output key
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function hkdf(string $salt, string $ikm, string $info, int $length): string
|
||||
{
|
||||
// extract
|
||||
$prk = hash_hmac('sha256', $ikm, $salt, true);
|
||||
|
||||
// expand
|
||||
return mb_substr(hash_hmac('sha256', $info.chr(1), $prk, true), 0, $length, '8bit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a context for deriving encryption parameters.
|
||||
* See section 4.2 of
|
||||
* {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00}
|
||||
* From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}.
|
||||
*
|
||||
* @param string $clientPublicKey The client's public key
|
||||
* @param string $serverPublicKey Our public key
|
||||
*
|
||||
* @return null|string
|
||||
*
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
private static function createContext(string $clientPublicKey, string $serverPublicKey, string $contentEncoding): ?string
|
||||
{
|
||||
if ($contentEncoding === "aes128gcm") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Utils::safeStrlen($clientPublicKey) !== 65) {
|
||||
throw new \ErrorException('Invalid client public key length');
|
||||
}
|
||||
|
||||
// This one should never happen, because it's our code that generates the key
|
||||
if (Utils::safeStrlen($serverPublicKey) !== 65) {
|
||||
throw new \ErrorException('Invalid server public key length');
|
||||
}
|
||||
|
||||
$len = chr(0).'A'; // 65 as Uint16BE
|
||||
|
||||
return chr(0).$len.$clientPublicKey.$len.$serverPublicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an info record. See sections 3.2 and 3.3 of
|
||||
* {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00}
|
||||
* From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}.
|
||||
*
|
||||
* @param string $type The type of the info record
|
||||
* @param string|null $context The context for the record
|
||||
* @param string $contentEncoding
|
||||
* @return string
|
||||
*
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
private static function createInfo(string $type, ?string $context, string $contentEncoding): string
|
||||
{
|
||||
if ($contentEncoding === "aesgcm") {
|
||||
if (!$context) {
|
||||
throw new \ErrorException('Context must exist');
|
||||
}
|
||||
|
||||
if (Utils::safeStrlen($context) !== 135) {
|
||||
throw new \ErrorException('Context argument has invalid size');
|
||||
}
|
||||
|
||||
return 'Content-Encoding: '.$type.chr(0).'P-256'.$context;
|
||||
} elseif ($contentEncoding === "aes128gcm") {
|
||||
return 'Content-Encoding: '.$type.chr(0);
|
||||
}
|
||||
|
||||
throw new \ErrorException('This content encoding is not supported.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
private static function createLocalKeyObject(): array
|
||||
{
|
||||
try {
|
||||
return self::createLocalKeyObjectUsingOpenSSL();
|
||||
} catch (\Exception $e) {
|
||||
return self::createLocalKeyObjectUsingPurePhpMethod();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
private static function createLocalKeyObjectUsingPurePhpMethod(): array
|
||||
{
|
||||
$curve = NistCurve::curve256();
|
||||
$privateKey = $curve->createPrivateKey();
|
||||
$publicKey = $curve->createPublicKey($privateKey);
|
||||
|
||||
if ($publicKey->getPoint()->getX() instanceof BigInteger) {
|
||||
return [
|
||||
new JWK([
|
||||
'kty' => 'EC',
|
||||
'crv' => 'P-256',
|
||||
'x' => Base64Url::encode(self::addNullPadding($publicKey->getPoint()->getX()->toBytes(false))),
|
||||
'y' => Base64Url::encode(self::addNullPadding($publicKey->getPoint()->getY()->toBytes(false))),
|
||||
'd' => Base64Url::encode(self::addNullPadding($privateKey->getSecret()->toBytes(false))),
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
new JWK([
|
||||
'kty' => 'EC',
|
||||
'crv' => 'P-256',
|
||||
'x' => Base64Url::encode(self::addNullPadding(hex2bin(gmp_strval($publicKey->getPoint()->getX(), 16)))),
|
||||
'y' => Base64Url::encode(self::addNullPadding(hex2bin(gmp_strval($publicKey->getPoint()->getY(), 16)))),
|
||||
'd' => Base64Url::encode(self::addNullPadding(hex2bin(gmp_strval($privateKey->getSecret(), 16)))),
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
private static function createLocalKeyObjectUsingOpenSSL(): array
|
||||
{
|
||||
$keyResource = openssl_pkey_new([
|
||||
'curve_name' => 'prime256v1',
|
||||
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
||||
]);
|
||||
|
||||
if (!$keyResource) {
|
||||
throw new \RuntimeException('Unable to create the key');
|
||||
}
|
||||
|
||||
$details = openssl_pkey_get_details($keyResource);
|
||||
openssl_pkey_free($keyResource);
|
||||
|
||||
if (!$details) {
|
||||
throw new \RuntimeException('Unable to get the key details');
|
||||
}
|
||||
|
||||
return [
|
||||
new JWK([
|
||||
'kty' => 'EC',
|
||||
'crv' => 'P-256',
|
||||
'x' => Base64Url::encode(self::addNullPadding($details['ec']['x'])),
|
||||
'y' => Base64Url::encode(self::addNullPadding($details['ec']['y'])),
|
||||
'd' => Base64Url::encode(self::addNullPadding($details['ec']['d'])),
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $userAuthToken
|
||||
* @param string $userPublicKey
|
||||
* @param string $localPublicKey
|
||||
* @param string $sharedSecret
|
||||
* @param string $contentEncoding
|
||||
* @return string
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
private static function getIKM(string $userAuthToken, string $userPublicKey, string $localPublicKey, string $sharedSecret, string $contentEncoding): string
|
||||
{
|
||||
if (!empty($userAuthToken)) {
|
||||
if ($contentEncoding === "aesgcm") {
|
||||
$info = 'Content-Encoding: auth'.chr(0);
|
||||
} elseif ($contentEncoding === "aes128gcm") {
|
||||
$info = "WebPush: info".chr(0).$userPublicKey.$localPublicKey;
|
||||
} else {
|
||||
throw new \ErrorException("This content encoding is not supported");
|
||||
}
|
||||
|
||||
return self::hkdf($userAuthToken, $sharedSecret, $info, 32);
|
||||
}
|
||||
|
||||
return $sharedSecret;
|
||||
}
|
||||
|
||||
private static function calculateAgreementKey(JWK $private_key, JWK $public_key): string
|
||||
{
|
||||
if (function_exists('openssl_pkey_derive')) {
|
||||
try {
|
||||
$publicPem = ECKey::convertPublicKeyToPEM($public_key);
|
||||
$privatePem = ECKey::convertPrivateKeyToPEM($private_key);
|
||||
|
||||
$result = openssl_pkey_derive($publicPem, $privatePem, 256); // @phpstan-ignore-line
|
||||
if ($result === false) {
|
||||
throw new \Exception('Unable to compute the agreement key');
|
||||
}
|
||||
return $result;
|
||||
} catch (\Throwable $throwable) {
|
||||
//Does nothing. Will fallback to the pure PHP function
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$curve = NistCurve::curve256();
|
||||
try {
|
||||
$rec_x = self::convertBase64ToBigInteger($public_key->get('x'));
|
||||
$rec_y = self::convertBase64ToBigInteger($public_key->get('y'));
|
||||
$sen_d = self::convertBase64ToBigInteger($private_key->get('d'));
|
||||
$priv_key = PrivateKey::create($sen_d);
|
||||
$pub_key = $curve->getPublicKeyFrom($rec_x, $rec_y);
|
||||
|
||||
return hex2bin(str_pad($curve->mul($pub_key->getPoint(), $priv_key->getSecret())->getX()->toBase(16), 64, '0', STR_PAD_LEFT)); // @phpstan-ignore-line
|
||||
} catch (\Throwable $e) {
|
||||
$rec_x = self::convertBase64ToGMP($public_key->get('x'));
|
||||
$rec_y = self::convertBase64ToGMP($public_key->get('y'));
|
||||
$sen_d = self::convertBase64ToGMP($private_key->get('d'));
|
||||
$priv_key = PrivateKey::create($sen_d); // @phpstan-ignore-line
|
||||
$pub_key = $curve->getPublicKeyFrom($rec_x, $rec_y); // @phpstan-ignore-line
|
||||
|
||||
return hex2bin(gmp_strval($curve->mul($pub_key->getPoint(), $priv_key->getSecret())->getX(), 16)); // @phpstan-ignore-line
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $value
|
||||
* @return BigInteger
|
||||
*/
|
||||
private static function convertBase64ToBigInteger(string $value): BigInteger
|
||||
{
|
||||
$value = unpack('H*', Base64Url::decode($value));
|
||||
|
||||
return BigInteger::fromBase($value[1], 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $value
|
||||
* @return \GMP
|
||||
*/
|
||||
private static function convertBase64ToGMP(string $value): \GMP
|
||||
{
|
||||
$value = unpack('H*', Base64Url::decode($value));
|
||||
|
||||
return gmp_init($value[1], 16);
|
||||
}
|
||||
|
||||
private static function addNullPadding(string $data): string
|
||||
{
|
||||
return str_pad($data, 32, chr(0), STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
/**
|
||||
* @author Igor Timoshenkov [it@campoint.net]
|
||||
* @started: 03.09.2018 9:21
|
||||
*/
|
||||
|
||||
namespace Minishlink\WebPush;
|
||||
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Standardized response from sending a message
|
||||
*/
|
||||
class MessageSentReport implements \JsonSerializable
|
||||
{
|
||||
|
||||
/**
|
||||
* @var boolean
|
||||
*/
|
||||
protected $success;
|
||||
|
||||
/**
|
||||
* @var RequestInterface
|
||||
*/
|
||||
protected $request;
|
||||
|
||||
/**
|
||||
* @var ResponseInterface | null
|
||||
*/
|
||||
protected $response;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $reason;
|
||||
|
||||
/**
|
||||
* @param RequestInterface $request
|
||||
* @param ResponseInterface $response
|
||||
* @param bool $success
|
||||
* @param string $reason
|
||||
*/
|
||||
public function __construct(RequestInterface $request, ?ResponseInterface $response = null, bool $success = true, $reason = 'OK')
|
||||
{
|
||||
$this->request = $request;
|
||||
$this->response = $response;
|
||||
$this->success = $success;
|
||||
$this->reason = $reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->success;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $success
|
||||
*
|
||||
* @return MessageSentReport
|
||||
*/
|
||||
public function setSuccess(bool $success): MessageSentReport
|
||||
{
|
||||
$this->success = $success;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return RequestInterface
|
||||
*/
|
||||
public function getRequest(): RequestInterface
|
||||
{
|
||||
return $this->request;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param RequestInterface $request
|
||||
*
|
||||
* @return MessageSentReport
|
||||
*/
|
||||
public function setRequest(RequestInterface $request): MessageSentReport
|
||||
{
|
||||
$this->request = $request;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ResponseInterface | null
|
||||
*/
|
||||
public function getResponse(): ?ResponseInterface
|
||||
{
|
||||
return $this->response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ResponseInterface $response
|
||||
*
|
||||
* @return MessageSentReport
|
||||
*/
|
||||
public function setResponse(ResponseInterface $response): MessageSentReport
|
||||
{
|
||||
$this->response = $response;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getEndpoint(): string
|
||||
{
|
||||
return $this->request->getUri()->__toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function isSubscriptionExpired(): bool
|
||||
{
|
||||
if (!$this->response) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \in_array($this->response->getStatusCode(), [404, 410], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getReason(): string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $reason
|
||||
*
|
||||
* @return MessageSentReport
|
||||
*/
|
||||
public function setReason(string $reason): MessageSentReport
|
||||
{
|
||||
$this->reason = $reason;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getRequestPayload(): string
|
||||
{
|
||||
return $this->request->getBody()->getContents();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string | null
|
||||
*/
|
||||
public function getResponseContent(): ?string
|
||||
{
|
||||
if (!$this->response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->response->getBody()->getContents();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|mixed
|
||||
*/
|
||||
public function jsonSerialize()
|
||||
{
|
||||
return [
|
||||
'success' => $this->isSuccess(),
|
||||
'expired' => $this->isSubscriptionExpired(),
|
||||
'reason' => $this->reason,
|
||||
'endpoint' => $this->getEndpoint(),
|
||||
'payload' => $this->request->getBody()->getContents(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the WebPush library.
|
||||
*
|
||||
* (c) Louis Lagrange <lagrange.louis@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Minishlink\WebPush;
|
||||
|
||||
class Notification
|
||||
{
|
||||
/** @var SubscriptionInterface */
|
||||
private $subscription;
|
||||
|
||||
/** @var null|string */
|
||||
private $payload;
|
||||
|
||||
/** @var array Options : TTL, urgency, topic */
|
||||
private $options;
|
||||
|
||||
/** @var array Auth details : VAPID */
|
||||
private $auth;
|
||||
|
||||
/**
|
||||
* Notification constructor.
|
||||
*
|
||||
* @param SubscriptionInterface $subscription
|
||||
* @param null|string $payload
|
||||
* @param array $options
|
||||
* @param array $auth
|
||||
*/
|
||||
public function __construct(SubscriptionInterface $subscription, ?string $payload, array $options, array $auth)
|
||||
{
|
||||
$this->subscription = $subscription;
|
||||
$this->payload = $payload;
|
||||
$this->options = $options;
|
||||
$this->auth = $auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SubscriptionInterface
|
||||
*/
|
||||
public function getSubscription(): SubscriptionInterface
|
||||
{
|
||||
return $this->subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|string
|
||||
*/
|
||||
public function getPayload(): ?string
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $defaultOptions
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getOptions(array $defaultOptions = []): array
|
||||
{
|
||||
$options = $this->options;
|
||||
$options['TTL'] = array_key_exists('TTL', $options) ? $options['TTL'] : $defaultOptions['TTL'];
|
||||
$options['urgency'] = array_key_exists('urgency', $options) ? $options['urgency'] : $defaultOptions['urgency'];
|
||||
$options['topic'] = array_key_exists('topic', $options) ? $options['topic'] : $defaultOptions['topic'];
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $defaultAuth
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getAuth(array $defaultAuth): array
|
||||
{
|
||||
return count($this->auth) > 0 ? $this->auth : $defaultAuth;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the WebPush library.
|
||||
*
|
||||
* (c) Louis Lagrange <lagrange.louis@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Minishlink\WebPush;
|
||||
|
||||
class Subscription implements SubscriptionInterface
|
||||
{
|
||||
/** @var string */
|
||||
private $endpoint;
|
||||
|
||||
/** @var null|string */
|
||||
private $publicKey;
|
||||
|
||||
/** @var null|string */
|
||||
private $authToken;
|
||||
|
||||
/** @var null|string */
|
||||
private $contentEncoding;
|
||||
|
||||
/**
|
||||
* Subscription constructor.
|
||||
*
|
||||
* @param string $endpoint
|
||||
* @param null|string $publicKey
|
||||
* @param null|string $authToken
|
||||
* @param string $contentEncoding (Optional) Must be "aesgcm"
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public function __construct(
|
||||
string $endpoint,
|
||||
?string $publicKey = null,
|
||||
?string $authToken = null,
|
||||
?string $contentEncoding = null
|
||||
) {
|
||||
$this->endpoint = $endpoint;
|
||||
|
||||
if ($publicKey || $authToken || $contentEncoding) {
|
||||
$supportedContentEncodings = ['aesgcm', 'aes128gcm'];
|
||||
if ($contentEncoding && !in_array($contentEncoding, $supportedContentEncodings)) {
|
||||
throw new \ErrorException('This content encoding ('.$contentEncoding.') is not supported.');
|
||||
}
|
||||
|
||||
$this->publicKey = $publicKey;
|
||||
$this->authToken = $authToken;
|
||||
$this->contentEncoding = $contentEncoding ?: "aesgcm";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription factory.
|
||||
*
|
||||
* @param array $associativeArray (with keys endpoint, publicKey, authToken, contentEncoding)
|
||||
* @return self
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public static function create(array $associativeArray): self
|
||||
{
|
||||
if (array_key_exists('keys', $associativeArray) && is_array($associativeArray['keys'])) {
|
||||
return new self(
|
||||
$associativeArray['endpoint'],
|
||||
$associativeArray['keys']['p256dh'] ?? null,
|
||||
$associativeArray['keys']['auth'] ?? null,
|
||||
$associativeArray['contentEncoding'] ?? "aesgcm"
|
||||
);
|
||||
}
|
||||
|
||||
if (array_key_exists('publicKey', $associativeArray) || array_key_exists('authToken', $associativeArray) || array_key_exists('contentEncoding', $associativeArray)) {
|
||||
return new self(
|
||||
$associativeArray['endpoint'],
|
||||
$associativeArray['publicKey'] ?? null,
|
||||
$associativeArray['authToken'] ?? null,
|
||||
$associativeArray['contentEncoding'] ?? "aesgcm"
|
||||
);
|
||||
}
|
||||
|
||||
return new self(
|
||||
$associativeArray['endpoint']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getEndpoint(): string
|
||||
{
|
||||
return $this->endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getPublicKey(): ?string
|
||||
{
|
||||
return $this->publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getAuthToken(): ?string
|
||||
{
|
||||
return $this->authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getContentEncoding(): ?string
|
||||
{
|
||||
return $this->contentEncoding;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the WebPush library.
|
||||
*
|
||||
* (c) Louis Lagrange <lagrange.louis@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Minishlink\WebPush;
|
||||
|
||||
/**
|
||||
* @author Sergii Bondarenko <sb@firstvector.org>
|
||||
*/
|
||||
interface SubscriptionInterface
|
||||
{
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getEndpoint(): string;
|
||||
|
||||
/**
|
||||
* @return null|string
|
||||
*/
|
||||
public function getPublicKey(): ?string;
|
||||
|
||||
/**
|
||||
* @return null|string
|
||||
*/
|
||||
public function getAuthToken(): ?string;
|
||||
|
||||
/**
|
||||
* @return null|string
|
||||
*/
|
||||
public function getContentEncoding(): ?string;
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the WebPush library.
|
||||
*
|
||||
* (c) Louis Lagrange <lagrange.louis@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Minishlink\WebPush;
|
||||
|
||||
use Base64Url\Base64Url;
|
||||
use Brick\Math\BigInteger;
|
||||
use Jose\Component\Core\JWK;
|
||||
use Jose\Component\Core\Util\Ecc\PublicKey;
|
||||
|
||||
class Utils
|
||||
{
|
||||
/**
|
||||
* @param string $value
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function safeStrlen(string $value): int
|
||||
{
|
||||
return mb_strlen($value, '8bit');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PublicKey $publicKey
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function serializePublicKey(PublicKey $publicKey): string
|
||||
{
|
||||
$hexString = '04';
|
||||
$point = $publicKey->getPoint();
|
||||
if ($point->getX() instanceof BigInteger) {
|
||||
$hexString .= str_pad($point->getX()->toBase(16), 64, '0', STR_PAD_LEFT);
|
||||
$hexString .= str_pad($point->getY()->toBase(16), 64, '0', STR_PAD_LEFT);
|
||||
} else { // @phpstan-ignore-line
|
||||
$hexString .= str_pad(gmp_strval($point->getX(), 16), 64, '0', STR_PAD_LEFT);
|
||||
$hexString .= str_pad(gmp_strval($point->getY(), 16), 64, '0', STR_PAD_LEFT); // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
return $hexString;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param JWK $jwk
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function serializePublicKeyFromJWK(JWK $jwk): string
|
||||
{
|
||||
$hexString = '04';
|
||||
$hexString .= str_pad(bin2hex(Base64Url::decode($jwk->get('x'))), 64, '0', STR_PAD_LEFT);
|
||||
$hexString .= str_pad(bin2hex(Base64Url::decode($jwk->get('y'))), 64, '0', STR_PAD_LEFT);
|
||||
|
||||
return $hexString;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $data
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function unserializePublicKey(string $data): array
|
||||
{
|
||||
$data = bin2hex($data);
|
||||
if (mb_substr($data, 0, 2, '8bit') !== '04') {
|
||||
throw new \InvalidArgumentException('Invalid data: only uncompressed keys are supported.');
|
||||
}
|
||||
$data = mb_substr($data, 2, null, '8bit');
|
||||
$dataLength = self::safeStrlen($data);
|
||||
|
||||
return [
|
||||
hex2bin(mb_substr($data, 0, $dataLength / 2, '8bit')),
|
||||
hex2bin(mb_substr($data, $dataLength / 2, null, '8bit')),
|
||||
];
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the WebPush library.
|
||||
*
|
||||
* (c) Louis Lagrange <lagrange.louis@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Minishlink\WebPush;
|
||||
|
||||
use Base64Url\Base64Url;
|
||||
use Jose\Component\Core\AlgorithmManager;
|
||||
use Jose\Component\Core\JWK;
|
||||
use Jose\Component\KeyManagement\JWKFactory;
|
||||
use Jose\Component\Signature\Algorithm\ES256;
|
||||
use Jose\Component\Signature\JWSBuilder;
|
||||
use Jose\Component\Signature\Serializer\CompactSerializer;
|
||||
|
||||
class VAPID
|
||||
{
|
||||
private const PUBLIC_KEY_LENGTH = 65;
|
||||
private const PRIVATE_KEY_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* @param array $vapid
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public static function validate(array $vapid): array
|
||||
{
|
||||
if (!isset($vapid['subject'])) {
|
||||
throw new \ErrorException('[VAPID] You must provide a subject that is either a mailto: or a URL.');
|
||||
}
|
||||
|
||||
if (isset($vapid['pemFile'])) {
|
||||
$vapid['pem'] = file_get_contents($vapid['pemFile']);
|
||||
|
||||
if (!$vapid['pem']) {
|
||||
throw new \ErrorException('Error loading PEM file.');
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($vapid['pem'])) {
|
||||
$jwk = JWKFactory::createFromKey($vapid['pem']);
|
||||
if ($jwk->get('kty') !== 'EC' || !$jwk->has('d') || !$jwk->has('x') || !$jwk->has('y')) {
|
||||
throw new \ErrorException('Invalid PEM data.');
|
||||
}
|
||||
|
||||
$binaryPublicKey = hex2bin(Utils::serializePublicKeyFromJWK($jwk));
|
||||
if (!$binaryPublicKey) {
|
||||
throw new \ErrorException('Failed to convert VAPID public key from hexadecimal to binary');
|
||||
}
|
||||
$vapid['publicKey'] = base64_encode($binaryPublicKey);
|
||||
$vapid['privateKey'] = base64_encode(str_pad(Base64Url::decode($jwk->get('d')), 2 * self::PRIVATE_KEY_LENGTH, '0', STR_PAD_LEFT));
|
||||
}
|
||||
|
||||
if (!isset($vapid['publicKey'])) {
|
||||
throw new \ErrorException('[VAPID] You must provide a public key.');
|
||||
}
|
||||
|
||||
$publicKey = Base64Url::decode($vapid['publicKey']);
|
||||
|
||||
if (Utils::safeStrlen($publicKey) !== self::PUBLIC_KEY_LENGTH) {
|
||||
var_dump( $publicKey ); exit;
|
||||
throw new \ErrorException('[VAPID] Public key should be 65 bytes long when decoded.');
|
||||
}
|
||||
|
||||
if (!isset($vapid['privateKey'])) {
|
||||
throw new \ErrorException('[VAPID] You must provide a private key.');
|
||||
}
|
||||
|
||||
$privateKey = Base64Url::decode($vapid['privateKey']);
|
||||
|
||||
if (Utils::safeStrlen($privateKey) !== self::PRIVATE_KEY_LENGTH) {
|
||||
throw new \ErrorException('[VAPID] Private key should be 32 bytes long when decoded.');
|
||||
}
|
||||
|
||||
return [
|
||||
'subject' => $vapid['subject'],
|
||||
'publicKey' => $publicKey,
|
||||
'privateKey' => $privateKey,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* This method takes the required VAPID parameters and returns the required
|
||||
* header to be added to a Web Push Protocol Request.
|
||||
*
|
||||
* @param string $audience This must be the origin of the push service
|
||||
* @param string $subject This should be a URL or a 'mailto:' email address
|
||||
* @param string $publicKey The decoded VAPID public key
|
||||
* @param string $privateKey The decoded VAPID private key
|
||||
* @param string $contentEncoding
|
||||
* @param null|int $expiration The expiration of the VAPID JWT. (UNIX timestamp)
|
||||
*
|
||||
* @return array Returns an array with the 'Authorization' and 'Crypto-Key' values to be used as headers
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public static function getVapidHeaders(string $audience, string $subject, string $publicKey, string $privateKey, string $contentEncoding, ?int $expiration = null)
|
||||
{
|
||||
$expirationLimit = time() + 43200; // equal margin of error between 0 and 24h
|
||||
if (null === $expiration || $expiration > $expirationLimit) {
|
||||
$expiration = $expirationLimit;
|
||||
}
|
||||
|
||||
$header = [
|
||||
'typ' => 'JWT',
|
||||
'alg' => 'ES256',
|
||||
];
|
||||
|
||||
$jwtPayload = json_encode([
|
||||
'aud' => $audience,
|
||||
'exp' => $expiration,
|
||||
'sub' => $subject,
|
||||
], JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK);
|
||||
if (!$jwtPayload) {
|
||||
throw new \ErrorException('Failed to encode JWT payload in JSON');
|
||||
}
|
||||
|
||||
list($x, $y) = Utils::unserializePublicKey($publicKey);
|
||||
|
||||
$jwk = new JWK([
|
||||
'kty' => 'EC',
|
||||
'crv' => 'P-256',
|
||||
'x' => Base64Url::encode($x),
|
||||
'y' => Base64Url::encode($y),
|
||||
'd' => Base64Url::encode($privateKey),
|
||||
]);
|
||||
|
||||
$jwsCompactSerializer = new CompactSerializer();
|
||||
$jwsBuilder = new JWSBuilder(new AlgorithmManager([new ES256()]));
|
||||
$jws = $jwsBuilder
|
||||
->create()
|
||||
->withPayload($jwtPayload)
|
||||
->addSignature($jwk, $header)
|
||||
->build();
|
||||
|
||||
$jwt = $jwsCompactSerializer->serialize($jws, 0);
|
||||
$encodedPublicKey = Base64Url::encode($publicKey);
|
||||
|
||||
if ($contentEncoding === "aesgcm") {
|
||||
return [
|
||||
'Authorization' => 'WebPush '.$jwt,
|
||||
'Crypto-Key' => 'p256ecdsa='.$encodedPublicKey,
|
||||
];
|
||||
} elseif ($contentEncoding === 'aes128gcm') {
|
||||
return [
|
||||
'Authorization' => 'vapid t='.$jwt.', k='.$encodedPublicKey,
|
||||
];
|
||||
}
|
||||
|
||||
throw new \ErrorException('This content encoding is not supported');
|
||||
}
|
||||
|
||||
/**
|
||||
* This method creates VAPID keys in case you would not be able to have a Linux bash.
|
||||
* DO NOT create keys at each initialization! Save those keys and reuse them.
|
||||
*
|
||||
* @return array
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public static function createVapidKeys(): array
|
||||
{
|
||||
$jwk = JWKFactory::createECKey('P-256');
|
||||
|
||||
$binaryPublicKey = hex2bin(Utils::serializePublicKeyFromJWK($jwk));
|
||||
if (!$binaryPublicKey) {
|
||||
throw new \ErrorException('Failed to convert VAPID public key from hexadecimal to binary');
|
||||
}
|
||||
|
||||
$binaryPrivateKey = hex2bin(str_pad(bin2hex(Base64Url::decode($jwk->get('d'))), 2 * self::PRIVATE_KEY_LENGTH, '0', STR_PAD_LEFT));
|
||||
if (!$binaryPrivateKey) {
|
||||
throw new \ErrorException('Failed to convert VAPID private key from hexadecimal to binary');
|
||||
}
|
||||
|
||||
return [
|
||||
'publicKey' => Base64Url::encode($binaryPublicKey),
|
||||
'privateKey' => Base64Url::encode($binaryPrivateKey)
|
||||
];
|
||||
}
|
||||
}
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the WebPush library.
|
||||
*
|
||||
* (c) Louis Lagrange <lagrange.louis@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Minishlink\WebPush;
|
||||
|
||||
use Base64Url\Base64Url;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class WebPush
|
||||
{
|
||||
/**
|
||||
* @var Client
|
||||
*/
|
||||
private $client;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private $auth;
|
||||
|
||||
/**
|
||||
* @var null|array Array of array of Notifications
|
||||
*/
|
||||
private $notifications;
|
||||
|
||||
/**
|
||||
* @var array Default options : TTL, urgency, topic, batchSize
|
||||
*/
|
||||
private $defaultOptions;
|
||||
|
||||
/**
|
||||
* @var int Automatic padding of payloads, if disabled, trade security for bandwidth
|
||||
*/
|
||||
private $automaticPadding = Encryption::MAX_COMPATIBILITY_PAYLOAD_LENGTH;
|
||||
|
||||
/**
|
||||
* @var bool Reuse VAPID headers in the same flush session to improve performance
|
||||
*/
|
||||
private $reuseVAPIDHeaders = false;
|
||||
|
||||
/**
|
||||
* @var array Dictionary for VAPID headers cache
|
||||
*/
|
||||
private $vapidHeaders = [];
|
||||
|
||||
/**
|
||||
* WebPush constructor.
|
||||
*
|
||||
* @param array $auth Some servers needs authentication
|
||||
* @param array $defaultOptions TTL, urgency, topic, batchSize
|
||||
* @param int|null $timeout Timeout of POST request
|
||||
* @param array $clientOptions
|
||||
*
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public function __construct(array $auth = [], array $defaultOptions = [], ?int $timeout = 30, array $clientOptions = [])
|
||||
{
|
||||
$extensions = [
|
||||
'curl' => '[WebPush] curl extension is not loaded but is required. You can fix this in your php.ini.',
|
||||
'gmp' => '[WebPush] gmp extension is not loaded but is required for sending push notifications with payload or for VAPID authentication. You can fix this in your php.ini.',
|
||||
'mbstring' => '[WebPush] mbstring extension is not loaded but is required for sending push notifications with payload or for VAPID authentication. You can fix this in your php.ini.',
|
||||
'openssl' => '[WebPush] openssl extension is not loaded but is required for sending push notifications with payload or for VAPID authentication. You can fix this in your php.ini.',
|
||||
];
|
||||
foreach ($extensions as $extension => $message) {
|
||||
if (!extension_loaded($extension)) {
|
||||
trigger_error($message, E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
|
||||
if (ini_get('mbstring.func_overload') >= 2) {
|
||||
trigger_error("[WebPush] mbstring.func_overload is enabled for str* functions. You must disable it if you want to send push notifications with payload or use VAPID. You can fix this in your php.ini.", E_USER_NOTICE);
|
||||
}
|
||||
|
||||
if (isset($auth['VAPID'])) {
|
||||
$auth['VAPID'] = VAPID::validate($auth['VAPID']);
|
||||
}
|
||||
|
||||
$this->auth = $auth;
|
||||
|
||||
$this->setDefaultOptions($defaultOptions);
|
||||
|
||||
if (!array_key_exists('timeout', $clientOptions) && isset($timeout)) {
|
||||
$clientOptions['timeout'] = $timeout;
|
||||
}
|
||||
$this->client = new Client($clientOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a notification. Will be sent when flush() is called.
|
||||
*
|
||||
* @param SubscriptionInterface $subscription
|
||||
* @param string|null $payload If you want to send an array or object, json_encode it
|
||||
* @param array $options Array with several options tied to this notification. If not set, will use the default options that you can set in the WebPush object
|
||||
* @param array $auth Use this auth details instead of what you provided when creating WebPush
|
||||
*
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public function queueNotification(SubscriptionInterface $subscription, ?string $payload = null, array $options = [], array $auth = []): void
|
||||
{
|
||||
if (isset($payload)) {
|
||||
if (Utils::safeStrlen($payload) > Encryption::MAX_PAYLOAD_LENGTH) {
|
||||
throw new \ErrorException('Size of payload must not be greater than '.Encryption::MAX_PAYLOAD_LENGTH.' octets.');
|
||||
}
|
||||
|
||||
$contentEncoding = $subscription->getContentEncoding();
|
||||
if (!$contentEncoding) {
|
||||
throw new \ErrorException('Subscription should have a content encoding');
|
||||
}
|
||||
|
||||
$payload = Encryption::padPayload($payload, $this->automaticPadding, $contentEncoding);
|
||||
}
|
||||
|
||||
if (array_key_exists('VAPID', $auth)) {
|
||||
$auth['VAPID'] = VAPID::validate($auth['VAPID']);
|
||||
}
|
||||
|
||||
$this->notifications[] = new Notification($subscription, $payload, $options, $auth);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SubscriptionInterface $subscription
|
||||
* @param string|null $payload If you want to send an array or object, json_encode it
|
||||
* @param array $options Array with several options tied to this notification. If not set, will use the default options that you can set in the WebPush object
|
||||
* @param array $auth Use this auth details instead of what you provided when creating WebPush
|
||||
* @return MessageSentReport
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public function sendOneNotification(SubscriptionInterface $subscription, ?string $payload = null, array $options = [], array $auth = []): MessageSentReport
|
||||
{
|
||||
$this->queueNotification($subscription, $payload, $options, $auth);
|
||||
return $this->flush()->current();
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush notifications. Triggers the requests.
|
||||
*
|
||||
* @param null|int $batchSize Defaults the value defined in defaultOptions during instantiation (which defaults to 1000).
|
||||
*
|
||||
* @return \Generator|MessageSentReport[]
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public function flush(?int $batchSize = null): \Generator
|
||||
{
|
||||
if (null === $this->notifications || empty($this->notifications)) {
|
||||
yield from [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (null === $batchSize) {
|
||||
$batchSize = $this->defaultOptions['batchSize'];
|
||||
}
|
||||
|
||||
$batches = array_chunk($this->notifications, $batchSize);
|
||||
|
||||
// reset queue
|
||||
$this->notifications = [];
|
||||
|
||||
foreach ($batches as $batch) {
|
||||
// for each endpoint server type
|
||||
$requests = $this->prepare($batch);
|
||||
|
||||
$promises = [];
|
||||
|
||||
foreach ($requests as $request) {
|
||||
$promises[] = $this->client->sendAsync($request)
|
||||
->then(function ($response) use ($request) {
|
||||
/** @var ResponseInterface $response * */
|
||||
return new MessageSentReport($request, $response);
|
||||
})
|
||||
->otherwise(function ($reason) {
|
||||
/** @var RequestException $reason **/
|
||||
return new MessageSentReport($reason->getRequest(), $reason->getResponse(), false, $reason->getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
foreach ($promises as $promise) {
|
||||
yield $promise->wait();
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->reuseVAPIDHeaders) {
|
||||
$this->vapidHeaders = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $notifications
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
private function prepare(array $notifications): array
|
||||
{
|
||||
$requests = [];
|
||||
foreach ($notifications as $notification) {
|
||||
\assert($notification instanceof Notification);
|
||||
$subscription = $notification->getSubscription();
|
||||
$endpoint = $subscription->getEndpoint();
|
||||
$userPublicKey = $subscription->getPublicKey();
|
||||
$userAuthToken = $subscription->getAuthToken();
|
||||
$contentEncoding = $subscription->getContentEncoding();
|
||||
$payload = $notification->getPayload();
|
||||
$options = $notification->getOptions($this->getDefaultOptions());
|
||||
$auth = $notification->getAuth($this->auth);
|
||||
|
||||
if (!empty($payload) && !empty($userPublicKey) && !empty($userAuthToken)) {
|
||||
if (!$contentEncoding) {
|
||||
throw new \ErrorException('Subscription should have a content encoding');
|
||||
}
|
||||
|
||||
$encrypted = Encryption::encrypt($payload, $userPublicKey, $userAuthToken, $contentEncoding);
|
||||
$cipherText = $encrypted['cipherText'];
|
||||
$salt = $encrypted['salt'];
|
||||
$localPublicKey = $encrypted['localPublicKey'];
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Content-Encoding' => $contentEncoding,
|
||||
];
|
||||
|
||||
if ($contentEncoding === "aesgcm") {
|
||||
$headers['Encryption'] = 'salt='.Base64Url::encode($salt);
|
||||
$headers['Crypto-Key'] = 'dh='.Base64Url::encode($localPublicKey);
|
||||
}
|
||||
|
||||
$encryptionContentCodingHeader = Encryption::getContentCodingHeader($salt, $localPublicKey, $contentEncoding);
|
||||
$content = $encryptionContentCodingHeader.$cipherText;
|
||||
|
||||
$headers['Content-Length'] = Utils::safeStrlen($content);
|
||||
} else {
|
||||
$headers = [
|
||||
'Content-Length' => 0,
|
||||
];
|
||||
|
||||
$content = '';
|
||||
}
|
||||
|
||||
$headers['TTL'] = $options['TTL'];
|
||||
|
||||
if (isset($options['urgency'])) {
|
||||
$headers['Urgency'] = $options['urgency'];
|
||||
}
|
||||
|
||||
if (isset($options['topic'])) {
|
||||
$headers['Topic'] = $options['topic'];
|
||||
}
|
||||
|
||||
if (array_key_exists('VAPID', $auth) && $contentEncoding) {
|
||||
$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 = $this->getVAPIDHeaders($audience, $contentEncoding, $auth['VAPID']);
|
||||
|
||||
$headers['Authorization'] = $vapidHeaders['Authorization'];
|
||||
|
||||
if ($contentEncoding === 'aesgcm') {
|
||||
if (array_key_exists('Crypto-Key', $headers)) {
|
||||
$headers['Crypto-Key'] .= ';'.$vapidHeaders['Crypto-Key'];
|
||||
} else {
|
||||
$headers['Crypto-Key'] = $vapidHeaders['Crypto-Key'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$requests[] = new Request('POST', $endpoint, $headers, $content);
|
||||
}
|
||||
|
||||
return $requests;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function isAutomaticPadding(): bool
|
||||
{
|
||||
return $this->automaticPadding !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getAutomaticPadding()
|
||||
{
|
||||
return $this->automaticPadding;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|bool $automaticPadding Max padding length
|
||||
*
|
||||
* @return WebPush
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function setAutomaticPadding($automaticPadding): WebPush
|
||||
{
|
||||
if ($automaticPadding > 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 ($automaticPadding < 0) {
|
||||
throw new \Exception('Padding length should be positive or zero.');
|
||||
} elseif ($automaticPadding === true) {
|
||||
$this->automaticPadding = Encryption::MAX_COMPATIBILITY_PAYLOAD_LENGTH;
|
||||
} elseif ($automaticPadding === false) {
|
||||
$this->automaticPadding = 0;
|
||||
} else {
|
||||
$this->automaticPadding = $automaticPadding;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function getReuseVAPIDHeaders()
|
||||
{
|
||||
return $this->reuseVAPIDHeaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuse VAPID headers in the same flush session to improve performance
|
||||
* @param bool $enabled
|
||||
*
|
||||
* @return WebPush
|
||||
*/
|
||||
public function setReuseVAPIDHeaders(bool $enabled)
|
||||
{
|
||||
$this->reuseVAPIDHeaders = $enabled;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function getDefaultOptions(): array
|
||||
{
|
||||
return $this->defaultOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $defaultOptions Keys 'TTL' (Time To Live, defaults 4 weeks), 'urgency', 'topic', 'batchSize'
|
||||
*
|
||||
* @return WebPush
|
||||
*/
|
||||
public function setDefaultOptions(array $defaultOptions)
|
||||
{
|
||||
$this->defaultOptions['TTL'] = isset($defaultOptions['TTL']) ? $defaultOptions['TTL'] : 2419200;
|
||||
$this->defaultOptions['urgency'] = isset($defaultOptions['urgency']) ? $defaultOptions['urgency'] : null;
|
||||
$this->defaultOptions['topic'] = isset($defaultOptions['topic']) ? $defaultOptions['topic'] : null;
|
||||
$this->defaultOptions['batchSize'] = isset($defaultOptions['batchSize']) ? $defaultOptions['batchSize'] : 1000;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function countPendingNotifications(): int
|
||||
{
|
||||
return null !== $this->notifications ? count($this->notifications) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $audience
|
||||
* @param string $contentEncoding
|
||||
* @param array $vapid
|
||||
* @return array
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
private function getVAPIDHeaders(string $audience, string $contentEncoding, array $vapid)
|
||||
{
|
||||
$vapidHeaders = null;
|
||||
|
||||
$cache_key = null;
|
||||
if ($this->reuseVAPIDHeaders) {
|
||||
$cache_key = implode('#', [$audience, $contentEncoding, crc32(serialize($vapid))]);
|
||||
if (array_key_exists($cache_key, $this->vapidHeaders)) {
|
||||
$vapidHeaders = $this->vapidHeaders[$cache_key];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$vapidHeaders) {
|
||||
$vapidHeaders = VAPID::getVapidHeaders($audience, $vapid['subject'], $vapid['publicKey'], $vapid['privateKey'], $contentEncoding);
|
||||
}
|
||||
|
||||
if ($this->reuseVAPIDHeaders) {
|
||||
$this->vapidHeaders[$cache_key] = $vapidHeaders;
|
||||
}
|
||||
|
||||
return $vapidHeaders;
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "minishlink/web-push",
|
||||
"type": "library",
|
||||
"description": "Web Push library for PHP",
|
||||
"keywords": ["push", "notifications", "web", "WebPush", "Push API"],
|
||||
"homepage": "https://github.com/web-push-libs/web-push-php",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Louis Lagrange",
|
||||
"email": "lagrange.louis@gmail.com",
|
||||
"homepage": "https://github.com/Minishlink"
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"test:unit": "./vendor/bin/phpunit --color",
|
||||
"test:typing": "./vendor/bin/phpstan analyse",
|
||||
"test:syntax": "./vendor/bin/php-cs-fixer fix ./src --dry-run --stop-on-violation --using-cache=no"
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2",
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-openssl": "*",
|
||||
"guzzlehttp/guzzle": "^7.0.1|^6.2",
|
||||
"web-token/jwt-signature": "^2.0",
|
||||
"web-token/jwt-key-mgmt": "^2.0",
|
||||
"web-token/jwt-signature-algorithm-ecdsa": "^2.0",
|
||||
"web-token/jwt-util-ecc": "^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^8.0|^9.0",
|
||||
"phpstan/phpstan": "^0.11|^0.12",
|
||||
"friendsofphp/php-cs-fixer": "^2.14"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4" : {
|
||||
"Minishlink\\WebPush\\" : "src"
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
parameters:
|
||||
level: 7
|
||||
paths:
|
||||
- src
|
||||
checkMissingIterableValueType: false
|
||||
ignoreErrors:
|
||||
- '#Unreachable statement \- code above always terminates\.#'
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit backupGlobals="false"
|
||||
backupStaticAttributes="false"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
convertErrorsToExceptions="true"
|
||||
convertNoticesToExceptions="true"
|
||||
convertWarningsToExceptions="true"
|
||||
processIsolation="false"
|
||||
stopOnFailure="false">
|
||||
<testsuites>
|
||||
<testsuite name="WebPush Test Suite">
|
||||
<directory suffix=".php">./tests/</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
Reference in new issue
Block a user