├── .github
└── workflows
│ └── codecov.yml
├── .gitignore
├── LICENSE
├── MyLogger.php
├── MyVapid.php.org
├── PNClient.js
├── PNClientEx.js
├── PNSendWelcome.php
├── PNServiceWorker.js
├── PNServiceWorkerEx.js
├── PNSubscriber.php
├── PNTestClient.html
├── PNTestClientEx.html
├── PNTestPushSingle.php
├── PNTestServer.php
├── Psr
└── Log
│ ├── AbstractLogger.php
│ ├── InvalidArgumentException.php
│ ├── LogLevel.php
│ ├── LoggerAwareInterface.php
│ ├── LoggerAwareTrait.php
│ ├── LoggerInterface.php
│ ├── LoggerTrait.php
│ └── NullLogger.php
├── SKien
├── PNServer
│ ├── PNDataProvider.php
│ ├── PNDataProviderMySQL.php
│ ├── PNDataProviderSQLite.php
│ ├── PNEncryption.php
│ ├── PNPayload.php
│ ├── PNServer.php
│ ├── PNServerHelper.php
│ ├── PNSubscription.php
│ ├── PNVapid.php
│ └── Utils
│ │ ├── Curve.php
│ │ ├── Math.php
│ │ ├── NistCurve.php
│ │ └── Point.php
└── Test
│ └── PNServer
│ ├── PNDataProviderMySQLTest.php
│ ├── PNDataProviderSQLiteTest.php
│ ├── PNDataProviderTest.php
│ ├── PNEncryptionTest.php
│ ├── PNPayloadTest.php
│ ├── PNServerTest.php
│ ├── PNSubscriptionTest.php
│ ├── PNVapidTest.php
│ ├── TestHelperTrait.php
│ ├── UtilsCurveTest.php
│ ├── UtilsMathTest.php
│ ├── UtilsPointTest.php
│ └── testdata
│ ├── expired_subscription.json
│ ├── gone_subscription.json
│ ├── inv_endpoint_subscription.json
│ ├── invalid_subscription.json
│ ├── notfound_subscription.json
│ └── valid_subscription.json
├── autoloader.php
├── clover.xml
├── elephpant.png
├── phpstan.neon
├── phpunit.xml.org
└── readme.md
/.github/workflows/codecov.yml:
--------------------------------------------------------------------------------
1 | name: Codecov
2 | on: [push]
3 | jobs:
4 | run:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - uses: actions/checkout@master
8 | - name: Upload coverage to Codecov
9 | uses: codecov/codecov-action@v2
10 | with:
11 | files: ./clover.xml,./coverage.xml
12 | flags: unittests
13 | name: codecov-umbrella
14 | verbose: true
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.settings/
2 | /wiki/
3 | /.buildpath
4 | /.project
5 | /*.sqlite
6 | /myVAPID.txt
7 | /MyVapid.php
8 | /MyTest.php
9 | /phpunit.xml
10 | /SKien/Test/PNServer/coverage/
11 | /SKien/Test/PNServer/tempdata/
12 | /SKien/XLogger/
13 | /CCampbell/
14 | /FirePHP/
15 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Stefan Kientzler
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 |
--------------------------------------------------------------------------------
/MyLogger.php:
--------------------------------------------------------------------------------
1 | sometimes brwoser have to
41 | * be restarted to update service worker
42 | */
43 | async function pnUpdate() {
44 | var swReg = null;
45 | if (pnAvailable()) {
46 | // unfortunately there is no function to reset Notification permission...
47 | // unregister service worker
48 | await pnUpdateSW();
49 | }
50 | }
51 |
52 | /**
53 | * helper while testing
54 | * check if PN already subscribed
55 | */
56 | async function pnSubscribed() {
57 | var swReg;
58 | if (pnAvailable()) {
59 | swReg = await navigator.serviceWorker.getRegistration();
60 | }
61 | return (swReg !== undefined);
62 | }
63 |
64 | /**
65 | * checks whether all requirements for PN are met
66 | * 1. have to run in secure context
67 | * - window.isSecureContext = true
68 | * 2. browser should implement at least
69 | * - navigatpr.serviceWorker
70 | * - window.PushManager
71 | * - window.Notification
72 | *
73 | * @returns boolen
74 | */
75 | function pnAvailable() {
76 | var bAvailable = false;
77 | if (window.isSecureContext) {
78 | // running in secure context - check for available Push-API
79 | bAvailable = (('serviceWorker' in navigator) &&
80 | ('PushManager' in window) &&
81 | ('Notification' in window));
82 | } else {
83 | console.log('site have to run in secure context!');
84 | }
85 | return bAvailable;
86 | }
87 |
88 | /**
89 | * register the service worker.
90 | * there is no check for multiple registration necessary - browser/Push-API
91 | * takes care if same service-worker ist already registered
92 | */
93 | async function pnRegisterSW() {
94 | navigator.serviceWorker.register('PNServiceWorker.js')
95 | .then((swReg) => {
96 | // registration worked
97 | console.log('Registration succeeded. Scope is ' + swReg.scope);
98 | }).catch((error) => {
99 | // registration failed
100 | console.log('Registration failed with ' + error);
101 | });
102 | }
103 |
104 | /**
105 | * helper while testing
106 | * unregister the service worker.
107 | */
108 | async function pnUnregisterSW() {
109 | navigator.serviceWorker.getRegistration()
110 | .then(function(reg) {
111 | reg.unregister()
112 | .then(function(bOK) {
113 | if (bOK) {
114 | console.log('unregister service worker succeeded.');
115 | } else {
116 | console.log('unregister service worker failed.');
117 | }
118 | });
119 | });
120 | }
121 |
122 | /**
123 | * helper while testing
124 | * update service worker.
125 | */
126 | async function pnUpdateSW() {
127 | navigator.serviceWorker.getRegistration()
128 | .then(function(reg) {
129 | reg.update()
130 | .then(function(bOK) {
131 | if (bOK) {
132 | console.log('update of service worker succeeded.');
133 | } else {
134 | console.log('update of service worker failed.');
135 | }
136 | });
137 | });
138 | }
139 |
--------------------------------------------------------------------------------
/PNClientEx.js:
--------------------------------------------------------------------------------
1 | /**
2 | * functions to support PUSH notifications (PN) on user client
3 | */
4 |
5 | /**
6 | * subscribe Push notifications (PN)
7 | * - check, if PN's are available
8 | * - request users permission, if not done so far
9 | * - register service worker, if permisson is granted
10 | */
11 | async function pnSubscribe(userID) {
12 | if (pnAvailable()) {
13 | // if not granted or denied so far...
14 | if (window.Notification.permission === "default") {
15 | await window.Notification.requestPermission();
16 | }
17 | if (Notification.permission === 'granted') {
18 | // register service worker
19 | await pnRegisterSW(userID);
20 | }
21 | }
22 | }
23 |
24 | /**
25 | * helper while testing
26 | * unsubscribe Push notifications
27 | */
28 | async function pnUnsubscribe() {
29 | var swReg = null;
30 | if (pnAvailable()) {
31 | // unfortunately there is no function to reset Notification permission...
32 | // unregister service worker
33 | await pnUnregisterSW();
34 | }
35 | }
36 |
37 | /**
38 | * helper while testing
39 | * update service worker.
40 | * works not correct on each browser/os -> sometimes brwoser have to
41 | * be restarted to update service worker
42 | */
43 | async function pnUpdate() {
44 | var swReg = null;
45 | if (pnAvailable()) {
46 | // unfortunately there is no function to reset Notification permission...
47 | // unregister service worker
48 | await pnUpdateSW();
49 | }
50 | }
51 |
52 | /**
53 | * helper while testing
54 | * check if PN already subscribed
55 | */
56 | async function pnSubscribed() {
57 | var swReg = undefined;
58 | if (pnAvailable()) {
59 | swReg = await navigator.serviceWorker.getRegistration();
60 | }
61 | return (swReg != undefined);
62 | }
63 |
64 | /**
65 | * checks whether all requirements for PN are met
66 | * 1. have to run in secure context
67 | * - window.isSecureContext = true
68 | * 2. browser should implement at least
69 | * - navigatpr.serviceWorker
70 | * - window.PushManager
71 | * - window.Notification
72 | *
73 | * @returns boolen
74 | */
75 | function pnAvailable() {
76 | var bAvailable = false;
77 | if (window.isSecureContext) {
78 | // running in secure context - check for available Push-API
79 | bAvailable = (('serviceWorker' in navigator) &&
80 | ('PushManager' in window) &&
81 | ('Notification' in window));
82 | } else {
83 | console.log('site have to run in secure context!');
84 | }
85 | return bAvailable;
86 | }
87 |
88 | /**
89 | * register the service worker.
90 | * there is no check for multiple registration necessary - browser/Push-API
91 | * takes care if same service-worker ist already registered
92 | */
93 | async function pnRegisterSW(userID) {
94 | navigator.serviceWorker.register('PNServiceWorkerEx.js')
95 | .then((swReg) => {
96 | // now we can post message with the user-id
97 | swReg.active.postMessage(JSON.stringify({userID: userID}));
98 | }).catch((error) => {
99 | // registration failed
100 | console.log('Registration failed with ' + error);
101 | });
102 | }
103 |
104 | /**
105 | * helper while testing
106 | * unregister the service worker.
107 | */
108 | async function pnUnregisterSW() {
109 | navigator.serviceWorker.getRegistration()
110 | .then(function(reg) {
111 | reg.unregister()
112 | .then(function(bOK) {
113 | if (bOK) {
114 | console.log('unregister service worker succeeded.');
115 | } else {
116 | console.log('unregister service worker failed.');
117 | }
118 | });
119 | });
120 | }
121 |
122 | /**
123 | * helper while testing
124 | * update service worker.
125 | */
126 | async function pnUpdateSW() {
127 | navigator.serviceWorker.getRegistration()
128 | .then(function(reg) {
129 | reg.update()
130 | .then(function(bOK) {
131 | if (bOK) {
132 | console.log('update of service worker succeeded.');
133 | } else {
134 | console.log('update of service worker failed.');
135 | }
136 | });
137 | });
138 | }
139 |
--------------------------------------------------------------------------------
/PNSendWelcome.php:
--------------------------------------------------------------------------------
1 |
20 | * @copyright MIT License - see the LICENSE file for details
21 | */
22 |
23 | /**
24 | * @param PNSubscription $oSubscription
25 | */
26 | function sendWelcome(PNSubscription $oSubscription)
27 | {
28 | // create server. Since we are sending to a single subscription that was
29 | // passed as argument, we do not need a dataprovider
30 | $oServer = new PNServer();
31 |
32 | // create payload message for welcome...
33 | $oPayload = new PNPayload('Welcome to PNServer', 'We warmly welcome you to our homepage.', './elephpant.png');
34 |
35 | // set VAPID, payload and push to the passed subscription
36 | $oServer->setVapid(getMyVapid());
37 | $oServer->setPayload($oPayload);
38 | $oServer->pushSingle($oSubscription);
39 | }
--------------------------------------------------------------------------------
/PNServiceWorker.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Serviceworker for web push notifications
3 | * @package PNServer
4 | */
5 |
6 | // values to be modified for own project
7 | // VAPID appPublic key
8 | const strAppPublicKey = 'create your own VAPID key pair and insert public key here';
9 | // URL to save subscription on server via Fetch API
10 | const strSubscriberURL = 'https://www.your-domain.org/PNSubscriber.php';
11 | // default Notification Title if not pushed by server
12 | const strDefTitle = 'Your company or product';
13 | // default Notification Icon if not pushed by server
14 | const strDefIcon = './elephpant.png';
15 |
16 | /**
17 | * encode the public key to Array buffer
18 | * @param {string} strBase64 - key to encode
19 | * @return {Array} - UInt8Array
20 | */
21 | function encodeToUint8Array(strBase64) {
22 | var strPadding = '='.repeat((4 - (strBase64.length % 4)) % 4);
23 | strBase64 = (strBase64 + strPadding).replace(/\-/g, '+').replace(/_/g, '/');
24 | var rawData = atob(strBase64);
25 | var aOutput = new Uint8Array(rawData.length);
26 | for (i = 0; i < rawData.length; ++i) {
27 | aOutput[i] = rawData.charCodeAt(i);
28 | }
29 | return aOutput;
30 | }
31 |
32 | /**
33 | * event listener to subscribe notifications and save subscription at server
34 | * @param {ExtendableEvent} event
35 | */
36 | async function pnSubscribe(event) {
37 | console.log('Serviceworker: activate event');
38 | try {
39 | var appPublicKey = encodeToUint8Array(strAppPublicKey);
40 | var opt = {
41 | applicationServerKey: appPublicKey,
42 | userVisibleOnly: true
43 | };
44 |
45 | self.registration.pushManager.subscribe(opt)
46 | .then((sub) => {
47 | // subscription succeeded - send to server
48 | pnSaveSubscription(sub)
49 | .then((response) => {
50 | console.log(response);
51 | }).catch((e) => {
52 | // registration failed
53 | console.log('SaveSubscription failed with: ' + e);
54 | });
55 | }).catch((e) => {
56 | // registration failed
57 | console.log('Subscription failed with: ' + e);
58 | });
59 |
60 | } catch (e) {
61 | console.log('Error subscribing notifications: ' + e);
62 | }
63 | }
64 |
65 | /**
66 | * event listener handling when subscription change
67 | * just re-subscribe
68 | * @param {PushSubscriptionChangeEvent} event
69 | */
70 | async function pnSubscriptionChange(event) {
71 | console.log('Serviceworker: subscription change event: ' + event);
72 | try {
73 | // re-subscribe with old options
74 | self.registration.pushManager.subscribe(event.oldSubscription.options)
75 | .then((sub) => {
76 | // subscription succeeded - send to server
77 | pnSaveSubscription(sub)
78 | .then((response) => {
79 | console.log(response);
80 | }).catch((e) => {
81 | // registration failed
82 | console.log('SaveSubscription failed with: ' + e);
83 | });
84 | }).catch((e) => {
85 | // registration failed
86 | console.log('Subscription failed with: ' + e);
87 | });
88 |
89 | } catch (e) {
90 | console.log('Error subscribing notifications: ' + e);
91 | }
92 | }
93 |
94 | /**
95 | * save subscription on server
96 | * using Fetch API to send subscription infos to the server
97 | * subscription is encance with the userAgent for internal use on the server
98 | * @param {object} sub - PushSubscription
99 | * @return {string} - response of the request
100 | */
101 | async function pnSaveSubscription(sub) {
102 | // stringify and parse again to add 'custom' property
103 | // ... otherwise added property will be ignored when stringify subscription direct to body!
104 | var body = JSON.parse(JSON.stringify(sub));
105 | body.userAgent = navigator.userAgent;
106 | var fetchdata = {
107 | method: 'post',
108 | headers: { 'Content-Type': 'application/json' },
109 | body: JSON.stringify(body),
110 | };
111 | // we're using fetch() to post the data to the server
112 | var response = await fetch(strSubscriberURL, fetchdata);
113 | // activate following two lines, if any PHP-Error in the subscriber script occurs
114 | // -> in that case, response won't contain valid JSON data!
115 | /*
116 | var cloned = response.clone();
117 | console.log('Response: ', await cloned.text());
118 | */
119 | return await response.json();
120 | }
121 |
122 | /**
123 | * event listener to show notification
124 | * @param {PushEvent} event
125 | */
126 | function pnPushNotification(event) {
127 | console.log('push event: ' + event);
128 | var strTitle = strDefTitle;
129 | var oPayload = null;
130 | var opt = { icon: strDefIcon };
131 | if (event.data) {
132 | // PushMessageData Object containing the pushed payload
133 | try {
134 | // try to parse payload JSON-string
135 | oPayload = JSON.parse(event.data.text());
136 | } catch (e) {
137 | // if no valid JSON Data take text as it is...
138 | // ... comes maybe while testing directly from DevTools
139 | opt = {
140 | icon: strDefIcon,
141 | body: event.data.text(),
142 | };
143 | }
144 | if (oPayload) {
145 | if (oPayload.title !== undefined && oPayload.title !== '') {
146 | strTitle = oPayload.title;
147 | }
148 | opt = oPayload.opt;
149 | if (oPayload.opt.icon === undefined ||
150 | oPayload.opt.icon === null ||
151 | oPayload.icon === '') {
152 | // if no icon defined, use default
153 | opt.icon = strDefIcon;
154 | }
155 | }
156 | }
157 | var promise = self.registration.showNotification(strTitle, opt);
158 | event.waitUntil(promise);
159 | }
160 |
161 | /**
162 | * event listener to notification click
163 | * if URL passed, just open the window...
164 | * @param {NotificationClick} event
165 | */
166 | function pnNotificationClick(event) {
167 | console.log('notificationclick event: ' + event);
168 | if (event.notification.data && event.notification.data.url) {
169 | const promise = clients.openWindow(event.notification.data.url);
170 | event.waitUntil(promise);
171 | }
172 | if (event.action !== "") {
173 | // add handler for user defined action here...
174 | // pnNotificationAction(event.action);
175 | console.log('notificationclick action: ' + event.action);
176 | }
177 | }
178 |
179 | /**
180 | * event listener to notification close
181 | * ... if you want to do something for e.g. analytics
182 | * @param {NotificationClose} event
183 | */
184 | function pnNotificationClose(event) {
185 | console.log('notificationclose event: ' + event);
186 | }
187 |
188 | /**=========================================================
189 | * add all needed event-listeners
190 | * - activate: subscribe notifications and send to server
191 | * - push: show push notification
192 | * - click: handle click an notification and/or action
193 | * button
194 | * - change: subscription has changed
195 | * - close: notification was closed by the user
196 | *=========================================================*/
197 | // add event listener to subscribe and send subscription to server
198 | self.addEventListener('activate', pnSubscribe);
199 | // and listen to incomming push notifications
200 | self.addEventListener('push', pnPushNotification);
201 | // ... and listen to the click
202 | self.addEventListener('notificationclick', pnNotificationClick);
203 | // subscription has changed
204 | self.addEventListener('pushsubscriptionchange', pnSubscriptionChange);
205 | // notification was closed without further action
206 | self.addEventListener('notificationclose', pnNotificationClose);
--------------------------------------------------------------------------------
/PNServiceWorkerEx.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Serviceworker for web push notifications
3 | * @package PNServer
4 | */
5 |
6 | // values to be modified for own project
7 | // VAPID appPublic key
8 | const strAppPublicKey = 'create your own VAPID key pair and insert public key here';
9 | // URL to save subscription on server via Fetch API
10 | const strSubscriberURL = 'https://www.your-domain.org/PNSubscriber.php';
11 | // default Notification Title if not pushed by server
12 | const strDefTitle = 'Your company or product';
13 | // default Notification Icon if not pushed by server
14 | const strDefIcon = './elephpant.png';
15 |
16 | var userID = -1;
17 |
18 | /**
19 | * encode the public key to Array buffer
20 | * @param {string} strBase64 - key to encode
21 | * @return {Array} - UInt8Array
22 | */
23 | function encodeToUint8Array(strBase64) {
24 | var strPadding = '='.repeat((4 - (strBase64.length % 4)) % 4);
25 | var strBase64 = (strBase64 + strPadding).replace(/\-/g, '+').replace(/_/g, '/');
26 | var rawData = atob(strBase64);
27 | var aOutput = new Uint8Array(rawData.length);
28 | for (i = 0; i < rawData.length; ++i) {
29 | aOutput[i] = rawData.charCodeAt(i);
30 | }
31 | return aOutput;
32 | }
33 |
34 | /**
35 | * event listener to subscribe notifications and save subscription at server
36 | * @param {ExtendableEvent} event
37 | */
38 | async function pnSubscribe(event) {
39 | console.log('Serviceworker: activate event');
40 | }
41 |
42 | /**
43 | * event listener to subscribe notifications and save subscription at server
44 | * @param {ExtendableEvent} event
45 | */
46 | async function pnSubscribeEx(event) {
47 | console.log('Serviceworker: message event');
48 | var data = JSON.parse(event.data);
49 | self.userID = data.userID;
50 |
51 | try {
52 | var appPublicKey = encodeToUint8Array(strAppPublicKey);
53 | var opt = {
54 | applicationServerKey: appPublicKey,
55 | userVisibleOnly: true
56 | };
57 |
58 | self.registration.pushManager.subscribe(opt)
59 | .then((sub) => {
60 | // subscription succeeded - send to server
61 | pnSaveSubscription(sub)
62 | .then((response) => {
63 | console.log(response);
64 | }).catch((e) => {
65 | // registration failed
66 | console.log('SaveSubscription failed with: ' + e);
67 | });
68 | }, ).catch((e) => {
69 | // registration failed
70 | console.log('Subscription failed with: ' + e);
71 | });
72 |
73 | } catch (e) {
74 | console.log('Error subscribing notifications: ' + e);
75 | }
76 | }
77 |
78 | /**
79 | * event listener handling when subscription change
80 | * just re-subscribe
81 | * @param {PushSubscriptionChangeEvent} event
82 | */
83 | async function pnSubscriptionChange(event) {
84 | console.log('Serviceworker: subscription change event: ' + event);
85 | try {
86 | // re-subscribe with old options
87 | self.registration.pushManager.subscribe(event.oldSubscription.options)
88 | .then((sub) => {
89 | // subscription succeeded - send to server
90 | pnSaveSubscription(sub)
91 | .then((response) => {
92 | console.log(response);
93 | }).catch((e) => {
94 | // registration failed
95 | console.log('SaveSubscription failed with: ' + e);
96 | });
97 | }, ).catch((e) => {
98 | // registration failed
99 | console.log('Subscription failed with: ' + e);
100 | });
101 |
102 | } catch (e) {
103 | console.log('Error subscribing notifications: ' + e);
104 | }
105 | }
106 |
107 | /**
108 | * save subscription on server
109 | * using Fetch API to send subscription infos to the server
110 | * subscription is encance with the userAgent for internal use on the server
111 | * @param {object} sub - PushSubscription
112 | * @return {string} - response of the request
113 | */
114 | async function pnSaveSubscription(sub) {
115 | // stringify and parse again to add 'custom' property
116 | // ... otherwise added property will be ignored when stringify subscription direct to body!
117 | var body = JSON.parse(JSON.stringify(sub));
118 | body.userAgent = navigator.userAgent;
119 | body.userID = self.userID;
120 | console.log('Subscribe for user-id: ', self.userID);
121 | var fetchdata = {
122 | method: 'post',
123 | headers: { 'Content-Type': 'application/json' },
124 | body: JSON.stringify(body),
125 | };
126 | // we're using fetch() to post the data to the server
127 | var response = await fetch(strSubscriberURL, fetchdata);
128 | // activate following two lines, if any PHP-Error in the subscriber script occurs
129 | // -> in that case, response won't contain valid JSON data!
130 | var cloned = response.clone();
131 | console.log('Response: ', await cloned.text());
132 | return await response.json();
133 | }
134 |
135 | /**
136 | * event listener to show notification
137 | * @param {PushEvent} event
138 | */
139 | function pnPushNotification(event) {
140 | console.log('push event: ' + event);
141 | var strTitle = strDefTitle;
142 | var oPayload = null;
143 | var opt = { icon: strDefIcon };
144 | if (event.data) {
145 | // PushMessageData Object containing the pushed payload
146 | try {
147 | // try to parse payload JSON-string
148 | oPayload = JSON.parse(event.data.text());
149 | } catch (e) {
150 | // if no valid JSON Data take text as it is...
151 | // ... comes maybe while testing directly from DevTools
152 | opt = {
153 | icon: strDefIcon,
154 | body: event.data.text(),
155 | };
156 | }
157 | if (oPayload) {
158 | if (oPayload.title != undefined && oPayload.title != '') {
159 | strTitle = oPayload.title;
160 | }
161 | opt = oPayload.opt;
162 | if (oPayload.opt.icon == undefined ||
163 | oPayload.opt.icon == null ||
164 | oPayload.icon == '') {
165 | // if no icon defined, use default
166 | opt.icon = strDefIcon;
167 | }
168 | }
169 | }
170 | var promise = self.registration.showNotification(strTitle, opt);
171 | event.waitUntil(promise);
172 | }
173 |
174 | /**
175 | * event listener to notification click
176 | * if URL passed, just open the window...
177 | * @param {NotificationClick} event
178 | */
179 | function pnNotificationClick(event) {
180 | console.log('notificationclick event: ' + event);
181 | if (event.notification.data && event.notification.data.url) {
182 | const promise = clients.openWindow(event.notification.data.url);
183 | event.waitUntil(promise);
184 | }
185 | if (event.action != "") {
186 | // add handler for user defined action here...
187 | // pnNotificationAction(event.action);
188 | console.log('notificationclick action: ' + event.action);
189 | }
190 | }
191 |
192 | /**
193 | * event listener to notification close
194 | * ... if you want to do something for e.g. analytics
195 | * @param {NotificationClose} event
196 | */
197 | function pnNotificationClose(event) {
198 | console.log('notificationclose event: ' + event);
199 | }
200 |
201 | /**=========================================================
202 | * add all needed event-listeners
203 | * - activate: subscribe notifications and send to server
204 | * - push: show push notification
205 | * - click: handle click an notification and/or action
206 | * button
207 | * - change: subscription has changed
208 | * - close: notification was closed by the user
209 | *=========================================================*/
210 | // add event listener to subscribe and send subscription to server
211 | self.addEventListener('activate', pnSubscribe);
212 | // and listen to incomming push notifications
213 | self.addEventListener('push', pnPushNotification);
214 | // ... and listen to the clicklUserID
215 | self.addEventListener('notificationclick', pnNotificationClick);
216 | // subscription has changed
217 | self.addEventListener('pushsubscriptionchange', pnSubscriptionChange);
218 | // notification was closed without further action
219 | self.addEventListener('notificationclose', pnNotificationClose);
220 |
221 | self.addEventListener('message', pnSubscribeEx);
222 |
223 |
--------------------------------------------------------------------------------
/PNSubscriber.php:
--------------------------------------------------------------------------------
1 |
31 | * @copyright MIT License - see the LICENSE file for details
32 | */
33 |
34 | // set to true, if you will send songle welcome notification to each new subscription
35 | $bSendWelcome = true;
36 |
37 | $result = array();
38 | // only serve POST request containing valid json data
39 | if (strtolower($_SERVER['REQUEST_METHOD']) == 'post') {
40 | if (isset($_SERVER['CONTENT_TYPE']) && trim(strtolower($_SERVER['CONTENT_TYPE']) == 'application/json')) {
41 | // get posted json data
42 | if (($strJSON = trim(file_get_contents('php://input'))) === false) {
43 | $result['msg'] = 'invalid JSON data!';
44 | } else {
45 | // create any PSR-3 logger of your choice in MyLogger.php
46 | $oDP = new PNDataProviderSQLite(null, null, null, createLogger());
47 | if ($oDP->saveSubscription($strJSON) !== false) {
48 | $result['msg'] = 'subscription saved on server!';
49 | if ($bSendWelcome) {
50 | sendWelcome(PNSubscription::fromJSON($strJSON));
51 | }
52 | } else {
53 | $result['msg'] = 'error saving subscription!';
54 | }
55 | }
56 | } else {
57 | $result['msg'] = 'invalid content type!';
58 | }
59 | } else {
60 | $result['msg'] = 'no post request!';
61 | }
62 | // let the service-worker know the result
63 | echo json_encode($result);
64 |
--------------------------------------------------------------------------------
/PNTestClient.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | PUSH Notifications Client
6 |
7 |
38 |
39 |
40 | Welcome to PUSH Notifications Client
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/PNTestClientEx.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Extended PUSH Notifications Client
6 |
7 |
14 |
45 |
46 |
47 | Welcome to PUSH Notifications Client
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/PNTestPushSingle.php:
--------------------------------------------------------------------------------
1 |
36 | * @copyright MIT License - see the LICENSE file for details
37 | */
38 |
39 | // check, if PHP version is sufficient and all required extensions are installed
40 | $bExit = false;
41 | if (version_compare(phpversion(), '7.4', '<')) {
42 | trigger_error('At least PHP Version 7.4 is required (current Version is ' . phpversion() . ')!', E_USER_WARNING);
43 | $bExit = true;
44 | }
45 | $aExt = array('curl', 'gmp', 'mbstring', 'openssl', 'bcmath');
46 | foreach ($aExt as $strExt) {
47 | if (!extension_loaded($strExt)) {
48 | trigger_error('Extension ' . $strExt . ' must be installed!', E_USER_WARNING);
49 | $bExit = true;
50 | }
51 | }
52 | if ($bExit) {
53 | exit();
54 | }
55 |
56 | // for this test we use SQLite database
57 | $logger = createLogger();
58 | $oDP = new PNDataProviderSQLite(null, null, null, $logger);
59 | if (!$oDP->isConnected()) {
60 | echo $oDP->getError();
61 | exit();
62 | }
63 |
64 | echo 'Count of subscriptions: ' . $oDP->count() . '
' . PHP_EOL;
65 | if (!$oDP->init()) {
66 | echo $oDP->getError();
67 | exit();
68 | }
69 |
70 | // the server to handle all
71 | $oServer = new PNServer();
72 | $oServer->setLogger($logger);
73 |
74 | // Set our VAPID keys
75 | $oServer->setVapid(getMyVapid());
76 |
77 | // create and set payload
78 | // - we don't set a title - so service worker uses default
79 | // - URL to icon can be
80 | // * relative to the origin location of the service worker
81 | // * absolute from the homepage (begining with a '/')
82 | // * complete URL (beginning with https://)
83 | $oPayload = new PNPayload('', "...first text to display.", './elephpant.png');
84 | $oPayload->setTag('news', true);
85 | $oPayload->setURL('/where-to-go.php');
86 |
87 | $oServer->setPayload($oPayload);
88 |
89 | while (($strJsonSub = $oDP->fetch()) !== false) {
90 | $oServer->pushSingle(PNSubscription::fromJSON((string) $strJsonSub));
91 | }
92 |
93 | $aLog = $oServer->getLog();
94 | echo 'Push - Log:
' . PHP_EOL;
95 | foreach ($aLog as $strEndpoint => $aMsg ) {
96 | echo PNSubscription::getOrigin($strEndpoint) . ': ' .$aMsg['msg'] . '
' . PHP_EOL;
97 | }
98 |
--------------------------------------------------------------------------------
/PNTestServer.php:
--------------------------------------------------------------------------------
1 |
36 | * @copyright MIT License - see the LICENSE file for details
37 | */
38 |
39 | // check, if PHP version is sufficient and all required extensions are installed
40 | $bExit = false;
41 | if (version_compare(phpversion(), '7.4', '<')) {
42 | trigger_error('At least PHP Version 7.4 is required (current Version is ' . phpversion() . ')!', E_USER_WARNING);
43 | $bExit = true;
44 | }
45 | $aExt = array('curl', 'gmp', 'mbstring', 'openssl', 'bcmath');
46 | foreach ($aExt as $strExt) {
47 | if (!extension_loaded($strExt)) {
48 | trigger_error('Extension ' . $strExt . ' must be installed!', E_USER_WARNING);
49 | $bExit = true;
50 | }
51 | }
52 | if ($bExit) {
53 | exit();
54 | }
55 |
56 | // for this test we use SQLite database
57 | $logger = createLogger();
58 | $oDP = new PNDataProviderSQLite(null, null, null, $logger);
59 | if (!$oDP->isConnected()) {
60 | echo $oDP->getError();
61 | exit();
62 | }
63 |
64 | echo 'Count of subscriptions: ' . $oDP->count() . '
' . PHP_EOL;
65 | if (!$oDP->init()) {
66 | echo $oDP->getError();
67 | exit();
68 | }
69 |
70 | // the server to handle all
71 | $oServer = new PNServer($oDP);
72 | $oServer->setLogger($logger);
73 |
74 | // Set our VAPID keys
75 | $oServer->setVapid(getMyVapid());
76 |
77 | // create and set payload
78 | // - we don't set a title - so service worker uses default
79 | // - URL to icon can be
80 | // * relative to the origin location of the service worker
81 | // * absolute from the homepage (begining with a '/')
82 | // * complete URL (beginning with https://)
83 | $oPayload = new PNPayload('', "...first text to display.", './elephpant.png');
84 | $oPayload->setTag('news', true);
85 | $oPayload->setURL('/where-to-go.php');
86 |
87 | $oServer->setPayload($oPayload);
88 |
89 | // load subscriptions from database
90 | if (!$oServer->loadSubscriptions()) {
91 | echo $oDP->getError();
92 | exit();
93 | }
94 |
95 | // ... and finally push !
96 | if (!$oServer->push()) {
97 | echo '' . $oServer->getError() . '
' . PHP_EOL;
98 | } else {
99 | $aLog = $oServer->getLog();
100 | echo 'Summary:
' . PHP_EOL;
101 | $summary = $oServer->getSummary();
102 | echo 'total: ' . $summary['total'] . '
' . PHP_EOL;
103 | echo 'pushed: ' . $summary['pushed'] . '
' . PHP_EOL;
104 | echo 'failed: ' . $summary['failed'] . '
' . PHP_EOL;
105 | echo 'expired: ' . $summary['expired'] . '
' . PHP_EOL;
106 | echo 'removed: ' . $summary['removed'] . '
' . PHP_EOL;
107 |
108 | echo 'Push - Log:
' . PHP_EOL;
109 | foreach ($aLog as $strEndpoint => $aMsg ) {
110 | echo PNSubscription::getOrigin($strEndpoint) . ': ' .$aMsg['msg'] . '
' . PHP_EOL;
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/Psr/Log/AbstractLogger.php:
--------------------------------------------------------------------------------
1 | log(LogLevel::EMERGENCY, $message, $context);
25 | }
26 |
27 | /**
28 | * Action must be taken immediately.
29 | *
30 | * Example: Entire website down, database unavailable, etc. This should
31 | * trigger the SMS alerts and wake you up.
32 | *
33 | * @param string $message
34 | * @param array $context
35 | *
36 | * @return void
37 | */
38 | public function alert($message, array $context = array())
39 | {
40 | $this->log(LogLevel::ALERT, $message, $context);
41 | }
42 |
43 | /**
44 | * Critical conditions.
45 | *
46 | * Example: Application component unavailable, unexpected exception.
47 | *
48 | * @param string $message
49 | * @param array $context
50 | *
51 | * @return void
52 | */
53 | public function critical($message, array $context = array())
54 | {
55 | $this->log(LogLevel::CRITICAL, $message, $context);
56 | }
57 |
58 | /**
59 | * Runtime errors that do not require immediate action but should typically
60 | * be logged and monitored.
61 | *
62 | * @param string $message
63 | * @param array $context
64 | *
65 | * @return void
66 | */
67 | public function error($message, array $context = array())
68 | {
69 | $this->log(LogLevel::ERROR, $message, $context);
70 | }
71 |
72 | /**
73 | * Exceptional occurrences that are not errors.
74 | *
75 | * Example: Use of deprecated APIs, poor use of an API, undesirable things
76 | * that are not necessarily wrong.
77 | *
78 | * @param string $message
79 | * @param array $context
80 | *
81 | * @return void
82 | */
83 | public function warning($message, array $context = array())
84 | {
85 | $this->log(LogLevel::WARNING, $message, $context);
86 | }
87 |
88 | /**
89 | * Normal but significant events.
90 | *
91 | * @param string $message
92 | * @param array $context
93 | *
94 | * @return void
95 | */
96 | public function notice($message, array $context = array())
97 | {
98 | $this->log(LogLevel::NOTICE, $message, $context);
99 | }
100 |
101 | /**
102 | * Interesting events.
103 | *
104 | * Example: User logs in, SQL logs.
105 | *
106 | * @param string $message
107 | * @param array $context
108 | *
109 | * @return void
110 | */
111 | public function info($message, array $context = array())
112 | {
113 | $this->log(LogLevel::INFO, $message, $context);
114 | }
115 |
116 | /**
117 | * Detailed debug information.
118 | *
119 | * @param string $message
120 | * @param array $context
121 | *
122 | * @return void
123 | */
124 | public function debug($message, array $context = array())
125 | {
126 | $this->log(LogLevel::DEBUG, $message, $context);
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/Psr/Log/InvalidArgumentException.php:
--------------------------------------------------------------------------------
1 | logger = $logger;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Psr/Log/LoggerInterface.php:
--------------------------------------------------------------------------------
1 | log(LogLevel::EMERGENCY, $message, $context);
26 | }
27 |
28 | /**
29 | * Action must be taken immediately.
30 | *
31 | * Example: Entire website down, database unavailable, etc. This should
32 | * trigger the SMS alerts and wake you up.
33 | *
34 | * @param string $message
35 | * @param array $context
36 | *
37 | * @return void
38 | */
39 | public function alert($message, array $context = array())
40 | {
41 | $this->log(LogLevel::ALERT, $message, $context);
42 | }
43 |
44 | /**
45 | * Critical conditions.
46 | *
47 | * Example: Application component unavailable, unexpected exception.
48 | *
49 | * @param string $message
50 | * @param array $context
51 | *
52 | * @return void
53 | */
54 | public function critical($message, array $context = array())
55 | {
56 | $this->log(LogLevel::CRITICAL, $message, $context);
57 | }
58 |
59 | /**
60 | * Runtime errors that do not require immediate action but should typically
61 | * be logged and monitored.
62 | *
63 | * @param string $message
64 | * @param array $context
65 | *
66 | * @return void
67 | */
68 | public function error($message, array $context = array())
69 | {
70 | $this->log(LogLevel::ERROR, $message, $context);
71 | }
72 |
73 | /**
74 | * Exceptional occurrences that are not errors.
75 | *
76 | * Example: Use of deprecated APIs, poor use of an API, undesirable things
77 | * that are not necessarily wrong.
78 | *
79 | * @param string $message
80 | * @param array $context
81 | *
82 | * @return void
83 | */
84 | public function warning($message, array $context = array())
85 | {
86 | $this->log(LogLevel::WARNING, $message, $context);
87 | }
88 |
89 | /**
90 | * Normal but significant events.
91 | *
92 | * @param string $message
93 | * @param array $context
94 | *
95 | * @return void
96 | */
97 | public function notice($message, array $context = array())
98 | {
99 | $this->log(LogLevel::NOTICE, $message, $context);
100 | }
101 |
102 | /**
103 | * Interesting events.
104 | *
105 | * Example: User logs in, SQL logs.
106 | *
107 | * @param string $message
108 | * @param array $context
109 | *
110 | * @return void
111 | */
112 | public function info($message, array $context = array())
113 | {
114 | $this->log(LogLevel::INFO, $message, $context);
115 | }
116 |
117 | /**
118 | * Detailed debug information.
119 | *
120 | * @param string $message
121 | * @param array $context
122 | *
123 | * @return void
124 | */
125 | public function debug($message, array $context = array())
126 | {
127 | $this->log(LogLevel::DEBUG, $message, $context);
128 | }
129 |
130 | /**
131 | * Logs with an arbitrary level.
132 | *
133 | * @param mixed $level
134 | * @param string $message
135 | * @param array $context
136 | *
137 | * @return void
138 | *
139 | * @throws \Psr\Log\InvalidArgumentException
140 | */
141 | abstract public function log($level, $message, array $context = array());
142 | }
143 |
--------------------------------------------------------------------------------
/Psr/Log/NullLogger.php:
--------------------------------------------------------------------------------
1 | logger) { }`
11 | * blocks.
12 | */
13 | class NullLogger extends AbstractLogger
14 | {
15 | /**
16 | * Logs with an arbitrary level.
17 | *
18 | * @param mixed $level
19 | * @param string $message
20 | * @param array $context
21 | *
22 | * @return void
23 | *
24 | * @throws \Psr\Log\InvalidArgumentException
25 | */
26 | public function log($level, $message, array $context = array())
27 | {
28 | // noop
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/SKien/PNServer/PNDataProvider.php:
--------------------------------------------------------------------------------
1 |
16 | * @copyright MIT License - see the LICENSE file for details
17 | */
18 | interface PNDataProvider
19 | {
20 | /** default table name */
21 | const TABLE_NAME = "tPNSubscription";
22 | /** internal id */
23 | const COL_ID = "id";
24 | /** endpoint */
25 | const COL_ENDPOINT = "endpoint";
26 | /** expiration */
27 | const COL_EXPIRES = "expires";
28 | /** complete subscription as JSON-string */
29 | const COL_SUBSCRIPTION = "subscription";
30 | /** user agent at endpoint */
31 | const COL_USERAGENT = "useragent";
32 | /** timestamp supscription last updated */
33 | const COL_LASTUPDATED = "lastupdated";
34 |
35 | /**
36 | * check, if connected to data source
37 | */
38 | public function isConnected() : bool;
39 |
40 | /**
41 | * Saves subscription.
42 | * Inserts new or replaces existing subscription.
43 | * UNIQUE identifier alwas is the endpoint!
44 | * @param string $strJSON subscription as well formed JSON-string
45 | * @return bool true on success
46 | */
47 | public function saveSubscription(string $strJSON) : bool;
48 |
49 | /**
50 | * Remove subscription for $strEndPoint from DB.
51 | * @param string $strEndpoint
52 | * @return bool true on success
53 | */
54 | public function removeSubscription(string $strEndpoint) : bool;
55 |
56 | /**
57 | * Initialization for fetching data.
58 | * @param bool $bAutoRemove automatic remove of expired subscriptions
59 | * @return bool true on success
60 | */
61 | public function init(bool $bAutoRemove = true) : bool;
62 |
63 | /**
64 | * Get count of subscriptions.
65 | * @return int
66 | */
67 | public function count() : int;
68 |
69 | /**
70 | * Fetch next subscription.
71 | * @return string|bool subscription as well formed JSON-string or false at end of list
72 | */
73 | public function fetch();
74 |
75 | /**
76 | * Truncate subscription table.
77 | * (almost only needed while development and for testing/phpunit)
78 | * @return bool
79 | */
80 | public function truncate() : bool;
81 |
82 | /**
83 | * Get column value of last fetched row
84 | * @param string $strName
85 | * @return string column value or null, if no row selected or column not exist
86 | */
87 | public function getColumn(string $strName) : ?string;
88 |
89 | /**
90 | * @return string
91 | */
92 | public function getError() : string;
93 |
94 | /**
95 | * @param LoggerInterface $logger
96 | */
97 | public function setLogger(LoggerInterface $logger) : void;
98 | }
99 |
--------------------------------------------------------------------------------
/SKien/PNServer/PNDataProviderMySQL.php:
--------------------------------------------------------------------------------
1 |
19 | * @copyright MIT License - see the LICENSE file for details
20 | */
21 | class PNDataProviderMySQL implements PNDataProvider
22 | {
23 | /** @var string tablename */
24 | protected string $strTableName = '';
25 | /** @var string DB host */
26 | protected string $strDBHost = '';
27 | /** @var string DB user */
28 | protected string $strDBUser = '';
29 | /** @var string Password for DB */
30 | protected string $strDBPwd = '';
31 | /** @var string DB name */
32 | protected string $strDBName = '';
33 | /** @var \mysqli|false|null internal MySQL DB */
34 | protected $db = false;
35 | /** @var \mysqli_result|false result of DB queries */
36 | protected $dbres = false;
37 | /** @var array last fetched row or null */
38 | protected ?array $row = null;
39 | /** @var string last error */
40 | protected string $strLastError = '';
41 | /** @var bool does table exist */
42 | protected bool $bTableExist = false;
43 | /** @var LoggerInterface $logger */
44 | protected LoggerInterface $logger;
45 |
46 | /**
47 | * @param string $strDBHost DB Host
48 | * @param string $strDBUser DB User
49 | * @param string $strDBPwd DB Password
50 | * @param string $strDBName DB Name
51 | * @param string $strTableName tablename for the subscriptions - if null, self::TABLE_NAME is used and created if not exist
52 | * @param LoggerInterface $logger
53 | */
54 | public function __construct(string $strDBHost, string $strDBUser, string $strDBPwd, string $strDBName, ?string $strTableName = null, ?LoggerInterface $logger = null)
55 | {
56 | $this->logger = isset($logger) ? $logger : new NullLogger();
57 | $this->strDBHost = $strDBHost;
58 | $this->strDBUser = $strDBUser;
59 | $this->strDBPwd = $strDBPwd;
60 | $this->strDBName = $strDBName;
61 | $this->strTableName = isset($strTableName) ? $strTableName : self::TABLE_NAME;
62 |
63 | $this->db = @mysqli_connect($strDBHost, $strDBUser, $strDBPwd, $strDBName);
64 | if ($this->db !== false) {
65 | if (!$this->tableExist()) {
66 | $this->createTable();
67 | }
68 | } else {
69 | $this->strLastError = 'MySQL: Connect Error ' . mysqli_connect_errno();
70 | $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
71 | }
72 | }
73 |
74 | /**
75 | * {@inheritDoc}
76 | * @see PNDataProvider::isConnected()
77 | */
78 | public function isConnected() : bool
79 | {
80 | if (!$this->db) {
81 | $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
82 | } else if (!$this->tableExist()) {
83 | // Condition cannot be forced to test
84 | // - can only occur during development using invalid SQL-statement for creation!
85 | // @codeCoverageIgnoreStart
86 | if (strlen($this->strLastError) == 0) {
87 | $this->strLastError = 'database table ' . $this->strTableName . ' not exist!';
88 | }
89 | $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
90 | // @codeCoverageIgnoreEnd
91 | }
92 | return ($this->db && $this->bTableExist);
93 | }
94 |
95 | /**
96 | * {@inheritDoc}
97 | * @see PNDataProvider::saveSubscription()
98 | */
99 | public function saveSubscription(string $strJSON) : bool
100 | {
101 | $bSucceeded = false;
102 | if ($this->db) {
103 | $oSubscription = json_decode($strJSON, true);
104 | if ($oSubscription) {
105 | $iExpires = isset($oSubscription['expirationTime']) ? intval(bcdiv($oSubscription['expirationTime'], '1000')) : 0;
106 | $tsExpires = $iExpires > 0 ? date("'Y-m-d H:i:s'", $iExpires) : 'NULL';
107 | $strUserAgent = isset($oSubscription['userAgent']) ? $oSubscription['userAgent'] : 'unknown UserAgent';
108 |
109 | $strSQL = "INSERT INTO " . $this->strTableName . " (";
110 | $strSQL .= self::COL_ENDPOINT;
111 | $strSQL .= "," . self::COL_EXPIRES;
112 | $strSQL .= "," . self::COL_SUBSCRIPTION;
113 | $strSQL .= "," . self::COL_USERAGENT;
114 | $strSQL .= ") VALUES(";
115 | $strSQL .= "'" . $oSubscription['endpoint'] . "'";
116 | $strSQL .= "," . $tsExpires;
117 | $strSQL .= ",'" . $strJSON . "'";
118 | $strSQL .= ",'" . $strUserAgent . "'";
119 | $strSQL .= ") ";
120 | $strSQL .= "ON DUPLICATE KEY UPDATE "; // in case of UPDATE UA couldn't have been changed and endpoint is the UNIQUE key!
121 | $strSQL .= " expires = " . $tsExpires;
122 | $strSQL .= ",subscription = '" . $strJSON . "'";
123 | $strSQL .= ";";
124 |
125 | $bSucceeded = $this->db->query($strSQL) !== false;
126 | $this->strLastError = $this->db->error;
127 | $this->logger->info(__CLASS__ . ': ' . 'Subscription saved', strlen($this->strLastError) > 0 ? ['error' => $this->strLastError] : []);
128 | } else {
129 | $this->strLastError = 'Error json_decode: ' . json_last_error_msg();
130 | $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
131 | }
132 | }
133 | return $bSucceeded;
134 | }
135 |
136 | /**
137 | * {@inheritDoc}
138 | * @see PNDataProvider::removeSubscription()
139 | */
140 | public function removeSubscription(string $strEndpoint) : bool
141 | {
142 | $bSucceeded = false;
143 | if ($this->db) {
144 | $strSQL = "DELETE FROM " . $this->strTableName . " WHERE endpoint LIKE ";
145 | $strSQL .= "'" . $strEndpoint . "'";
146 |
147 | $bSucceeded = $this->db->query($strSQL) !== false;
148 | $this->strLastError = $this->db->error;
149 | $this->logger->info(__CLASS__ . ': ' . 'Subscription removed', strlen($this->strLastError) > 0 ? ['error' => $this->strLastError] : []);
150 | }
151 | return $bSucceeded;
152 | }
153 |
154 | /**
155 | * Select all subscriptions not expired so far.
156 | * columns expired and lastupdated are timestamp for better handling and visualization
157 | * e.g. in phpMyAdmin. For compatibility reasons with other dataproviders the query
158 | * selects the unis_timestamp values
159 | *
160 | * {@inheritDoc}
161 | * @see PNDataProvider::init()
162 | */
163 | public function init(bool $bAutoRemove = true) : bool
164 | {
165 | $bSucceeded = false;
166 | $this->dbres = false;
167 | $this->row = null;
168 | if ($this->db) {
169 | $strWhere = '';
170 | if ($bAutoRemove) {
171 | // remove expired subscriptions from DB
172 | $strSQL = "DELETE FROM " . $this->strTableName . " WHERE ";
173 | $strSQL .= self::COL_EXPIRES . " IS NOT NULL AND ";
174 | $strSQL .= self::COL_EXPIRES . " < NOW()";
175 |
176 | $bSucceeded = $this->db->query($strSQL) !== false;
177 | if (!$bSucceeded) {
178 | $this->strLastError = 'MySQL: ' . $this->db->error;
179 | $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
180 | }
181 | } else {
182 | // or just exclude them from query
183 | $strWhere = " WHERE ";
184 | $strWhere .= self::COL_EXPIRES . " IS NULL OR ";
185 | $strWhere .= self::COL_EXPIRES . " >= NOW()";
186 | $bSucceeded = true;
187 | }
188 | if ($bSucceeded) {
189 | $strSQL = "SELECT ";
190 | $strSQL .= self::COL_ID;
191 | $strSQL .= "," . self::COL_ENDPOINT;
192 | $strSQL .= ",UNIX_TIMESTAMP(" . self::COL_EXPIRES . ") AS " . self::COL_EXPIRES;
193 | $strSQL .= "," . self::COL_SUBSCRIPTION;
194 | $strSQL .= "," . self::COL_USERAGENT;
195 | $strSQL .= ",UNIX_TIMESTAMP(" . self::COL_LASTUPDATED . ") AS " . self::COL_LASTUPDATED;
196 | $strSQL .= " FROM " . $this->strTableName . $strWhere;
197 |
198 | $dbres = $this->db->query($strSQL);
199 | if ($dbres === false) {
200 | // @codeCoverageIgnoreStart
201 | // can only occur during development!
202 | $this->strLastError = 'MySQL: ' . $this->db->error;
203 | $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
204 | $bSucceeded = false;
205 | // @codeCoverageIgnoreEnd
206 | } elseif (is_object($dbres)) {
207 | $this->dbres = $dbres;
208 | }
209 | }
210 | }
211 | return $bSucceeded;
212 | }
213 |
214 | /**
215 | * {@inheritDoc}
216 | * @see PNDataProvider::count()
217 | */
218 | public function count() : int
219 | {
220 | $iCount = 0;
221 | if ($this->db) {
222 | $dbres = $this->db->query("SELECT count(*) AS iCount FROM " . $this->strTableName);
223 | if (is_object($dbres)) {
224 | $row = $dbres->fetch_array(MYSQLI_ASSOC);
225 | if ($row !== null) {
226 | $iCount = intval($row['iCount']);
227 | }
228 | }
229 | }
230 | return $iCount;
231 | }
232 |
233 | /**
234 | * {@inheritDoc}
235 | * @see PNDataProvider::fetch()
236 | */
237 | public function fetch()
238 | {
239 | $strSubJSON = false;
240 | if ($this->dbres !== false) {
241 | $this->row = $this->dbres->fetch_array(MYSQLI_ASSOC);
242 | if ($this->row) {
243 | $strSubJSON = $this->row[self::COL_SUBSCRIPTION];
244 | }
245 | }
246 | return $strSubJSON;
247 | }
248 |
249 | /**
250 | * {@inheritDoc}
251 | * @see PNDataProvider::truncate()
252 | */
253 | public function truncate() : bool
254 | {
255 | $bSucceeded = false;
256 | if ($this->isConnected()) {
257 | $bSucceeded = $this->db->query("TRUNCATE TABLE " . $this->strTableName); // @phpstan-ignore-line
258 | $this->logger->info(__CLASS__ . ': ' . 'Subscription table truncated');
259 | }
260 | return $bSucceeded !== false;
261 | }
262 |
263 | /**
264 | * {@inheritDoc}
265 | * @see PNDataProvider::getColumn()
266 | */
267 | public function getColumn(string $strName) : ?string
268 | {
269 | $value = null;
270 | if ($this->row !== false && isset($this->row[$strName])) {
271 | $value = $this->row[$strName];
272 | if ($strName == self::COL_EXPIRES || $strName == self::COL_LASTUPDATED) {
273 |
274 | }
275 | }
276 | return $value;
277 | }
278 |
279 | /**
280 | * get last error
281 | * @return string
282 | */
283 | public function getError() : string
284 | {
285 | return $this->strLastError;
286 | }
287 |
288 | /**
289 | * check, if table exist
290 | * @return bool
291 | */
292 | private function tableExist() : bool
293 | {
294 | if (!$this->bTableExist) {
295 | if ($this->db) {
296 | $dbres = $this->db->query("SHOW TABLES LIKE '" . $this->strTableName . "'");
297 | if (is_object($dbres)) {
298 | $this->bTableExist = $dbres->num_rows > 0;
299 | }
300 | }
301 | }
302 | return $this->bTableExist;
303 | }
304 |
305 | /**
306 | * create table if not exist
307 | */
308 | private function createTable() : bool
309 | {
310 | $bSucceeded = false;
311 | if (is_object($this->db)) {
312 | $strSQL = "CREATE TABLE IF NOT EXISTS " . $this->strTableName . " (";
313 | $strSQL .= " " . self::COL_ID . " int NOT NULL AUTO_INCREMENT";
314 | $strSQL .= "," . self::COL_ENDPOINT . " text NOT NULL";
315 | $strSQL .= "," . self::COL_EXPIRES . " timestamp NULL DEFAULT NULL";
316 | $strSQL .= "," . self::COL_SUBSCRIPTION . " text NOT NULL";
317 | $strSQL .= "," . self::COL_USERAGENT . " varchar(255) NOT NULL";
318 | $strSQL .= "," . self::COL_LASTUPDATED . " timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP";
319 | $strSQL .= ",PRIMARY KEY (id)";
320 | $strSQL .= ",UNIQUE (endpoint(500))";
321 | $strSQL .= ") ENGINE=InnoDB;";
322 |
323 | $bSucceeded = $this->db->query($strSQL) !== false;
324 | $this->strLastError = $this->db->error;
325 | $this->logger->info(__CLASS__ . ': ' . 'Subscription table created', strlen($this->strLastError) > 0 ? ['error' => $this->strLastError] : []);
326 | }
327 | $this->bTableExist = $bSucceeded;
328 | return $bSucceeded;
329 | }
330 |
331 | /**
332 | * @param LoggerInterface $logger
333 | */
334 | public function setLogger(LoggerInterface $logger) : void
335 | {
336 | $this->logger = $logger;
337 | }
338 | }
339 |
--------------------------------------------------------------------------------
/SKien/PNServer/PNDataProviderSQLite.php:
--------------------------------------------------------------------------------
1 |
19 | * @copyright MIT License - see the LICENSE file for details
20 | */
21 | class PNDataProviderSQLite implements PNDataProvider
22 | {
23 | /** @var string tablename */
24 | protected string $strTableName;
25 | /** @var string name of the DB file */
26 | protected string $strDBName;
27 | /** @var \SQLite3 internal SqLite DB */
28 | protected ?\SQLite3 $db = null;
29 | /** @var \SQLite3Result|false result of DB queries */
30 | protected $dbres = false;
31 | /** @var array|false last fetched row or false */
32 | protected $row = false;
33 | /** @var string last error */
34 | protected string $strLastError;
35 | /** @var bool does table exist */
36 | protected bool $bTableExist = false;
37 | /** @var LoggerInterface $logger */
38 | protected LoggerInterface $logger;
39 |
40 | /**
41 | * @param string $strDir directory - if null, current working directory assumed
42 | * @param string $strDBName name of DB file - if null, file 'pnsub.sqlite' is used and created if not exist
43 | * @param string $strTableName tablename for the subscriptions - if null, self::TABLE_NAME is used and created if not exist
44 | * @param LoggerInterface $logger
45 | */
46 | public function __construct(?string $strDir = null, ?string $strDBName = null, ?string $strTableName = null, ?LoggerInterface $logger = null)
47 | {
48 | $this->logger = $logger ?? new NullLogger();
49 | $this->strTableName = $strTableName ?? self::TABLE_NAME;
50 | $this->strDBName = $strDBName ?? 'pnsub.sqlite';
51 | $this->strLastError = '';
52 | $strDBName = $this->strDBName;
53 | if (isset($strDir) && strlen($strDir) > 0) {
54 | $strDBName = rtrim($strDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $this->strDBName;
55 | }
56 | try {
57 | if (file_exists($strDBName) && !is_writable($strDBName)) {
58 | $this->strLastError .= 'readonly database file ' . $strDBName . '!';
59 | $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
60 | } else {
61 | $this->db = new \SQLite3($strDBName);
62 | if (!$this->tableExist()) {
63 | $this->createTable();
64 | }
65 | }
66 | } catch (\Exception $e) {
67 | $this->db = null;
68 | $this->strLastError = $e->getMessage();
69 | if (!file_exists($strDBName)) {
70 | $strDir = pathinfo($strDBName, PATHINFO_DIRNAME) == '' ? __DIR__ : pathinfo($strDBName, PATHINFO_DIRNAME);
71 | if (!is_writable($strDir)) {
72 | $this->strLastError .= ' (no rights to write on directory ' . $strDir . ')';
73 | }
74 | }
75 | $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
76 | }
77 | }
78 |
79 | /**
80 | * {@inheritDoc}
81 | * @see PNDataProvider::isConnected()
82 | */
83 | public function isConnected() : bool
84 | {
85 | if (!$this->db) {
86 | $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
87 | } else if (!$this->tableExist()) {
88 | // Condition cannot be forced to test
89 | // - can only occur during development using invalid SQL-statement for creation!
90 | // @codeCoverageIgnoreStart
91 | if (strlen($this->strLastError) == 0) {
92 | $this->strLastError = 'database table ' . $this->strTableName . ' not exist!';
93 | }
94 | $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
95 | // @codeCoverageIgnoreEnd
96 | }
97 | return (is_object($this->db) && $this->bTableExist);
98 | }
99 |
100 | /**
101 | * {@inheritDoc}
102 | * @see PNDataProvider::saveSubscription()
103 | */
104 | public function saveSubscription(string $strJSON) : bool
105 | {
106 | $bSucceeded = false;
107 | if ($this->isConnected()) {
108 | $oSubscription = json_decode($strJSON, true);
109 | if ($oSubscription) {
110 | $iExpires = isset($oSubscription['expirationTime']) ? bcdiv((string) $oSubscription['expirationTime'], '1000') : 0;
111 | $strUserAgent = isset($oSubscription['userAgent']) ? $oSubscription['userAgent'] : 'unknown UserAgent';
112 |
113 | // insert or update - relevant is the endpoint as unique index
114 | $strSQL = "REPLACE INTO " . $this->strTableName . " (";
115 | $strSQL .= self::COL_ENDPOINT;
116 | $strSQL .= "," . self::COL_EXPIRES;
117 | $strSQL .= "," . self::COL_SUBSCRIPTION;
118 | $strSQL .= "," . self::COL_USERAGENT;
119 | $strSQL .= "," . self::COL_LASTUPDATED;
120 | $strSQL .= ") VALUES(";
121 | $strSQL .= "'" . $oSubscription['endpoint'] . "'";
122 | $strSQL .= "," . $iExpires;
123 | $strSQL .= ",'" . $strJSON . "'";
124 | $strSQL .= ",'" . $strUserAgent . "'";
125 | $strSQL .= ',' . time();
126 | $strSQL .= ");";
127 |
128 | $bSucceeded = $this->db->exec($strSQL);
129 | $this->setSQLiteError($bSucceeded);
130 | $this->logger->info(__CLASS__ . ': ' . 'Subscription saved', strlen($this->strLastError) > 0 ? ['error' => $this->strLastError] : []);
131 | } else {
132 | $this->strLastError = 'Error json_decode: ' . json_last_error_msg();
133 | $this->logger->error(__CLASS__ . ': ' . $this->strLastError);
134 | }
135 | }
136 | return $bSucceeded;
137 | }
138 |
139 | /**
140 | * {@inheritDoc}
141 | * @see PNDataProvider::removeSubscription()
142 | */
143 | public function removeSubscription(string $strEndpoint) : bool
144 | {
145 | $bSucceeded = false;
146 | if ($this->isConnected()) {
147 | $strSQL = "DELETE FROM " . $this->strTableName . " WHERE " . self::COL_ENDPOINT . " LIKE ";
148 | $strSQL .= "'" . $strEndpoint . "'";
149 |
150 | $bSucceeded = $this->db->exec($strSQL);
151 | $this->setSQLiteError($bSucceeded);
152 | $this->logger->info(__CLASS__ . ': ' . 'Subscription removed', strlen($this->strLastError) > 0 ? ['error' => $this->strLastError] : []);
153 | }
154 | return $bSucceeded;
155 | }
156 |
157 | /**
158 | * select all subscriptions not expired so far
159 | * {@inheritDoc}
160 | * @see PNDataProvider::init()
161 | */
162 | public function init(bool $bAutoRemove = true) : bool
163 | {
164 | $bSucceeded = false;
165 | $this->dbres = false;
166 | $this->row = false;
167 | if ($this->isConnected()) {
168 | if ($bAutoRemove) {
169 | // remove expired subscriptions from DB
170 | $strSQL = "DELETE FROM " . $this->strTableName . " WHERE ";
171 | $strSQL .= self::COL_EXPIRES . " != 0 AND ";
172 | $strSQL .= self::COL_EXPIRES . " < " . time();
173 |
174 | $bSucceeded = $this->db->exec($strSQL);
175 | $this->setSQLiteError($bSucceeded !== false);
176 | $strSQL = "SELECT * FROM " . $this->strTableName;
177 | } else {
178 | // or just exclude them from query
179 | $strSQL = "SELECT * FROM " . $this->strTableName . " WHERE ";
180 | $strSQL .= self::COL_EXPIRES . " = 0 OR ";
181 | $strSQL .= self::COL_EXPIRES . " >= " . time();
182 | $bSucceeded = true;
183 | }
184 | if ($bSucceeded) {
185 | $this->dbres = $this->db->query($strSQL);
186 | $bSucceeded = $this->dbres !== false && $this->dbres->numColumns() > 0;
187 | $this->setSQLiteError($bSucceeded);
188 | }
189 | }
190 | return (bool) $bSucceeded;
191 | }
192 |
193 | /**
194 | * {@inheritDoc}
195 | * @see PNDataProvider::count()
196 | */
197 | public function count() : int
198 | {
199 | $iCount = 0;
200 | if ($this->isConnected()) {
201 | $iCount = $this->db->querySingle("SELECT count(*) FROM " . $this->strTableName);
202 | $this->setSQLiteError($iCount !== false);
203 | }
204 | return intval($iCount);
205 | }
206 |
207 | /**
208 | * {@inheritDoc}
209 | * @see PNDataProvider::fetch()
210 | */
211 | public function fetch()
212 | {
213 | $strSubJSON = false;
214 | $this->row = false;
215 | if ($this->dbres !== false) {
216 | $this->row = $this->dbres->fetchArray(SQLITE3_ASSOC);
217 | $this->setSQLiteError(!is_bool($this->row));
218 | if ($this->row !== false) {
219 | $strSubJSON = $this->row[self::COL_SUBSCRIPTION];
220 | }
221 | }
222 | return $strSubJSON;
223 | }
224 |
225 | /**
226 | * {@inheritDoc}
227 | * @see PNDataProvider::truncate()
228 | */
229 | public function truncate() : bool
230 | {
231 | $bSucceeded = false;
232 | if ($this->isConnected()) {
233 | $bSucceeded = $this->db->exec("DELETE FROM " . $this->strTableName);
234 | $this->logger->info(__CLASS__ . ': ' . 'Subscription table truncated');
235 | }
236 | return $bSucceeded;
237 | }
238 |
239 | /**
240 | * {@inheritDoc}
241 | * @see PNDataProvider::getColumn()
242 | */
243 | public function getColumn($strName) : ?string
244 | {
245 | $value = null;
246 | if ($this->row !== false && isset($this->row[$strName])) {
247 | $value = $this->row[$strName];
248 | }
249 | return strval($value);
250 | }
251 |
252 | /**
253 | * @return string
254 | */
255 | public function getError() : string
256 | {
257 | return $this->strLastError;
258 | }
259 |
260 | /**
261 | * @return bool
262 | */
263 | private function tableExist() : bool
264 | {
265 | if (!$this->bTableExist) {
266 | if ($this->db) {
267 | $this->bTableExist = ($this->db->querySingle("SELECT name FROM sqlite_master WHERE type='table' AND name='" . $this->strTableName . "'") != null);
268 | }
269 | }
270 | return $this->bTableExist;
271 | }
272 |
273 | /**
274 | * @return bool
275 | */
276 | private function createTable() : bool
277 | {
278 | $bSucceeded = false;
279 | if ($this->db) {
280 | $strSQL = "CREATE TABLE " . $this->strTableName . " (";
281 | $strSQL .= self::COL_ID . " INTEGER PRIMARY KEY";
282 | $strSQL .= "," . self::COL_ENDPOINT . " TEXT UNIQUE";
283 | $strSQL .= "," . self::COL_EXPIRES . " INTEGER NOT NULL";
284 | $strSQL .= "," . self::COL_SUBSCRIPTION . " TEXT NOT NULL";
285 | $strSQL .= "," . self::COL_USERAGENT . " TEXT NOT NULL";
286 | $strSQL .= "," . self::COL_LASTUPDATED . " INTEGER NOT NULL";
287 | $strSQL .= ");";
288 |
289 | $bSucceeded = $this->db->exec($strSQL);
290 | $this->setSQLiteError($bSucceeded);
291 | $this->logger->info(__CLASS__ . ': ' . 'Subscription table created', strlen($this->strLastError) > 0 ? ['error' => $this->strLastError] : []);
292 | }
293 | $this->bTableExist = $bSucceeded;
294 | return $bSucceeded;
295 | }
296 |
297 | /**
298 | * @param bool $bSucceeded set error, if last opperation not succeeded
299 | */
300 | private function setSQLiteError(bool $bSucceeded) : void
301 | {
302 | // All reasons, with the exception of incorrect SQL statements, are intercepted
303 | // beforehand - so this part of the code is no longer run through in the test
304 | // anphase. This section is therefore excluded from codecoverage.
305 | // @codeCoverageIgnoreStart
306 | if (!$bSucceeded && $this->db) {
307 | $this->strLastError = 'SQLite3: ' . $this->db->lastErrorMsg();
308 | }
309 | // @codeCoverageIgnoreEnd
310 | }
311 |
312 | /**
313 | * @param LoggerInterface $logger
314 | */
315 | public function setLogger(LoggerInterface $logger) : void
316 | {
317 | $this->logger = $logger;
318 | }
319 | }
320 |
--------------------------------------------------------------------------------
/SKien/PNServer/PNEncryption.php:
--------------------------------------------------------------------------------
1 |
13 | * https://github.com/web-push-libs/web-push-php
14 | * and from package spomky-labs/jose
15 | * https://github.com/Spomky-Labs/Jose
16 | *
17 | * thanks to Matt Gaunt and Mat Scale
18 | * https://web-push-book.gauntface.com/downloads/web-push-book.pdf
19 | * https://developers.google.com/web/updates/2016/03/web-push-encryption
20 | *
21 | * @package PNServer
22 | * @author Stefanius
23 | * @copyright MIT License - see the LICENSE file for details
24 | */
25 | class PNEncryption
26 | {
27 | use PNServerHelper;
28 |
29 | /** max length of the payload */
30 | const MAX_PAYLOAD_LENGTH = 4078;
31 | /** max compatible length of the payload */
32 | const MAX_COMPATIBILITY_PAYLOAD_LENGTH = 3052;
33 |
34 | /** @var string public key from subscription */
35 | protected string $strSubscrKey = '';
36 | /** @var string subscription authenthication code */
37 | protected string $strSubscrAuth = '';
38 | /** @var string encoding 'aesgcm' / 'aes128gcm' */
39 | protected string $strEncoding = '';
40 | /** @var string payload to encrypt */
41 | protected string $strPayload = '';
42 | /** @var string local generated public key */
43 | protected string $strLocalPublicKey = '';
44 | /** @var \GMP local generated private key */
45 | protected \GMP $gmpLocalPrivateKey;
46 | /** @var string generated salt */
47 | protected string $strSalt = '';
48 | /** @var string last error msg */
49 | protected string $strError = '';
50 |
51 | /**
52 | * @param string $strSubscrKey public key from subscription
53 | * @param string $strSubscrAuth subscription authenthication code
54 | * @param string $strEncoding encoding (default: 'aesgcm')
55 | */
56 | public function __construct(string $strSubscrKey, string $strSubscrAuth, string $strEncoding = 'aesgcm')
57 | {
58 | $this->strSubscrKey = self::decodeBase64URL($strSubscrKey);
59 | $this->strSubscrAuth = self::decodeBase64URL($strSubscrAuth);
60 | $this->strEncoding = $strEncoding;
61 | $this->strError = '';
62 | }
63 |
64 | /**
65 | * encrypt the payload.
66 | * @param string $strPayload
67 | * @return string|false encrypted string at success, false on any error
68 | */
69 | public function encrypt(string $strPayload)
70 | {
71 | $this->strError = '';
72 | $this->strPayload = $strPayload;
73 | $strContent = false;
74 |
75 | // there's nothing to encrypt without payload...
76 | if (strlen($strPayload) == 0) {
77 | // it's OK - just set content-length of request to 0!
78 | return '';
79 | }
80 |
81 | if ($this->strEncoding !== 'aesgcm' && $this->strEncoding !== 'aes128gcm') {
82 | $this->strError = "Encoding '" . $this->strEncoding . "' is not supported!";
83 | return false;
84 | }
85 |
86 | if (mb_strlen($this->strSubscrKey, '8bit') !== 65) {
87 | $this->strError = "Invalid client public key length!";
88 | return false;
89 | }
90 |
91 | try {
92 | // create random salt and local key pair
93 | $this->strSalt = \random_bytes(16);
94 | if (!$this->createLocalKey()) {
95 | return false;
96 | }
97 |
98 | // create shared secret between local private key and public subscription key
99 | $strSharedSecret = $this->getSharedSecret();
100 |
101 | // context and pseudo random key (PRK) to create content encryption key (CEK) and nonce
102 | /*
103 | * A nonce is a value that prevents replay attacks as it should only be used once.
104 | * The content encryption key (CEK) is the key that will ultimately be used toencrypt
105 | * our payload.
106 | * @link https://en.wikipedia.org/wiki/Cryptographic_nonce
107 | */
108 | $context = $this->createContext();
109 | $prk = $this->getPRK($strSharedSecret);
110 |
111 | // derive the encryption key
112 | $cekInfo = $this->createInfo($this->strEncoding, $context);
113 | $cek = self::hkdf($this->strSalt, $prk, $cekInfo, 16);
114 |
115 | // and the nonce
116 | $nonceInfo = $this->createInfo('nonce', $context);
117 | $nonce = self::hkdf($this->strSalt, $prk, $nonceInfo, 12);
118 |
119 | // pad payload ... from now payload converted to binary string
120 | $strPayload = $this->padPayload($strPayload, self::MAX_COMPATIBILITY_PAYLOAD_LENGTH);
121 |
122 | // encrypt
123 | // "The additional data passed to each invocation of AEAD_AES_128_GCM is a zero-length octet sequence."
124 | $strTag = '';
125 | $strEncrypted = openssl_encrypt($strPayload, 'aes-128-gcm', $cek, OPENSSL_RAW_DATA, $nonce, $strTag);
126 |
127 | // base64URL encode salt and local public key (for aes128gcm they are needed in binary form)
128 | if ($this->strEncoding === 'aesgcm') {
129 | $this->strSalt = self::encodeBase64URL($this->strSalt);
130 | $this->strLocalPublicKey = self::encodeBase64URL($this->strLocalPublicKey);
131 | }
132 |
133 | $strContent = $this->getContentCodingHeader() . $strEncrypted . $strTag;
134 | } catch (\RuntimeException $e) {
135 | $this->strError = $e->getMessage();
136 | $strContent = false;
137 | }
138 |
139 | return $strContent;
140 | }
141 |
142 | /**
143 | * Get headers for previous encrypted payload.
144 | * Already existing headers (e.g. the VAPID-signature) can be passed through the input param
145 | * and will be merged with the additional headers for the encryption
146 | *
147 | * @param array $aHeaders existing headers to merge with
148 | * @return array
149 | */
150 | public function getHeaders(?array $aHeaders = null) : array
151 | {
152 | if (!$aHeaders) {
153 | $aHeaders = array();
154 | }
155 | if (strlen($this->strPayload) > 0) {
156 | $aHeaders['Content-Type'] = 'application/octet-stream';
157 | $aHeaders['Content-Encoding'] = $this->strEncoding;
158 | if ($this->strEncoding === "aesgcm") {
159 | $aHeaders['Encryption'] = 'salt=' . $this->strSalt;
160 | if (isset($aHeaders['Crypto-Key'])) {
161 | $aHeaders['Crypto-Key'] = 'dh=' . $this->strLocalPublicKey . ';' . $aHeaders['Crypto-Key'];
162 | } else {
163 | $aHeaders['Crypto-Key'] = 'dh=' . $this->strLocalPublicKey;
164 | }
165 | }
166 | }
167 | return $aHeaders;
168 | }
169 |
170 | /**
171 | * @return string last error
172 | */
173 | public function getError() : string
174 | {
175 | return $this->strError;
176 | }
177 |
178 | /**
179 | * create local public/private key pair using prime256v1 curve
180 | * @return bool
181 | */
182 | private function createLocalKey() : bool
183 | {
184 | $bSucceeded = false;
185 | $keyResource = \openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]);
186 | if ($keyResource !== false) {
187 | $details = \openssl_pkey_get_details($keyResource);
188 | \openssl_pkey_free($keyResource);
189 |
190 | if ($details !== false) {
191 | $strLocalPublicKey = '04';
192 | $strLocalPublicKey .= str_pad(gmp_strval(gmp_init(bin2hex($details['ec']['x']), 16), 16), 64, '0', STR_PAD_LEFT);
193 | $strLocalPublicKey .= str_pad(gmp_strval(gmp_init(bin2hex($details['ec']['y']), 16), 16), 64, '0', STR_PAD_LEFT);
194 | $strLocalPublicKey = hex2bin($strLocalPublicKey);
195 | if ($strLocalPublicKey !== false) {
196 | $this->strLocalPublicKey = $strLocalPublicKey;
197 | }
198 | $this->gmpLocalPrivateKey = gmp_init(bin2hex($details['ec']['d']), 16);
199 | $bSucceeded = true;
200 | }
201 | }
202 | if (!$bSucceeded) {
203 | $this->strError = 'openssl: ' . \openssl_error_string();
204 | }
205 | return $bSucceeded;
206 | }
207 |
208 | /**
209 | * build shared secret from user public key and local private key using prime256v1 curve
210 | * @return string
211 | */
212 | private function getSharedSecret() : string
213 | {
214 |
215 | $curve = NistCurve::curve256();
216 |
217 | $x = '';
218 | $y = '';
219 | self::getXYFromPublicKey($this->strSubscrKey, $x, $y);
220 |
221 | $strSubscrKeyPoint = $curve->getPublicKeyFrom(\gmp_init(bin2hex($x), 16), \gmp_init(bin2hex($y), 16));
222 |
223 | // get shared secret from user public key and local private key
224 | $strSharedSecret = $curve->mul($strSubscrKeyPoint, $this->gmpLocalPrivateKey);
225 | $strSharedSecret = $strSharedSecret->getX();
226 | $strSharedSecret = hex2bin(str_pad(\gmp_strval($strSharedSecret, 16), 64, '0', STR_PAD_LEFT));
227 |
228 | return ($strSharedSecret !== false ? $strSharedSecret : '');
229 | }
230 |
231 | /**
232 | * get pseudo random key
233 | * @param string $strSharedSecret
234 | * @return string
235 | */
236 | private function getPRK(string $strSharedSecret) : string
237 | {
238 | if (!empty($this->strSubscrAuth)) {
239 | if ($this->strEncoding === "aesgcm") {
240 | $info = 'Content-Encoding: auth' . chr(0);
241 | } else {
242 | $info = "WebPush: info" . chr(0) . $this->strSubscrKey . $this->strLocalPublicKey;
243 | }
244 | $strSharedSecret = self::hkdf($this->strSubscrAuth, $strSharedSecret, $info, 32);
245 | }
246 |
247 | return $strSharedSecret;
248 | }
249 |
250 | /**
251 | * Creates a context for deriving encryption parameters.
252 | * See section 4.2 of
253 | * {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00}
254 | * From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}.
255 | *
256 | * @return null|string
257 | * @throws \ErrorException
258 | */
259 | private function createContext() : ?string
260 | {
261 | if ($this->strEncoding === "aes128gcm") {
262 | return null;
263 | }
264 |
265 | // This one should never happen, because it's our code that generates the key
266 | /*
267 | if (mb_strlen($this->strLocalPublicKey, '8bit') !== 65) {
268 | throw new \ErrorException('Invalid server public key length');
269 | }
270 | */
271 |
272 | $len = chr(0) . 'A'; // 65 as Uint16BE
273 |
274 | return chr(0) . $len . $this->strSubscrKey . $len . $this->strLocalPublicKey;
275 | }
276 |
277 | /**
278 | * Returns an info record. See sections 3.2 and 3.3 of
279 | * {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00}
280 | * From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}.
281 | *
282 | * @param string $strType The type of the info record
283 | * @param string|null $strContext The context for the record
284 | * @return string
285 | * @throws \ErrorException
286 | */
287 | private function createInfo(string $strType, ?string $strContext) : string
288 | {
289 | if ($this->strEncoding === "aesgcm") {
290 | if (!$strContext) {
291 | throw new \ErrorException('Context must exist');
292 | }
293 |
294 | if (mb_strlen($strContext, '8bit') !== 135) {
295 | throw new \ErrorException('Context argument has invalid size');
296 | }
297 |
298 | $strInfo = 'Content-Encoding: ' . $strType . chr(0) . 'P-256' . $strContext;
299 | } else {
300 | $strInfo = 'Content-Encoding: ' . $strType . chr(0);
301 | }
302 | return $strInfo;
303 | }
304 |
305 | /**
306 | * get the content coding header to add to encrypted payload
307 | * @return string
308 | */
309 | private function getContentCodingHeader() : string
310 | {
311 | $strHeader = '';
312 | if ($this->strEncoding === "aes128gcm") {
313 | $strHeader = $this->strSalt
314 | . pack('N*', 4096)
315 | . pack('C*', mb_strlen($this->strLocalPublicKey, '8bit'))
316 | . $this->strLocalPublicKey;
317 | }
318 | return $strHeader;
319 | }
320 |
321 | /**
322 | * pad the payload.
323 | * Before we encrypt our payload, we need to define how much padding we wish toadd to
324 | * the front of the payload. The reason we’d want to add padding is that it prevents
325 | * the risk of eavesdroppers being able to determine “types” of messagesbased on the
326 | * payload size. We must add two bytes of padding to indicate the length of any
327 | * additionalpadding.
328 | *
329 | * @param string $strPayload
330 | * @param int $iMaxLengthToPad
331 | * @return string
332 | */
333 | private function padPayload(string $strPayload, int $iMaxLengthToPad = 0) : string
334 | {
335 | $iLen = mb_strlen($strPayload, '8bit');
336 | $iPad = $iMaxLengthToPad ? $iMaxLengthToPad - $iLen : 0;
337 |
338 | if ($this->strEncoding === "aesgcm") {
339 | $strPayload = pack('n*', $iPad) . str_pad($strPayload, $iPad + $iLen, chr(0), STR_PAD_LEFT);
340 | } elseif ($this->strEncoding === "aes128gcm") {
341 | $strPayload = str_pad($strPayload . chr(2), $iPad + $iLen, chr(0), STR_PAD_RIGHT);
342 | }
343 | return $strPayload;
344 | }
345 |
346 | /**
347 | * HMAC-based Extract-and-Expand Key Derivation Function (HKDF).
348 | *
349 | * This is used to derive a secure encryption key from a mostly-secure shared
350 | * secret.
351 | *
352 | * This is a partial implementation of HKDF tailored to our specific purposes.
353 | * In particular, for us the value of N will always be 1, and thus T always
354 | * equals HMAC-Hash(PRK, info | 0x01).
355 | *
356 | * See {@link https://www.rfc-editor.org/rfc/rfc5869.txt}
357 | * From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}
358 | *
359 | * @param string $salt A non-secret random value
360 | * @param string $ikm Input keying material
361 | * @param string $info Application-specific context
362 | * @param int $length The length (in bytes) of the required output key
363 | *
364 | * @return string
365 | */
366 | private static function hkdf(string $salt, string $ikm, string $info, int $length) : string
367 | {
368 | // extract
369 | $prk = hash_hmac('sha256', $ikm, $salt, true);
370 |
371 | // expand
372 | return mb_substr(hash_hmac('sha256', $info . chr(1), $prk, true), 0, $length, '8bit');
373 | }
374 | }
375 |
--------------------------------------------------------------------------------
/SKien/PNServer/PNPayload.php:
--------------------------------------------------------------------------------
1 |
17 | * @copyright MIT License - see the LICENSE file for details
18 | */
19 | class PNPayload
20 | {
21 | use PNServerHelper;
22 |
23 | /** @var array */
24 | protected array $aPayload;
25 |
26 | /**
27 | * Create instance of payload with title, text and icon to display.
28 | * - title should be short and meaningfull.
29 | * - The text should not increase 200 characters - the different browsers and
30 | * platforms limit the display differently (partly according to the number of
31 | * lines, others according to the number of characters)
32 | * - icon should be square (if not, some browsers/platforms cut a square). There
33 | * is no exact specification for the 'optimal' size, 64dp (px * device pixel ratio)
34 | * should be a good decision (... 192px for highest device pixel ratio)
35 | *
36 | * @param string $strTitle Title to display
37 | * @param string $strText A string representing an extra content to display within the notification.
38 | * @param string $strIcon containing the URL of an image to be used as an icon by the notification.
39 | */
40 | public function __construct(string $strTitle, ?string $strText = null, ?string $strIcon = null)
41 | {
42 | $this->aPayload = array(
43 | 'title' => $strTitle,
44 | 'opt' => array(
45 | 'body' => $strText,
46 | 'icon' => $strIcon,
47 | ),
48 | );
49 | }
50 |
51 | /**
52 | * Note: the URL is no part of the JS showNotification() - Options!
53 | * @param string $strURL URL to open when user click on the notification.
54 | */
55 | public function setURL(string $strURL) : void
56 | {
57 | if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) {
58 | if (!isset($this->aPayload['opt']['data']) || !is_array($this->aPayload['opt']['data'])) {
59 | $this->aPayload['opt']['data'] = array();
60 | }
61 | $this->aPayload['opt']['data']['url'] = $strURL;
62 | }
63 | }
64 |
65 | /**
66 | * An ID for a given notification that allows you to find, replace, or remove the notification using
67 | * a script if necessary.
68 | * If set, multiple notifications with the same tag will only reappear if $bReNotify is set to true.
69 | * Usualy the last notification with same tag is displayed in this case.
70 | *
71 | * @param string $strTag
72 | * @param bool $bReNotify
73 | */
74 | public function setTag(string $strTag, bool $bReNotify = false) : void
75 | {
76 | if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) {
77 | $this->aPayload['opt']['tag'] = $strTag;
78 | $this->aPayload['opt']['renotify'] = $bReNotify;
79 | }
80 | }
81 |
82 | /**
83 | * containing the URL of an larger image to be displayed in the notification.
84 | * Size, position and cropping vary with the different browsers and platforms
85 | * @param string $strImage
86 | */
87 | public function setImage(string $strImage) : void
88 | {
89 | if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) {
90 | $this->aPayload['opt']['image'] = $strImage;
91 | }
92 | }
93 |
94 | /**
95 | * containing the URL of an badge assigend to the notification.
96 | * The badge is a small monochrome icon that is used to portray a little
97 | * more information to the user about where the notification is from.
98 | * So far I have only found Chrome for Android that supports the badge...
99 | * ... in most cases the browsers icon is displayed.
100 | *
101 | * @param string $strBadge
102 | */
103 | public function setBadge(string $strBadge) : void
104 | {
105 | if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) {
106 | $this->aPayload['opt']['badge'] = $strBadge;
107 | }
108 | }
109 |
110 | /**
111 | * Add action to display in the notification.
112 | *
113 | * The count of action that can be displayed vary between browser/platform. On
114 | * the client it can be detected with javascript: Notification.maxActions
115 | *
116 | * Appropriate responses have to be implemented within the notificationclick event.
117 | * the event.action property contains the $strAction clicked on
118 | *
119 | * @param string $strAction identifying a user action to be displayed on the notification.
120 | * @param string $strTitle containing action text to be shown to the user.
121 | * @param string $strIcon containing the URL of an icon to display with the action.
122 | * @param string $strCustom custom info - not part of the showNotification()- Options!
123 | */
124 | public function addAction(string $strAction, string $strTitle, ?string $strIcon = null, string $strCustom = '') : void
125 | {
126 | if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) {
127 | if (!isset($this->aPayload['opt']['actions']) || !is_array($this->aPayload['opt']['actions'])) {
128 | $this->aPayload['opt']['actions'] = array();
129 | }
130 | $this->aPayload['opt']['actions'][] = array('action' => $strAction, 'title' => $strTitle, 'icon' => $strIcon, 'custom' => $strCustom);
131 | }
132 | }
133 |
134 | /**
135 | * Set the time when the notification was created.
136 | * It can be used to indicate the time at which a notification is actual. For example, this could
137 | * be in the past when a notification is used for a message that couldn’t immediately be delivered
138 | * because the device was offline, or in the future for a meeting that is about to start.
139 | *
140 | * @param mixed $timestamp DateTime object, UNIX timestamp or English textual datetime description
141 | */
142 | public function setTimestamp($timestamp) : void
143 | {
144 | if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) {
145 | $iTimestamp = $timestamp;
146 | if (self::className($timestamp) == 'DateTime') {
147 | // DateTime -object
148 | $iTimestamp = $timestamp->getTimestamp();
149 | } else if (is_string($timestamp)) {
150 | // string
151 | $iTimestamp = strtotime($timestamp);
152 | }
153 | // timestamp in milliseconds!
154 | $this->aPayload['opt']['timestamp'] = bcmul((string) $iTimestamp, '1000');
155 | }
156 | }
157 |
158 | /**
159 | * Indicates that on devices with sufficiently large screens, a notification should remain active until
160 | * the user clicks or dismisses it. If this value is absent or false, the desktop version of Chrome
161 | * will auto-minimize notifications after approximately twenty seconds. Implementation depends on
162 | * browser and plattform.
163 | *
164 | * @param bool $bSet
165 | */
166 | public function requireInteraction(bool $bSet = true) : void
167 | {
168 | if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) {
169 | $this->aPayload['opt']['requireInteraction'] = $bSet;
170 | }
171 | }
172 |
173 | /**
174 | * Indicates that no sounds or vibrations should be made.
175 | * If this 'mute' function is activated, a previously set vibration is reset to prevent a TypeError exception.
176 | * @param bool $bSet
177 | */
178 | public function setSilent(bool $bSet = true) : void
179 | {
180 | if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) {
181 | $this->aPayload['opt']['silent'] = $bSet;
182 | if ($bSet && isset($this->aPayload['opt']['vibrate'])) {
183 | // silent=true and defined vibation causes TypeError
184 | unset($this->aPayload['opt']['vibrate']);
185 | }
186 | }
187 | }
188 |
189 | /**
190 | * A vibration pattern to run with the display of the notification.
191 | * A vibration pattern can be an array with as few as one member. The values are times in milliseconds
192 | * where the even indices (0, 2, 4, etc.) indicate how long to vibrate and the odd indices indicate
193 | * how long to pause. For example, [300, 100, 400] would vibrate 300ms, pause 100ms, then vibrate 400ms.
194 | *
195 | * @param array $aPattern
196 | */
197 | public function setVibration(array $aPattern) : void
198 | {
199 | if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) {
200 | $this->aPayload['opt']['vibrate'] = $aPattern;
201 | if (isset($this->aPayload['opt']['silent'])) {
202 | // silent=true and vibation pattern causes TypeError
203 | $this->aPayload['opt']['silent'] = false;
204 | }
205 | }
206 | }
207 |
208 | /**
209 | * containing the URL of an sound - file (mp3 or wav).
210 | * currently not found any browser supports sounds
211 | * @param string $strSound
212 | */
213 | public function setSound(string $strSound) : void
214 | {
215 | if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) {
216 | $this->aPayload['opt']['sound'] = $strSound;
217 | }
218 | }
219 |
220 | /**
221 | * Get the Payload data as array
222 | * @return array
223 | */
224 | public function getPayload() : array
225 | {
226 | return $this->aPayload;
227 | }
228 |
229 | /**
230 | * Convert payload dasta to JSON string.
231 | * @return string JSON string representing payloal
232 | */
233 | public function toJSON() : string
234 | {
235 | $strJson = json_encode($this->aPayload);
236 | return utf8_encode($strJson !== false ? $strJson : '');
237 | }
238 |
239 | /**
240 | * @return string JSON string representing payloal
241 | */
242 | public function __toString() : string
243 | {
244 | return $this->toJSON();
245 | }
246 |
247 | }
248 |
--------------------------------------------------------------------------------
/SKien/PNServer/PNServer.php:
--------------------------------------------------------------------------------
1 |
14 | * @copyright MIT License - see the LICENSE file for details
15 | */
16 | class PNServer
17 | {
18 | use LoggerAwareTrait;
19 | use PNServerHelper;
20 |
21 | /** @var PNDataProvider dataprovider */
22 | protected ?PNDataProvider $oDP = null;
23 | /** @var bool set when data has been loaded from DB */
24 | protected bool $bFromDB = false;
25 | /** @var bool auto remove invalid/expired subscriptions */
26 | protected bool $bAutoRemove = true;
27 | /** @var PNVapid */
28 | protected ?PNVapid $oVapid = null;
29 | /** @var string */
30 | protected string $strPayload = '';
31 | /** @var array */
32 | protected array $aSubscription = [];
33 | /** @var array> */
34 | protected array $aLog = [];
35 | /** @var int $iAutoRemoved count of items autoremoved in loadSubscriptions */
36 | protected int $iAutoRemoved = 0;
37 | /** @var int $iExpired count of expired items */
38 | protected int $iExpired = 0;
39 | /** @var string last error msg */
40 | protected string $strError = '';
41 |
42 | /**
43 | * create instance.
44 | * if $oDP specified, subscriptions can be loaded direct from data Source
45 | * and invalid or expired subscriptions will be removed automatically in
46 | * case rejection from the push service.
47 | *
48 | * @param PNDataProvider $oDP
49 | */
50 | public function __construct(?PNDataProvider $oDP = null)
51 | {
52 | $this->oDP = $oDP;
53 | $this->reset();
54 | $this->logger = new NullLogger();
55 | }
56 |
57 | /**
58 | * @return PNDataProvider
59 | */
60 | public function getDP() : ?PNDataProvider
61 | {
62 | return $this->oDP;
63 | }
64 |
65 | /**
66 | * reset ll to begin new push notification.
67 | */
68 | public function reset() : void
69 | {
70 | $this->bFromDB = false;
71 | $this->strPayload = '';
72 | $this->oVapid = null;
73 | $this->aSubscription = [];
74 | $this->aLog = [];
75 | }
76 |
77 | /**
78 | * set VAPID subject and keys.
79 | * @param PNVapid $oVapid
80 | */
81 | public function setVapid(PNVapid $oVapid) : void
82 | {
83 | $this->oVapid = $oVapid;
84 | }
85 |
86 | /**
87 | * set payload used for all push notifications.
88 | * @param mixed $payload string or PNPayload object
89 | */
90 | public function setPayload($payload) : void
91 | {
92 | if (is_string($payload) || self::className($payload) == 'PNPayload') {
93 | $this->strPayload = (string) $payload;
94 | }
95 | }
96 |
97 | /**
98 | * @return string
99 | */
100 | public function getPayload() : string
101 | {
102 | return $this->strPayload;
103 | }
104 |
105 | /**
106 | * add subscription to the notification list.
107 | * @param PNSubscription $oSubscription
108 | */
109 | public function addSubscription(PNSubscription $oSubscription) : void
110 | {
111 | if ($oSubscription->isValid()) {
112 | $this->aSubscription[] = $oSubscription;
113 | }
114 | $this->logger->info(__CLASS__ . ': ' . 'added {state} Subscription.', ['state' => $oSubscription->isValid() ? 'valid' : 'invalid']);
115 | }
116 |
117 | /**
118 | * Get the count of valid subscriptions set.
119 | * @return int
120 | */
121 | public function getSubscriptionCount() : int
122 | {
123 | return count($this->aSubscription);
124 | }
125 |
126 | /**
127 | * Load subscriptions from internal DataProvider.
128 | * if $this->bAutoRemove set (default: true), expired subscriptions will
129 | * be automatically removed from the data source.
130 | * @return bool
131 | */
132 | public function loadSubscriptions() : bool
133 | {
134 | $bSucceeded = false;
135 | $this->aSubscription = [];
136 | $this->iAutoRemoved = 0;
137 | $this->iExpired = 0;
138 | if ($this->oDP !== null) {
139 | $iBefore = $this->oDP->count();
140 | if (($bSucceeded = $this->oDP->init($this->bAutoRemove)) !== false) {
141 | $this->bFromDB = true;
142 | $this->iAutoRemoved = $iBefore - $this->oDP->count();
143 | while (($strJsonSub = $this->oDP->fetch()) !== false) {
144 | $this->addSubscription(PNSubscription::fromJSON((string) $strJsonSub));
145 | }
146 | // if $bAutoRemove is false, $this->iExpired may differs from $this->iAutoRemoved
147 | $this->iExpired = $iBefore - count($this->aSubscription);
148 | $this->logger->info(__CLASS__ . ': ' . 'added {count} Subscriptions from DB.', ['count' => count($this->aSubscription)]);
149 | } else {
150 | $this->strError = $this->oDP->getError();
151 | $this->logger->error(__CLASS__ . ': ' . $this->strError);
152 | }
153 | } else {
154 | $this->strError = 'missing dataprovider!';
155 | $this->logger->error(__CLASS__ . ': ' . $this->strError);
156 | }
157 | return $bSucceeded;
158 | }
159 |
160 | /**
161 | * auto remove invalid/expired subscriptions.
162 | * has only affect, if data loaded through DataProvider
163 | * @param bool $bAutoRemove
164 | */
165 | public function setAutoRemove(bool $bAutoRemove = true) : void
166 | {
167 | $this->bAutoRemove = $bAutoRemove;
168 | }
169 |
170 | /**
171 | * push all notifications.
172 | *
173 | * Since a large number is expected when sending PUSH notifications, the
174 | * POST requests are generated asynchronously via a cURL multi handle.
175 | * The response codes are then assigned to the respective end point and a
176 | * transmission log is generated.
177 | * If the subscriptions comes from the internal data provider, all
178 | * subscriptions that are no longer valid or that are no longer available
179 | * with the push service will be removed from the database.
180 | * @return bool
181 | */
182 | public function push() : bool
183 | {
184 | if (!$this->oVapid) {
185 | $this->strError = 'no VAPID-keys set!';
186 | $this->logger->error(__CLASS__ . ': ' . $this->strError);
187 | } elseif (!$this->oVapid->isValid()) {
188 | $this->strError = 'VAPID error: ' . $this->oVapid->getError();
189 | $this->logger->error(__CLASS__ . ': ' . $this->strError);
190 | } elseif (count($this->aSubscription) == 0) {
191 | $this->strError = 'no valid Subscriptions set!';
192 | $this->logger->warning(__CLASS__ . ': ' . $this->strError);
193 | } else {
194 | // create multi requests...
195 | $mcurl = curl_multi_init();
196 | if ($mcurl !== false) {
197 | $aRequests = array();
198 |
199 | foreach ($this->aSubscription as $oSub) {
200 | $aLog = ['msg' => '', 'curl_response' => '', 'curl_response_code' => -1];
201 | // payload must be encrypted every time although it does not change, since
202 | // each subscription has at least his public key and authentication token of its own ...
203 | $oEncrypt = new PNEncryption($oSub->getPublicKey(), $oSub->getAuth(), $oSub->getEncoding());
204 | if (($strContent = $oEncrypt->encrypt($this->strPayload)) !== false) {
205 | // merge headers from encryption and VAPID (maybe both containing 'Crypto-Key')
206 | if (($aVapidHeaders = $this->oVapid->getHeaders($oSub->getEndpoint())) !== false) {
207 | $aHeaders = $oEncrypt->getHeaders($aVapidHeaders);
208 | $aHeaders['Content-Length'] = mb_strlen($strContent, '8bit');
209 | $aHeaders['TTL'] = 2419200;
210 |
211 | // build Http - Headers
212 | $aHttpHeader = array();
213 | foreach ($aHeaders as $strName => $strValue) {
214 | $aHttpHeader[] = $strName . ': ' . $strValue;
215 | }
216 |
217 | // and send request with curl
218 | $curl = curl_init($oSub->getEndpoint());
219 |
220 | if ($curl !== false) {
221 | curl_setopt($curl, CURLOPT_POST, true);
222 | curl_setopt($curl, CURLOPT_POSTFIELDS, $strContent);
223 | curl_setopt($curl, CURLOPT_HTTPHEADER, $aHttpHeader);
224 | curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
225 |
226 | curl_multi_add_handle($mcurl, $curl);
227 |
228 | $aRequests[$oSub->getEndpoint()] = $curl;
229 | }
230 | } else {
231 | $aLog['msg'] = 'VAPID error: ' . $this->oVapid->getError();
232 | }
233 | } else {
234 | $aLog['msg'] = 'Payload encryption error: ' . $oEncrypt->getError();
235 | }
236 | if (strlen($aLog['msg']) > 0) {
237 | $this->aLog[$oSub->getEndpoint()] = $aLog;
238 | }
239 | }
240 |
241 | if (count($aRequests) > 0) {
242 | // now performing multi request...
243 | $iRunning = null;
244 | do {
245 | $iMState = curl_multi_exec($mcurl, $iRunning);
246 | } while ($iRunning && $iMState == CURLM_OK);
247 |
248 | if ($iMState == CURLM_OK) {
249 | // ...and get response of each request
250 | foreach ($aRequests as $strEndPoint => $curl) {
251 | $aLog = array();
252 | $iRescode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
253 |
254 | $aLog['msg'] = $this->getPushServiceResponseText($iRescode);
255 | $aLog['curl_response'] = curl_multi_getcontent($curl);
256 | $aLog['curl_response_code'] = $iRescode;
257 | $this->aLog[$strEndPoint] = $aLog;
258 | // remove handle from multi and close
259 | curl_multi_remove_handle($mcurl, $curl);
260 | curl_close($curl);
261 | }
262 |
263 | } else {
264 | $this->strError = 'curl_multi_exec() Erroro: ' . curl_multi_strerror($iMState);
265 | $this->logger->error(__CLASS__ . ': ' . $this->strError);
266 | }
267 | // ... close the door
268 | curl_multi_close($mcurl);
269 | }
270 | if ($this->oDP != null && $this->bFromDB && $this->bAutoRemove) {
271 | foreach ($this->aLog as $strEndPoint => $aLogItem) {
272 | if ($this->checkAutoRemove($aLogItem['curl_response_code'])) {
273 | // just remove subscription from DB
274 | $aLogItem['msg'] .= ' Subscription removed from DB!';
275 | $this->oDP->removeSubscription($strEndPoint);
276 | }
277 | }
278 | }
279 | }
280 | }
281 | $this->logger->info(__CLASS__ . ': ' . 'notifications pushed', $this->getSummary());
282 | return (strlen($this->strError) == 0);
283 | }
284 |
285 | /**
286 | * Push one single subscription.
287 | * @param PNSubscription $oSub
288 | * @return bool
289 | */
290 | public function pushSingle(PNSubscription $oSub) : bool
291 | {
292 | if (!$this->oVapid) {
293 | $this->strError = 'no VAPID-keys set!';
294 | $this->logger->error(__CLASS__ . ': ' . $this->strError);
295 | } elseif (!$this->oVapid->isValid()) {
296 | $this->strError = 'VAPID error: ' . $this->oVapid->getError();
297 | $this->logger->error(__CLASS__ . ': ' . $this->strError);
298 | } else {
299 | $aLog = ['msg' => '', 'curl_response' => '', 'curl_response_code' => -1];
300 | // payload must be encrypted every time although it does not change, since
301 | // each subscription has at least his public key and authentication token of its own ...
302 | $oEncrypt = new PNEncryption($oSub->getPublicKey(), $oSub->getAuth(), $oSub->getEncoding());
303 | if (($strContent = $oEncrypt->encrypt($this->strPayload)) !== false) {
304 | // merge headers from encryption and VAPID (maybe both containing 'Crypto-Key')
305 | if (($aVapidHeaders = $this->oVapid->getHeaders($oSub->getEndpoint())) !== false) {
306 | $aHeaders = $oEncrypt->getHeaders($aVapidHeaders);
307 | $aHeaders['Content-Length'] = mb_strlen($strContent, '8bit');
308 | $aHeaders['TTL'] = 2419200;
309 |
310 | // build Http - Headers
311 | $aHttpHeader = array();
312 | foreach ($aHeaders as $strName => $strValue) {
313 | $aHttpHeader[] = $strName . ': ' . $strValue;
314 | }
315 |
316 | // and send request with curl
317 | $curl = curl_init($oSub->getEndpoint());
318 |
319 | if ($curl !== false) {
320 | curl_setopt($curl, CURLOPT_POST, true);
321 | curl_setopt($curl, CURLOPT_POSTFIELDS, $strContent);
322 | curl_setopt($curl, CURLOPT_HTTPHEADER, $aHttpHeader);
323 | curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
324 |
325 | if (($strResponse = curl_exec($curl)) !== false) {
326 | $iRescode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
327 |
328 | $aLog['msg'] = $this->getPushServiceResponseText($iRescode);
329 | $aLog['curl_response'] = $strResponse;
330 | $aLog['curl_response_code'] = $iRescode;
331 | curl_close($curl);
332 | }
333 | }
334 | } else {
335 | $aLog['msg'] = 'VAPID error: ' . $this->oVapid->getError();
336 | }
337 | } else {
338 | $aLog['msg'] = 'Payload encryption error: ' . $oEncrypt->getError();
339 | }
340 | $this->aLog[$oSub->getEndpoint()] = $aLog;
341 | }
342 | $this->logger->info(__CLASS__ . ': ' . 'single notifications pushed.');
343 | return (strlen($this->strError) == 0);
344 | }
345 |
346 | /**
347 | * @return array>
348 | */
349 | public function getLog() : array
350 | {
351 | return $this->aLog;
352 | }
353 |
354 | /**
355 | * Build summary for the log of the last push operation.
356 | * - total count of subscriptions processed
357 | * - count of successfull pushed messages
358 | * - count of failed messages (subscriptions couldn't be pushed of any reason)
359 | * - count of expired subscriptions
360 | * - count of removed subscriptions (expired, gone, not found, invalid)
361 | * The count of expired entries removed in the loadSubscriptions() is added to
362 | * the count of responsecode caused removed items.
363 | * The count of failed and removed messages may differ even if $bAutoRemove is set
364 | * if there are transferns with responsecode 413 or 429
365 | * @return array
366 | */
367 | public function getSummary() : array
368 | {
369 | $aSummary = [
370 | 'total' => $this->iExpired,
371 | 'pushed' => 0,
372 | 'failed' => 0,
373 | 'expired' => $this->iExpired,
374 | 'removed' => $this->iAutoRemoved,
375 | ];
376 | foreach ($this->aLog as $aLogItem) {
377 | $aSummary['total']++;
378 | if ($aLogItem['curl_response_code'] == 201) {
379 | $aSummary['pushed']++;
380 | } else {
381 | $aSummary['failed']++;
382 | if ($this->checkAutoRemove($aLogItem['curl_response_code'])) {
383 | $aSummary['removed']++;
384 | }
385 | }
386 | }
387 | return $aSummary;
388 | }
389 |
390 | /**
391 | * @return string last error
392 | */
393 | public function getError() : string
394 | {
395 | return $this->strError;
396 | }
397 |
398 | /**
399 | * Check if item should be removed.
400 | * We remove items with responsecode
401 | * -> 0: unknown responsecode (usually unknown/invalid endpoint origin)
402 | * -> -1: Payload encryption error
403 | * -> 400: Invalid request
404 | * -> 404: Not Found
405 | * -> 410: Gone
406 | *
407 | * @param int $iRescode
408 | * @return bool
409 | */
410 | protected function checkAutoRemove(int $iRescode) : bool
411 | {
412 | $aRemove = $this->bAutoRemove ? [-1, 0, 400, 404, 410] : [];
413 | return in_array($iRescode, $aRemove);
414 | }
415 |
416 | /**
417 | * get text according to given push service responsecode
418 | *
419 | * push service response codes
420 | * 201: The request to send a push message was received and accepted.
421 | * 400: Invalid request. This generally means one of your headers is invalid or improperly formatted.
422 | * 404: Not Found. This is an indication that the subscription is expired and can't be used. In this case
423 | * you should delete the PushSubscription and wait for the client to resubscribe the user.
424 | * 410: Gone. The subscription is no longer valid and should be removed from application server. This can
425 | * be reproduced by calling `unsubscribe()` on a `PushSubscription`.
426 | * 413: Payload size too large. The minimum size payload a push service must support is 4096 bytes (or 4kb).
427 | * 429: Too many requests. Meaning your application server has reached a rate limit with a push service.
428 | * The push service should include a 'Retry-After' header to indicate how long before another request
429 | * can be made.
430 | *
431 | * @param int $iRescode
432 | * @return string
433 | */
434 | protected function getPushServiceResponseText(int $iRescode) : string
435 | {
436 | $strText = 'unknwown Rescode from push service: ' . $iRescode;
437 | $aText = array(
438 | 201 => "The request to send a push message was received and accepted.",
439 | 400 => "Invalid request. Invalid headers or improperly formatted.",
440 | 404 => "Not Found. Subscription is expired and can't be used anymore.",
441 | 410 => "Gone. Subscription is no longer valid.", // This can be reproduced by calling 'unsubscribe()' on a 'PushSubscription'.
442 | 413 => "Payload size too large.",
443 | 429 => "Too many requests. Your application server has reached a rate limit with a push service."
444 | );
445 | if (isset($aText[$iRescode])) {
446 | $strText = $aText[$iRescode];
447 | }
448 | return $strText;
449 | }
450 | }
451 |
--------------------------------------------------------------------------------
/SKien/PNServer/PNServerHelper.php:
--------------------------------------------------------------------------------
1 |
11 | * @copyright MIT License - see the LICENSE file for details
12 | */
13 | trait PNServerHelper
14 | {
15 | /**
16 | * Get classname without namespace.
17 | * @param mixed $o
18 | * @return string
19 | */
20 | public static function className($o) : string
21 | {
22 | $strName = '';
23 | if (is_object($o)) {
24 | $path = explode('\\', get_class($o));
25 | $strName = array_pop($path);
26 | }
27 | return $strName;
28 | }
29 |
30 | /**
31 | * Encode data to Base64URL.
32 | * @param string $data
33 | * @return string encoded string
34 | */
35 | public static function encodeBase64URL(string $data) : string
36 | {
37 | // Convert Base64 to Base64URL by replacing “+” with “-” and “/” with “_”
38 | $url = strtr(base64_encode($data), '+/', '-_');
39 |
40 | // Remove padding character from the end of line and return the Base64URL result
41 | return rtrim($url, '=');
42 | }
43 |
44 | /**
45 | * Decode data from Base64URL.
46 | * If the strict parameter is set to TRUE then the function will return false
47 | * if the input contains character from outside the base64 alphabet. Otherwise
48 | * invalid characters will be silently discarded.
49 | * @param string $data
50 | * @param boolean $strict
51 | * @return string
52 | */
53 | public static function decodeBase64URL(string $data, bool $strict = false) : string
54 | {
55 | // Convert Base64URL to Base64 by replacing “-” with “+” and “_” with “/”
56 | $b64 = strtr($data, '-_', '+/');
57 |
58 | // Decode Base64 string and return the original data
59 | $strDecoded = base64_decode($b64, $strict);
60 |
61 | return $strDecoded !== false ? $strDecoded : 'error';
62 | }
63 |
64 | public static function getP256PEM(string $strPublicKey, string $strPrivateKey) : string
65 | {
66 | $der = self::p256PrivateKey($strPrivateKey);
67 | $der .= $strPublicKey;
68 |
69 | $pem = '-----BEGIN EC PRIVATE KEY-----' . PHP_EOL;
70 | $pem .= chunk_split(base64_encode($der), 64, PHP_EOL);
71 | $pem .= '-----END EC PRIVATE KEY-----' . PHP_EOL;
72 |
73 | return $pem;
74 | }
75 |
76 | private static function p256PrivateKey(string $strPrivateKey) : string
77 | {
78 | $aUP = \unpack('H*', str_pad($strPrivateKey, 32, "\0", STR_PAD_LEFT));
79 | $key = '';
80 | if ($aUP !== false) {
81 | $key = $aUP[1];
82 | }
83 | return pack(
84 | 'H*',
85 | '3077' // SEQUENCE, length 87+length($d)=32
86 | . '020101' // INTEGER, 1
87 | . '0420' // OCTET STRING, length($d) = 32
88 | . $key
89 | . 'a00a' // TAGGED OBJECT #0, length 10
90 | . '0608' // OID, length 8
91 | . '2a8648ce3d030107' // 1.3.132.0.34 = P-256 Curve
92 | . 'a144' // TAGGED OBJECT #1, length 68
93 | . '0342' // BIT STRING, length 66
94 | . '00' // prepend with NUL - pubkey will follow
95 | );
96 | }
97 |
98 | /**
99 | * @param string $der
100 | * @return string|false
101 | */
102 | public static function signatureFromDER(string $der)
103 | {
104 | $sig = false;
105 | $R = false;
106 | $S = false;
107 | $aUP = \unpack('H*', $der);
108 | $hex = '';
109 | if ($aUP !== false) {
110 | $hex = $aUP[1];
111 | }
112 | if ('30' === \mb_substr($hex, 0, 2, '8bit')) {
113 | // SEQUENCE
114 | if ('81' === \mb_substr($hex, 2, 2, '8bit')) {
115 | // LENGTH > 128
116 | $hex = \mb_substr($hex, 6, null, '8bit');
117 | } else {
118 | $hex = \mb_substr($hex, 4, null, '8bit');
119 | }
120 | if ('02' === \mb_substr($hex, 0, 2, '8bit')) {
121 | // INTEGER
122 | $Rl = (int) \hexdec(\mb_substr($hex, 2, 2, '8bit'));
123 | $R = self::retrievePosInt(\mb_substr($hex, 4, $Rl * 2, '8bit'));
124 | $R = \str_pad($R, 64, '0', STR_PAD_LEFT);
125 |
126 | $hex = \mb_substr($hex, 4 + $Rl * 2, null, '8bit');
127 | if ('02' === \mb_substr($hex, 0, 2, '8bit')) {
128 | // INTEGER
129 | $Sl = (int) \hexdec(\mb_substr($hex, 2, 2, '8bit'));
130 | $S = self::retrievePosInt(\mb_substr($hex, 4, $Sl * 2, '8bit'));
131 | $S = \str_pad($S, 64, '0', STR_PAD_LEFT);
132 | }
133 | }
134 | }
135 |
136 | if ($R !== false && $S !== false) {
137 | $sig = \pack('H*', $R . $S);
138 | }
139 |
140 | return $sig;
141 | }
142 |
143 | private static function retrievePosInt(string $data) : string
144 | {
145 | while ('00' === \mb_substr($data, 0, 2, '8bit') && \mb_substr($data, 2, 2, '8bit') > '7f') {
146 | $data = \mb_substr($data, 2, null, '8bit');
147 | }
148 |
149 | return $data;
150 | }
151 |
152 | public static function getXYFromPublicKey(string $strKey, string &$x, string &$y) : bool
153 | {
154 | $bSucceeded = false;
155 | $hexData = bin2hex($strKey);
156 | if (mb_substr($hexData, 0, 2, '8bit') === '04') {
157 | $hexData = mb_substr($hexData, 2, null, '8bit');
158 | $dataLength = mb_strlen($hexData, '8bit');
159 |
160 | $x = hex2bin(mb_substr($hexData, 0, $dataLength / 2, '8bit'));
161 | $y = hex2bin(mb_substr($hexData, $dataLength / 2, null, '8bit'));
162 | }
163 | return $bSucceeded;
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/SKien/PNServer/PNSubscription.php:
--------------------------------------------------------------------------------
1 |
11 | * @copyright MIT License - see the LICENSE file for details
12 | */
13 | class PNSubscription
14 | {
15 | use PNServerHelper;
16 |
17 | /** @var string the endpoint URL for the push notification */
18 | protected string $strEndpoint = '';
19 | /** @var string public key */
20 | protected string $strPublicKey = '';
21 | /** @var string authentification token */
22 | protected string $strAuth = '';
23 | /** @var int unix timesatmp of expiration (0, if no expiration defined) */
24 | protected int $timeExpiration = 0;
25 | /** @var string encoding ('aesgcm' / 'aes128gcm') */
26 | protected string $strEncoding = '';
27 |
28 | /**
29 | * Use static method PNSubscription::fromJSON() instead of new-operator
30 | * if data is available as JSON-string
31 | * @param string $strEndpoint
32 | * @param string $strPublicKey
33 | * @param string $strAuth
34 | * @param int $timeExpiration
35 | * @param string $strEncoding
36 | */
37 | public function __construct(string $strEndpoint, string $strPublicKey, string $strAuth, int $timeExpiration = 0, string $strEncoding = 'aesgcm')
38 | {
39 | $this->strEndpoint = $strEndpoint;
40 | $this->strPublicKey = $strPublicKey;
41 | $this->strAuth = $strAuth;
42 | $this->timeExpiration = $timeExpiration;
43 | $this->strEncoding = $strEncoding;
44 | }
45 |
46 | /**
47 | * @param string $strJSON subscription as valid JSON string
48 | * @return PNSubscription
49 | */
50 | public static function fromJSON(string $strJSON) : PNSubscription
51 | {
52 | $strEndpoint = '';
53 | $strPublicKey = '';
54 | $strAuth = '';
55 | $timeExpiration = 0;
56 | $aJSON = json_decode($strJSON, true);
57 | if (isset($aJSON['endpoint'])) {
58 | $strEndpoint = $aJSON['endpoint'];
59 | }
60 | if (isset($aJSON['expirationTime'])) {
61 | $timeExpiration = intval(bcdiv($aJSON['expirationTime'], '1000'));
62 | }
63 | if (isset($aJSON['keys'])) {
64 | if (isset($aJSON['keys']['p256dh'])) {
65 | $strPublicKey = $aJSON['keys']['p256dh'];
66 | }
67 | if (isset($aJSON['keys']['auth'])) {
68 | $strAuth = $aJSON['keys']['auth'];
69 | }
70 | }
71 | return new self($strEndpoint, $strPublicKey, $strAuth, $timeExpiration);
72 | }
73 |
74 | /**
75 | * basic check if object containing valid data
76 | * - endpoint, public key and auth token must be set
77 | * - only encoding 'aesgcm' or 'aes128gcm' supported
78 | * @return bool
79 | */
80 | public function isValid() : bool
81 | {
82 | $bValid = false;
83 | if (!$this->isExpired()) {
84 | $bValid = (
85 | isset($this->strEndpoint) && strlen($this->strEndpoint) > 0 &&
86 | isset($this->strPublicKey) && strlen($this->strPublicKey) > 0 &&
87 | isset($this->strAuth) && strlen($this->strAuth) > 0 &&
88 | ($this->strEncoding == 'aesgcm' || $this->strEncoding == 'aes128gcm')
89 | );
90 | }
91 | return $bValid;
92 | }
93 |
94 | /**
95 | * @return string
96 | */
97 | public function getEndpoint() : string
98 | {
99 | return $this->strEndpoint;
100 | }
101 |
102 | /**
103 | * @return string
104 | */
105 | public function getPublicKey() : string
106 | {
107 | return $this->strPublicKey;
108 | }
109 |
110 | /**
111 | * @return string
112 | */
113 | public function getAuth() : string
114 | {
115 | return $this->strAuth;
116 | }
117 |
118 | /**
119 | * @return string
120 | */
121 | public function getEncoding() : string
122 | {
123 | return $this->strEncoding;
124 | }
125 |
126 | /**
127 | * @param string $strEndpoint
128 | */
129 | public function setEndpoint(string $strEndpoint) : void
130 | {
131 | $this->strEndpoint = $strEndpoint;
132 | }
133 |
134 | /**
135 | * @param string $strPublicKey
136 | */
137 | public function setPublicKey(string $strPublicKey) : void
138 | {
139 | $this->strPublicKey = $strPublicKey;
140 | }
141 |
142 | /**
143 | * @param string $strAuth
144 | */
145 | public function setAuth(string $strAuth) : void
146 | {
147 | $this->strAuth = $strAuth;
148 | }
149 |
150 | /**
151 | * @param int $timeExpiration
152 | */
153 | public function setExpiration(int $timeExpiration) : void
154 | {
155 | $this->timeExpiration = $timeExpiration;
156 | }
157 |
158 | /**
159 | * @param string $strEncoding
160 | */
161 | public function setEncoding(string $strEncoding) : void
162 | {
163 | $this->strEncoding = $strEncoding;
164 | }
165 |
166 | /**
167 | * @return bool
168 | */
169 | public function isExpired() : bool
170 | {
171 | return ($this->timeExpiration != 0 && $this->timeExpiration < time());
172 | }
173 |
174 | /**
175 | * extract origin from endpoint
176 | * @param string $strEndpoint endpoint URL
177 | * @return string
178 | */
179 | public static function getOrigin(string $strEndpoint) : string
180 | {
181 | return parse_url($strEndpoint, PHP_URL_SCHEME) . '://' . parse_url($strEndpoint, PHP_URL_HOST);
182 | }
183 | }
184 |
--------------------------------------------------------------------------------
/SKien/PNServer/PNVapid.php:
--------------------------------------------------------------------------------
1 |
10 | * @link https://github.com/Spomky-Labs/Jose
11 | *
12 | * @package PNServer
13 | * @author Stefanius
14 | * @copyright MIT License - see the LICENSE file for details
15 | */
16 | class PNVapid
17 | {
18 | use PNServerHelper;
19 |
20 | /** lenght of public key (Base64URL - decoded) */
21 | const PUBLIC_KEY_LENGTH = 65;
22 | /** lenght of private key (Base64URL - decoded) */
23 | const PRIVATE_KEY_LENGTH = 32;
24 |
25 | const ERR_EMPTY_ARGUMENT = 'Empty Argument!';
26 | const ERR_INVALID_PUBLIC_KEY_LENGTH = 'Invalid public key length!';
27 | const ERR_INVALID_PRIVATE_KEY_LENGTH = 'Invalid private key length!';
28 | const ERR_NO_COMPRESSED_KEY_SUPPORTED = 'Invalid public key: only uncompressed keys are supported!';
29 |
30 | /** @var string VAPID subject (email or uri) */
31 | protected string $strSubject = '';
32 | /** @var string public key */
33 | protected string $strPublicKey = '';
34 | /** @var string private key */
35 | protected string $strPrivateKey = '';
36 | /** @var string last error msg */
37 | protected string $strError = '';
38 |
39 | /**
40 | * @param string $strSubject usually 'mailto:mymail@mydomain.de'
41 | * @param string $strPublicKey
42 | * @param string $strPrivateKey
43 | */
44 | public function __construct(string $strSubject, string $strPublicKey, string $strPrivateKey)
45 | {
46 | $this->strSubject = $strSubject;
47 | $this->strPublicKey = $this->decodeBase64URL($strPublicKey);
48 | $this->strPrivateKey = $this->decodeBase64URL($strPrivateKey);
49 | }
50 |
51 | /**
52 | * Check for valid VAPID.
53 | * - subject, public key and private key must be set
54 | * - decoded public key must be 65 bytes long
55 | * - no compresed public key supported
56 | * - decoded private key must be 32 bytes long
57 | * @return bool
58 | */
59 | public function isValid() : bool
60 | {
61 | if (strlen($this->strSubject) == 0 ||
62 | strlen($this->strPublicKey) == 0 ||
63 | strlen($this->strPrivateKey) == 0) {
64 | $this->strError = self::ERR_EMPTY_ARGUMENT;
65 | return false;
66 | }
67 | if (mb_strlen($this->strPublicKey, '8bit') !== self::PUBLIC_KEY_LENGTH) {
68 | $this->strError = self::ERR_INVALID_PUBLIC_KEY_LENGTH;
69 | return false;
70 | }
71 | $hexPublicKey = bin2hex($this->strPublicKey);
72 | if (mb_substr($hexPublicKey, 0, 2, '8bit') !== '04') {
73 | $this->strError = self::ERR_NO_COMPRESSED_KEY_SUPPORTED;
74 | return false;
75 | }
76 | if (mb_strlen($this->strPrivateKey, '8bit') !== self::PRIVATE_KEY_LENGTH) {
77 | $this->strError = self::ERR_INVALID_PRIVATE_KEY_LENGTH;
78 | return false;
79 | }
80 | return true;
81 | }
82 |
83 | /**
84 | * Create header for endpoint using current timestamp.
85 | * @param string $strEndpoint
86 | * @return array|false headers if succeeded, false on error
87 | */
88 | public function getHeaders(string $strEndpoint)
89 | {
90 | $aHeaders = false;
91 |
92 | // info
93 | $aJwtInfo = array("typ" => "JWT", "alg" => "ES256");
94 | $jsonJwtInfo = json_encode($aJwtInfo);
95 | $strJwtInfo = 'invalid';
96 | if ($jsonJwtInfo !== false) {
97 | $strJwtInfo = self::encodeBase64URL($jsonJwtInfo);
98 | }
99 |
100 | // data
101 | // - origin from endpoint
102 | // - timeout 12h from now
103 | // - subject (e-mail or URL to invoker of VAPID-keys)
104 | // TODO: change param to $strEndPointOrigin to eliminate dependency to PNSubscription!
105 | $aJwtData = array(
106 | 'aud' => PNSubscription::getOrigin($strEndpoint),
107 | 'exp' => time() + 43200,
108 | 'sub' => $this->strSubject
109 | );
110 | $jsonJwtData = json_encode($aJwtData);
111 | $strJwtData = 'invalid';
112 | if ($jsonJwtData !== false) {
113 | $strJwtData = self::encodeBase64URL($jsonJwtData);
114 | }
115 |
116 | // signature
117 | // ECDSA encrypting "JwtInfo.JwtData" using the P-256 curve and the SHA-256 hash algorithm
118 | $strData = $strJwtInfo . '.' . $strJwtData;
119 | $pem = self::getP256PEM($this->strPublicKey, $this->strPrivateKey);
120 |
121 | $this->strError = 'Error creating signature!';
122 | $strSignature = '';
123 | if (\openssl_sign($strData, $strSignature, $pem, OPENSSL_ALGO_SHA256)) {
124 | if (($sig = self::signatureFromDER($strSignature)) !== false) {
125 | $this->strError = '';
126 | $strSignature = self::encodeBase64URL($sig);
127 | $aHeaders = [
128 | 'Authorization' => 'WebPush ' . $strJwtInfo . '.' . $strJwtData . '.' . $strSignature,
129 | 'Crypto-Key' => 'p256ecdsa=' . self::encodeBase64URL($this->strPublicKey),
130 | ];
131 | }
132 | }
133 | return $aHeaders;
134 | }
135 |
136 | /**
137 | * @return string last error
138 | */
139 | public function getError() : string
140 | {
141 | return $this->strError;
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/SKien/PNServer/Utils/Curve.php:
--------------------------------------------------------------------------------
1 | size = $size;
59 | $this->prime = $prime;
60 | $this->a = $a;
61 | $this->b = $b;
62 | $this->generator = $generator;
63 | }
64 |
65 | public function getA() : \GMP
66 | {
67 | return $this->a;
68 | }
69 |
70 | public function getB() : \GMP
71 | {
72 | return $this->b;
73 | }
74 |
75 | public function getPrime() : \GMP
76 | {
77 | return $this->prime;
78 | }
79 |
80 | public function getSize() : int
81 | {
82 | return $this->size;
83 | }
84 |
85 | public function getPoint(\GMP $x, \GMP $y, \GMP $order = null) : Point
86 | {
87 | if (!$this->contains($x, $y)) {
88 | throw new \RuntimeException('Curve ' . $this->__toString() . ' does not contain point (' . Math::toString($x) . ', ' . Math::toString($y) . ')');
89 | }
90 | $point = Point::create($x, $y, $order);
91 |
92 | if (!\is_null($order)) {
93 | $this->mul($point, $order);
94 | /* RuntimeException never reached - even with abstruse values in UnitTest
95 | $mul = $this->mul($point, $order);
96 | if (!$mul->isInfinity()) {
97 | throw new \RuntimeException('SELF * ORDER MUST EQUAL INFINITY. (' . (string) $mul . ' found instead)');
98 | }
99 | */
100 | }
101 |
102 | return $point;
103 | }
104 |
105 | public function getPublicKeyFrom(\GMP $x, \GMP $y) : Point
106 | {
107 | $zero = \gmp_init(0, 10);
108 | if (Math::cmp($x, $zero) < 0 || Math::cmp($this->generator->getOrder(), $x) <= 0 || Math::cmp($y, $zero) < 0 || Math::cmp($this->generator->getOrder(), $y) <= 0) {
109 | throw new \RuntimeException('Generator point has x and y out of range.');
110 | }
111 | $point = $this->getPoint($x, $y);
112 |
113 | return $point;
114 | }
115 |
116 | public function contains(\GMP $x, \GMP $y) : bool
117 | {
118 | $eq_zero = Math::equals(
119 | Math::modSub(
120 | Math::pow($y, 2),
121 | Math::add(
122 | Math::add(
123 | Math::pow($x, 3),
124 | Math::mul($this->getA(), $x)
125 | ),
126 | $this->getB()
127 | ),
128 | $this->getPrime()
129 | ),
130 | \gmp_init(0, 10)
131 | );
132 |
133 | return $eq_zero;
134 | }
135 |
136 | public function add(Point $one, Point $two) : Point
137 | {
138 | if ($two->isInfinity()) {
139 | return clone $one;
140 | }
141 |
142 | if ($one->isInfinity()) {
143 | return clone $two;
144 | }
145 |
146 | if (Math::equals($two->getX(), $one->getX())) {
147 | if (Math::equals($two->getY(), $one->getY())) {
148 | return $this->getDouble($one);
149 | } else {
150 | return Point::infinity();
151 | }
152 | }
153 |
154 | $slope = Math::modDiv(
155 | Math::sub($two->getY(), $one->getY()),
156 | Math::sub($two->getX(), $one->getX()),
157 | $this->getPrime()
158 | );
159 |
160 | $xR = Math::modSub(
161 | Math::sub(Math::pow($slope, 2), $one->getX()),
162 | $two->getX(),
163 | $this->getPrime()
164 | );
165 |
166 | $yR = Math::modSub(
167 | Math::mul($slope, Math::sub($one->getX(), $xR)),
168 | $one->getY(),
169 | $this->getPrime()
170 | );
171 |
172 | return $this->getPoint($xR, $yR, $one->getOrder());
173 | }
174 |
175 | public function mul(Point $one, \GMP $n) : Point
176 | {
177 | if ($one->isInfinity()) {
178 | return Point::infinity();
179 | }
180 |
181 | /** @var \GMP $zero */
182 | $zero = \gmp_init(0, 10);
183 | if (Math::cmp($one->getOrder(), $zero) > 0) {
184 | $n = Math::mod($n, $one->getOrder());
185 | }
186 |
187 | if (Math::equals($n, $zero)) {
188 | return Point::infinity();
189 | }
190 |
191 | /** @var Point[] $r */
192 | $r = [
193 | Point::infinity(),
194 | clone $one,
195 | ];
196 |
197 | $k = $this->getSize();
198 | $n = \str_pad(Math::baseConvert(Math::toString($n), 10, 2), $k, '0', STR_PAD_LEFT);
199 |
200 | for ($i = 0; $i < $k; ++$i) {
201 | $j = (int) $n[$i];
202 | Point::cswap($r[0], $r[1], $j ^ 1);
203 | $r[0] = $this->add($r[0], $r[1]);
204 | $r[1] = $this->getDouble($r[1]);
205 | Point::cswap($r[0], $r[1], $j ^ 1);
206 | }
207 |
208 | return $r[0];
209 | }
210 |
211 | public function __toString() : string
212 | {
213 | return 'curve(' . Math::toString($this->getA()) . ', ' . Math::toString($this->getB()) . ', ' . Math::toString($this->getPrime()) . ')';
214 | }
215 |
216 | public function getDouble(Point $point) : Point
217 | {
218 | if ($point->isInfinity()) {
219 | return Point::infinity();
220 | }
221 |
222 | $a = $this->getA();
223 | $threeX2 = Math::mul(\gmp_init(3, 10), Math::pow($point->getX(), 2));
224 |
225 | $tangent = Math::modDiv(
226 | Math::add($threeX2, $a),
227 | Math::mul(\gmp_init(2, 10), $point->getY()),
228 | $this->getPrime()
229 | );
230 |
231 | $x3 = Math::modSub(
232 | Math::pow($tangent, 2),
233 | Math::mul(\gmp_init(2, 10), $point->getX()),
234 | $this->getPrime()
235 | );
236 |
237 | $y3 = Math::modSub(
238 | Math::mul($tangent, Math::sub($point->getX(), $x3)),
239 | $point->getY(),
240 | $this->getPrime()
241 | );
242 |
243 | return $this->getPoint($x3, $y3, $point->getOrder());
244 | }
245 | }
246 |
--------------------------------------------------------------------------------
/SKien/PNServer/Utils/Math.php:
--------------------------------------------------------------------------------
1 | x = $x;
59 | $this->y = $y;
60 | $this->order = $order;
61 | $this->infinity = $infinity;
62 | }
63 |
64 | /**
65 | * @return Point
66 | */
67 | public static function create(\GMP $x, \GMP $y, \GMP $order = null) : Point
68 | {
69 | return new self($x, $y, null === $order ? \gmp_init(0, 10) : $order);
70 | }
71 |
72 | /**
73 | * @return Point
74 | */
75 | public static function infinity() : Point
76 | {
77 | $zero = \gmp_init(0, 10);
78 |
79 | return new self($zero, $zero, $zero, true);
80 | }
81 |
82 | public function isInfinity() : bool
83 | {
84 | return $this->infinity;
85 | }
86 |
87 | public function getOrder() : \GMP
88 | {
89 | return $this->order;
90 | }
91 |
92 | public function getX() : \GMP
93 | {
94 | return $this->x;
95 | }
96 |
97 | public function getY() : \GMP
98 | {
99 | return $this->y;
100 | }
101 |
102 | /**
103 | * @param Point $a
104 | * @param Point $b
105 | * @param int $cond
106 | */
107 | public static function cswap(Point $a, Point $b, int $cond) : void
108 | {
109 | self::cswapGMP($a->x, $b->x, $cond);
110 | self::cswapGMP($a->y, $b->y, $cond);
111 | self::cswapGMP($a->order, $b->order, $cond);
112 | self::cswapBoolean($a->infinity, $b->infinity, $cond);
113 | }
114 |
115 | private static function cswapBoolean(bool &$a, bool &$b, int $cond) : void
116 | {
117 | $sa = \gmp_init((int) ($a), 10);
118 | $sb = \gmp_init((int) ($b), 10);
119 |
120 | self::cswapGMP($sa, $sb, $cond);
121 |
122 | $a = (bool) \gmp_strval($sa, 10);
123 | $b = (bool) \gmp_strval($sb, 10);
124 | }
125 |
126 | private static function cswapGMP(\GMP &$sa, \GMP &$sb, int $cond) : void
127 | {
128 | $size = \max(\mb_strlen(\gmp_strval($sa, 2), '8bit'), \mb_strlen(\gmp_strval($sb, 2), '8bit'));
129 | $mask = (string) (1 - (int) ($cond));
130 | $mask = \str_pad('', $size, $mask, STR_PAD_LEFT);
131 | $mask = \gmp_init($mask, 2);
132 | $taA = Math::bitwiseAnd($sa, $mask);
133 | $taB = Math::bitwiseAnd($sb, $mask);
134 | $sa = Math::bitwiseXor(Math::bitwiseXor($sa, $sb), $taB);
135 | $sb = Math::bitwiseXor(Math::bitwiseXor($sa, $sb), $taA);
136 | $sa = Math::bitwiseXor(Math::bitwiseXor($sa, $sb), $taB);
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/PNDataProviderMySQLTest.php:
--------------------------------------------------------------------------------
1 |
16 | * @copyright MIT License - see the LICENSE file for details
17 | */
18 | class PNDataProviderMySQLTest extends PNDataProviderTest
19 | {
20 | public static function setUpBeforeClass() : void
21 | {
22 | $db = mysqli_connect($GLOBALS['MYSQL_HOST'], $GLOBALS['MYSQL_USER'], $GLOBALS['MYSQL_PASSWD'], $GLOBALS['MYSQL_DBNAME']);
23 | if (!$db) {
24 | fwrite(STDOUT, 'MySQL: Connect Error ' . mysqli_connect_errno() . PHP_EOL);
25 | } else {
26 | $db->query("DROP TABLE IF EXISTS " . PNDataProviderMySQL::TABLE_NAME);
27 | }
28 | }
29 |
30 | public static function tearDownAfterClass() : void
31 | {
32 | $db = mysqli_connect($GLOBALS['MYSQL_HOST'], $GLOBALS['MYSQL_USER'], $GLOBALS['MYSQL_PASSWD'], $GLOBALS['MYSQL_DBNAME']);
33 | if ($db) {
34 | $db->query("DROP TABLE IF EXISTS " . PNDataProviderMySQL::TABLE_NAME);
35 | }
36 | }
37 |
38 | public function setUp() : void
39 | {
40 | // connection params set in the phpunit.xml configuration file
41 | $strDBHost = $GLOBALS['MYSQL_HOST'];
42 | $strDBUser = $GLOBALS['MYSQL_USER'];
43 | $strDBPwd = $GLOBALS['MYSQL_PASSWD'];
44 | $strDBName = $GLOBALS['MYSQL_DBNAME'];
45 | $this->dp = new PNDataProviderMySQL($strDBHost, $strDBUser, $strDBPwd, $strDBName);
46 | }
47 |
48 | public function test_constructError()
49 | {
50 | $dp = new PNDataProviderMySQL('not', 'a', 'valid', 'connection');
51 | $this->assertIsObject($dp);
52 | $this->assertFalse($dp->isConnected());
53 | $this->assertNotEmpty($dp->getError());
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/PNDataProviderSQLiteTest.php:
--------------------------------------------------------------------------------
1 |
20 | * @copyright MIT License - see the LICENSE file for details
21 | */
22 | class PNDataProviderSQLiteTest extends PNDataProviderTest
23 | {
24 | use TestHelperTrait;
25 |
26 | protected $strTmpDir;
27 | protected $strFullPath;
28 |
29 | public static function setUpBeforeClass() : void
30 | {
31 | // fwrite(STDOUT, __METHOD__ . "\n");
32 | self::getTempDataDir();
33 | self::deleteTempDataFile();
34 | }
35 |
36 | public static function tearDownAfterClass() : void
37 | {
38 | self::deleteTempDataFile();
39 | }
40 |
41 | public function setUp() : void
42 | {
43 | // each test needs dir/path and a conected DP
44 | $this->strTmpDir = self::getTempDataDir();
45 | $this->strFullPath = $this->strTmpDir . DIRECTORY_SEPARATOR . self::$strSQLiteDBFilename;
46 | $this->dp = new PNDataProviderSQLite($this->strTmpDir, self::$strSQLiteDBFilename);
47 | }
48 |
49 | public function test_constructError1() : void
50 | {
51 | // test for invalid directory
52 | $strInvalidDir = 'invaliddir';
53 | $dp = new PNDataProviderSQLite($strInvalidDir, self::$strSQLiteDBFilename);
54 | $this->assertIsObject($dp);
55 | $this->assertFalse($dp->isConnected());
56 | $this->assertNotEmpty($dp->getError());
57 | }
58 |
59 | public function test_constructError2() : void
60 | {
61 | // test for readonly directory
62 | chmod($this->strTmpDir, 0444);
63 | $dp = new PNDataProviderSQLite($this->strTmpDir, self::$strSQLiteDBFilename);
64 | $this->assertIsObject($dp);
65 | $this->assertFalse($dp->isConnected());
66 | $this->assertNotEmpty($dp->getError());
67 | chmod($this->strTmpDir, 0777);
68 | }
69 |
70 | public function test_constructError3() : void
71 | {
72 | // test for readonly db-file
73 | chmod($this->strFullPath, 0444);
74 | // for this test we need to create local instance AFTER set the DB-file to readonly ...
75 | $dp = new PNDataProviderSQLite($this->strTmpDir, self::$strSQLiteDBFilename);
76 | $this->assertIsObject($dp);
77 | $this->assertFalse($dp->isConnected());
78 | $strExpected = 'readonly database file ' . $this->strFullPath . '!';
79 | $this->assertEquals($strExpected, $dp->getError());
80 | chmod($this->strFullPath, 0777);
81 | }
82 |
83 | public function test_errorWithLogger() : void
84 | {
85 | $oLogger = new FileLogger();
86 | $oLogger->setFullpath($this->strTmpDir . DIRECTORY_SEPARATOR . 'testlog.csv');
87 | $oLogger->reset();
88 |
89 | // test for readonly db-file
90 | chmod($this->strFullPath, 0444);
91 | // for this test we need to create local instance AFTER set the DB-file to readonly ...
92 | $dp = new PNDataProviderSQLite($this->strTmpDir, self::$strSQLiteDBFilename);
93 | $dp->setLogger($oLogger);
94 | $this->assertIsObject($dp);
95 | chmod($this->strFullPath, 0777);
96 | }
97 | }
98 |
99 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/PNDataProviderTest.php:
--------------------------------------------------------------------------------
1 |
21 | * @copyright MIT License - see the LICENSE file for details
22 | */
23 | abstract class PNDataProviderTest extends TestCase
24 | {
25 | use TestHelperTrait;
26 |
27 | protected ?PNDataProvider $dp = null;
28 |
29 | public function test_construct() : void
30 | {
31 | $this->assertIsObject($this->dp);
32 | $this->assertEmpty($this->dp->getError());
33 | $this->assertTrue($this->dp->isConnected());
34 | }
35 |
36 | public function test_saveSubscription() : string
37 | {
38 | $this->assertIsObject($this->dp);
39 |
40 | $this->assertEquals(0, $this->dp->count());
41 | $strSub = $this->loadSubscription('valid_subscription.json');
42 | $this->assertTrue($this->dp->saveSubscription($strSub));
43 | $this->assertEmpty($this->dp->getError());
44 | $this->assertEquals(1, $this->dp->count());
45 | $sub = PNSubscription::fromJSON($strSub);
46 |
47 | // pass to test_remove...
48 | return $sub->getEndpoint();
49 | }
50 |
51 | /**
52 | * @depends test_saveSubscription
53 | */
54 | public function test_removeSubscription(string $strEndpoint) : void
55 | {
56 | $this->assertIsObject($this->dp);
57 |
58 | $this->assertEquals(1, $this->dp->count());
59 | // remove previous inserted subscription by endpoint
60 | $this->assertTrue($this->dp->removeSubscription($strEndpoint));
61 | $this->assertEmpty($this->dp->getError());
62 | $this->assertEquals(0, $this->dp->count());
63 | }
64 |
65 | public function test_fetch() : void
66 | {
67 | $this->assertIsObject($this->dp);
68 |
69 | // create with valid and expired subscription
70 | $this->assertTrue($this->dp->isConnected());
71 | $this->dp->saveSubscription($this->loadSubscription('valid_subscription.json'));
72 | $this->dp->saveSubscription($this->loadSubscription('expired_subscription.json'));
73 | $this->assertEquals(2, $this->dp->count());
74 |
75 | // init list with autoremove=false - expired record have to be ignored but not deleted
76 | $this->assertTrue($this->dp->init(false));
77 |
78 | $this->assertNotEmpty($this->dp->fetch());
79 | $this->assertGreaterThan(0, intval($this->dp->getColumn(PNDataProvider::COL_ID)));
80 | // no more record to fetch
81 | $this->assertFalse($this->dp->fetch());
82 | }
83 |
84 | public function test_autoremove() : void
85 | {
86 | $this->assertIsObject($this->dp);
87 |
88 | // create with valid and expired subscription
89 | $this->assertTrue($this->dp->isConnected());
90 | $this->dp->saveSubscription($this->loadSubscription('valid_subscription.json'));
91 | $this->dp->saveSubscription($this->loadSubscription('expired_subscription.json'));
92 | $this->assertTrue($this->dp->init(true));
93 | // ... 1 record left
94 | $this->assertEquals(1, $this->dp->count());
95 | }
96 |
97 | public function test_truncate() : void
98 | {
99 | $this->assertIsObject($this->dp);
100 |
101 | $this->dp->saveSubscription($this->loadSubscription('valid_subscription.json'));
102 | $this->assertGreaterThan(0, $this->dp->count());
103 | $this->assertTrue($this->dp->truncate());
104 | $this->assertEquals(0, $this->dp->count());
105 | }
106 |
107 | public function test_saveSubscriptionError() : void
108 | {
109 | $this->assertIsObject($this->dp);
110 |
111 | // causing error by passing invalid JSON string
112 | $result = $this->dp->saveSubscription('{,');
113 | $this->assertFalse($result);
114 | $this->assertNotEmpty($this->dp->getError());
115 | }
116 | }
117 |
118 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/PNEncryptionTest.php:
--------------------------------------------------------------------------------
1 |
11 | * @copyright MIT License - see the LICENSE file for details
12 | */
13 | class PNEncryptionTest extends TestCase
14 | {
15 | use TestHelperTrait;
16 |
17 | const VALID_P256H = "BEQrfuNX-ZrXPf0Mm-IdVMO1LMpu5N3ifgcyeUD2nYwuUhRUDmn_wVOM3eQyYux5vW2B8-TyTYco4-bFKKR02IA";
18 | const VALID_AUTH = "jOfywakW_srfHhMF-NiZ3Q";
19 | const VALID_ENCODING = "aesgcm";
20 |
21 | public function test_encrypt1() : void
22 | {
23 | $enc = new PNEncryption(self::VALID_P256H, self::VALID_AUTH);
24 |
25 | $result = $enc->encrypt('');
26 | $this->assertTrue(is_string($result));
27 | $this->assertEquals('', $result);
28 |
29 | $result = $enc->encrypt('my payload');
30 | $this->assertTrue(is_string($result));
31 | $this->assertTrue(strlen($result) > 0);
32 | }
33 |
34 | public function test_encrypt2() : void
35 | {
36 | $enc = new PNEncryption(self::VALID_P256H, self::VALID_AUTH, "aes128gcm");
37 | $result = $enc->encrypt('my payload');
38 | $this->assertTrue(is_string($result));
39 | $this->assertNotEmpty($result);
40 | }
41 |
42 | public function test_encryptError1() : void
43 | {
44 | $sub = json_decode($this->loadSubscription('invalid_subscription.json'));
45 | $enc = new PNEncryption($sub->keys->p256dh, $sub->keys->auth);
46 | $this->assertFalse($enc->encrypt('my payload'));
47 | $this->assertNotEmpty($enc->getError());
48 | $this->assertEquals("Invalid client public key length!", $enc->getError());
49 | }
50 |
51 | public function test_encryptError2() : void
52 | {
53 | $enc = new PNEncryption(self::VALID_P256H, self::VALID_AUTH, 'invalidencoding');
54 | $this->assertFalse($enc->encrypt('my payload'));
55 | $this->assertNotEmpty($enc->getError());
56 | }
57 |
58 | public function test_getHeaders1() : void
59 | {
60 | $enc = new PNEncryption(self::VALID_P256H, self::VALID_AUTH);
61 |
62 | $enc->encrypt('');
63 | $headers = $enc->getHeaders();
64 | $this->assertTrue(is_array($headers));
65 | $this->assertEquals(0, count($headers));
66 |
67 | $enc->encrypt('my payload');
68 | $headers = $enc->getHeaders();
69 | $this->assertTrue(is_array($headers));
70 | $this->assertArrayHasKey('Content-Type', $headers);
71 | $this->assertEquals('application/octet-stream', $headers['Content-Type']);
72 | $this->assertArrayHasKey('Content-Encoding', $headers);
73 | $this->assertEquals("aesgcm", $headers['Content-Encoding']);
74 | $this->assertArrayHasKey('Encryption', $headers);
75 | $this->assertTrue(strlen($headers['Encryption']) > 0);
76 | $this->assertArrayHasKey('Crypto-Key', $headers);
77 | $this->assertTrue(strlen($headers['Crypto-Key']) > 0);
78 | }
79 |
80 | public function test_getHeaders2() : void
81 | {
82 | $enc = new PNEncryption(self::VALID_P256H, self::VALID_AUTH);
83 | $enc->encrypt('my payload');
84 | // with existing header to merge with
85 | $headers = $enc->getHeaders(['Crypto-Key' => 'testkey']);
86 | $this->assertEquals(';testkey', substr($headers['Crypto-Key'], -8));
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/PNPayloadTest.php:
--------------------------------------------------------------------------------
1 |
11 | * @copyright MIT License - see the LICENSE file for details
12 | */
13 | class PNPayloadTest extends TestCase
14 | {
15 | protected PNPayload $pl;
16 | protected string $strExpected;
17 |
18 | public function setUp() : void
19 | {
20 | $this->pl = new PNPayload('title', 'text', 'icon.png');
21 |
22 | $this->strExpected = '{';
23 | $this->strExpected .= '"title":"title",';
24 | $this->strExpected .= '"opt":{';
25 | $this->strExpected .= '"body":"text",';
26 | $this->strExpected .= '"icon":"icon.png",';
27 | $this->strExpected .= '"data":{"url":"url.php"},';
28 | $this->strExpected .= '"tag":"tag",';
29 | $this->strExpected .= '"renotify":true,';
30 | $this->strExpected .= '"image":"image.png",';
31 | $this->strExpected .= '"badge":"badge.png",';
32 | $this->strExpected .= '"actions":[';
33 | $this->strExpected .= '{"action":"ok","title":"OK","icon":"ok.png","custom":"customOK"},';
34 | $this->strExpected .= '{"action":"cancel","title":"Cancel","icon":"cancel.png","custom":"customCancel"}';
35 | $this->strExpected .= '],';
36 | $this->strExpected .= '"timestamp":"1588716000000",';
37 | $this->strExpected .= '"requireInteraction":true,';
38 | $this->strExpected .= '"silent":false,';
39 | $this->strExpected .= '"vibrate":[300,100,400],';
40 | $this->strExpected .= '"sound":"sound.mp3"';
41 | $this->strExpected .= '}}';
42 | }
43 |
44 | public function test_construct() : void
45 | {
46 | $data = $this->pl->getPayload();
47 | $this->assertTrue(is_array($data));
48 | $this->assertArrayHasKey('title', $data);
49 | $this->assertArrayHasKey('opt', $data);
50 | $this->assertTrue(is_array($data['opt']));
51 | }
52 |
53 | public function test_set() : void
54 | {
55 | $this->pl->setURL('url.php');
56 | $this->pl->setTag('tag', true);
57 | $this->pl->setImage('image.png');
58 | $this->pl->setBadge('badge.png');
59 | $this->pl->addAction('ok', 'OK', 'ok.png', 'customOK');
60 | $this->pl->addAction('cancel', 'Cancel', 'cancel.png', 'customCancel');
61 | $this->pl->setTimestamp(mktime(0, 0, 0, 5, 6, 2020));
62 | $this->pl->requireInteraction();
63 | // set to true - must be reseted to false by setVibration() !!!
64 | $this->pl->setSilent(true);
65 | $this->pl->setVibration([300, 100, 400]);
66 | $this->pl->setSound('sound.mp3');
67 | $this->assertEquals($this->strExpected, $this->pl->toJSON());
68 |
69 | // same value but other types
70 | $this->pl->setTimestamp('2020-05-06');
71 | $this->assertEquals($this->strExpected, $this->pl->toJSON());
72 |
73 | $this->pl->setTimestamp(new \DateTime('2020-05-06'));
74 | $this->assertEquals($this->strExpected, $this->pl->toJSON());
75 |
76 | // now the ['opt']['vibrate'] property must be reseted
77 | $this->pl->setSilent(true);
78 | $data = $this->pl->getPayload();
79 | $this->assertArrayNotHasKey('vibrate', $data['opt']);
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/PNServerTest.php:
--------------------------------------------------------------------------------
1 |
20 | * @copyright MIT License - see the LICENSE file for details
21 | */
22 | class PNServerTest extends TestCase
23 | {
24 | use TestHelperTrait;
25 |
26 | /**
27 | * Fixture for the test to work with SQLite dp.
28 | * - create temp directory for datafile if not exist
29 | * - and ensure it is writeable
30 | * both will be done in self::getTempDataDir
31 | * - start without existing DB (delete DB file if exists so far)
32 | */
33 | public static function setUpBeforeClass() : void
34 | {
35 | self::getTempDataDir();
36 | self::deleteTempDataFile();
37 | }
38 |
39 | /**
40 | * remove created DB-file after last test
41 | */
42 | public static function tearDownAfterClass() : void
43 | {
44 | self::deleteTempDataFile();
45 | }
46 |
47 | public function test_setPayloadObject() : void
48 | {
49 | $srv = new PNServer();
50 | $pl = new PNPayload('title', 'text', 'icon.png');
51 | $srv->setPayload($pl);
52 | $this->assertNotEmpty($srv->getPayload());
53 | }
54 |
55 | public function test_setPayloadPlainText() : void
56 | {
57 | $srv = new PNServer();
58 | $srv->setPayload('plain text');
59 | $this->assertEquals('plain text', $srv->getPayload());
60 | }
61 |
62 | public function test_addSubscription() : void
63 | {
64 | $srv = new PNServer();
65 | $sub = new PNSubscription(VALID_ENDPOINT, VALID_P256H, VALID_AUTH);
66 | $srv->addSubscription($sub);
67 | $this->assertEquals(1, $srv->getSubscriptionCount());
68 | }
69 |
70 | public function test_loadSubscriptions() : void
71 | {
72 | $srv = $this->setupValidServer();
73 | $dp = $srv->getDP();
74 | // 5 subscriptions created: 'valid', 'notfound', 'expired', 'gone', 'invalid'
75 | $this->assertEquals(6, $dp->count());
76 | // load subscriptions with autoremove true (default)
77 | $this->assertTrue($srv->loadSubscriptions());
78 | // 'expired' must not be loaded...
79 | $this->assertEquals(5, $srv->getSubscriptionCount());
80 | // ... and have to be removed from DB
81 | $this->assertEquals(5, $dp->count());
82 | }
83 |
84 | public function test_push() : PNServer
85 | {
86 | $srv = $this->setupValidServer();
87 | $dp = $srv->getDP();
88 | $srv->loadSubscriptions();
89 | $this->assertTrue($srv->push());
90 | // After notifications have been pushed, all entries except the valid one should have been removed from the database!
91 | $this->assertEquals(1, $dp->count());
92 |
93 | return $srv;
94 | }
95 |
96 | public function test_loadSubscriptionsWithError1() : void
97 | {
98 | // Test errorhandling if no dataprovider set
99 | $srv = new PNServer();
100 | $this->assertFalse($srv->loadSubscriptions());
101 | $this->assertNotEmpty($srv->getError());
102 | }
103 |
104 | public function test_loadSubscriptionsWithError2() : void
105 | {
106 | // Test errorhandling for not conected dataprovider
107 | $srv = new PNServer(new PNDataProviderMySQL('', '', '', ''));
108 | $this->assertFalse($srv->loadSubscriptions());
109 | $this->assertNotEmpty($srv->getError());
110 | }
111 |
112 | public function test_loadSubscriptionsNoAutoremove() : void
113 | {
114 | $srv = $this->setupValidServer();
115 | $dp = $srv->getDP();
116 | // 5 subscriptions created: 'valid', 'notfound', 'expired', 'gone', 'invalid'
117 | $this->assertEquals(6, $dp->count());
118 | $srv->setAutoRemove(false);
119 | $this->assertTrue($srv->loadSubscriptions());
120 | // 'expired' must not be loaded...
121 | $this->assertEquals(5, $srv->getSubscriptionCount());
122 | // ... but must NOT be removed from DB
123 | $this->assertEquals(6, $dp->count());
124 | }
125 |
126 | public function test_pushWithError() : void
127 | {
128 | $srv = new PNServer();
129 | // no vapid set
130 | $this->assertFalse($srv->push());
131 | $this->assertNotEmpty($srv->getError());
132 | // invalid vapid
133 | $srv->setVapid(new PNVapid(VALID_SUBJECT, '', VALID_PRIVATE_KEY));
134 | $this->assertFalse($srv->push());
135 | $this->assertNotEmpty($srv->getError());
136 | // no subscription(s) set
137 | $srv->setVapid(new PNVapid(VALID_SUBJECT, VALID_PUBLIC_KEY, VALID_PRIVATE_KEY));
138 | $this->assertFalse($srv->push());
139 | $this->assertNotEmpty($srv->getError());
140 | }
141 |
142 | /**
143 | * @depends test_push
144 | */
145 | public function test_getLog(PNServer $srv) : PNServer
146 | {
147 | $log = $srv->getLog();
148 | $this->assertIsArray($log);
149 | $this->assertEquals(5, count($log));
150 | $aResponse = [-1, 201, 404, 410, 0]; // Encryption error, OK, Not Found, Gone. inv. Endpoint
151 | $i = 0;
152 | foreach ($log as $strEndpoint => $aMsg) {
153 | $this->assertNotEmpty($strEndpoint);
154 | $this->assertEquals($aResponse[$i++], $aMsg['curl_response_code']);
155 | // fwrite(STDOUT, "\n" . $aMsg['msg'] . "\n");
156 | }
157 | return $srv;
158 | }
159 |
160 | /**
161 | * @depends test_getLog
162 | */
163 | public function test_getSummary(PNServer $srv) : void
164 | {
165 | $summary = $srv->getSummary();
166 | $this->assertIsArray($summary);
167 | $this->assertArrayHasKey('total', $summary);
168 | $this->assertArrayHasKey('pushed', $summary);
169 | $this->assertArrayHasKey('failed', $summary);
170 | $this->assertArrayHasKey('expired', $summary);
171 | $this->assertArrayHasKey('removed', $summary);
172 | $this->assertEquals(6, $summary['total']);
173 | $this->assertEquals(1, $summary['pushed']);
174 | $this->assertEquals(4, $summary['failed']);
175 | $this->assertEquals(1, $summary['expired']);
176 | $this->assertEquals(5, $summary['removed']);
177 | }
178 |
179 | /**
180 | * NOT implemented in the setUp()-method because not all testmethods needs valid server
181 | * @return PNServer
182 | */
183 | protected function setupValidServer() : PNServer
184 | {
185 | $dp = new PNDataProviderSQLite(self::getTempDataDir(), self::$strSQLiteDBFilename);
186 | $dp->truncate();
187 | $dp->saveSubscription($this->loadSubscription('valid_subscription.json'));
188 | $dp->saveSubscription($this->loadSubscription('notfound_subscription.json'));
189 | $dp->saveSubscription($this->loadSubscription('expired_subscription.json'));
190 | $dp->saveSubscription($this->loadSubscription('gone_subscription.json'));
191 | $dp->saveSubscription($this->loadSubscription('invalid_subscription.json'));
192 | $dp->saveSubscription($this->loadSubscription('inv_endpoint_subscription.json'));
193 |
194 | $srv = new PNServer($dp);
195 | $vapid = new PNVapid(VALID_SUBJECT, VALID_PUBLIC_KEY, VALID_PRIVATE_KEY);
196 | $srv->setVapid($vapid);
197 | $srv->setPayload('Greetings from phpUnit .-)');
198 | $this->assertIsObject($srv);
199 |
200 | return $srv;
201 | }
202 | }
203 |
204 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/PNSubscriptionTest.php:
--------------------------------------------------------------------------------
1 |
11 | * @copyright MIT License - see the LICENSE file for details
12 | */
13 | class PNSubscriptionTest extends TestCase
14 | {
15 | use TestHelperTrait;
16 |
17 | public function test_isValid() : void
18 | {
19 | $sub = PNSubscription::fromJSON($this->loadSubscription('expired_subscription.json'));
20 | $this->assertFalse($sub->isValid());
21 | $this->assertTrue($sub->isExpired());
22 | $sub->setExpiration(time() + 3600);
23 | $this->assertTrue($sub->isValid());
24 | $this->assertFalse($sub->isExpired());
25 |
26 | $sub->setEncoding('aes128gcm');
27 | $this->assertTrue($sub->isValid());
28 | $sub->setEncoding('aes256gcm');
29 | $this->assertFalse($sub->isValid());
30 | }
31 |
32 | public function test_set() : void
33 | {
34 | $sub = new PNSubscription('', '', '');
35 |
36 | $sub->setEndpoint(VALID_ENDPOINT);
37 | $this->assertFalse($sub->isValid());
38 | $sub->setPublicKey(VALID_P256H);
39 | $this->assertFalse($sub->isValid());
40 | $sub->setAuth(VALID_AUTH);
41 | $this->assertTrue($sub->isValid());
42 | $this->assertFalse($sub->isExpired());
43 | $this->assertEquals(VALID_ENDPOINT, $sub->getEndpoint());
44 | $this->assertEquals(VALID_P256H, $sub->getPublicKey());
45 | $this->assertEquals(VALID_AUTH, $sub->getAuth());
46 | $this->assertEquals('aesgcm', $sub->getEncoding());
47 | }
48 |
49 | public function test_getOrigin() : void
50 | {
51 | $this->assertEquals('https://fcm.googleapis.com', PNSubscription::getOrigin(VALID_ENDPOINT));
52 | }
53 | }
54 |
55 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/PNVapidTest.php:
--------------------------------------------------------------------------------
1 |
14 | * @copyright MIT License - see the LICENSE file for details
15 | */
16 | class PNVapidTest extends TestCase
17 | {
18 | const COMPRESSED_PUBLIC_KEY = "ADtOCcUUTYvuUzx9ktgYs3mB6tQCjFLNfOkuiaIi_2LNosLbHQY6P91eMzQ8opTDLK_PjJHsjMSiJ-MUOeSjV8E";
19 |
20 | public function test_isValid() : void
21 | {
22 | $vapid = new PNVapid(VALID_SUBJECT, VALID_PUBLIC_KEY, VALID_PRIVATE_KEY);
23 | $this->assertTrue($vapid->isValid());
24 |
25 | $vapid = new PNVapid('', VALID_PUBLIC_KEY . 'XX', VALID_PRIVATE_KEY);
26 | $this->assertFalse($vapid->isValid());
27 | $this->assertEquals(PNVapid::ERR_EMPTY_ARGUMENT, $vapid->getError());
28 |
29 | $vapid = new PNVapid(VALID_SUBJECT, '', VALID_PRIVATE_KEY);
30 | $this->assertFalse($vapid->isValid());
31 | $this->assertEquals(PNVapid::ERR_EMPTY_ARGUMENT, $vapid->getError());
32 |
33 | $vapid = new PNVapid(VALID_SUBJECT, VALID_PUBLIC_KEY, '');
34 | $this->assertFalse($vapid->isValid());
35 | $this->assertEquals(PNVapid::ERR_EMPTY_ARGUMENT, $vapid->getError());
36 |
37 | $vapid = new PNVapid(VALID_SUBJECT, VALID_PUBLIC_KEY . 'XX', VALID_PRIVATE_KEY);
38 | $this->assertFalse($vapid->isValid());
39 | $this->assertEquals(PNVapid::ERR_INVALID_PUBLIC_KEY_LENGTH, $vapid->getError());
40 |
41 | $vapid = new PNVapid(VALID_SUBJECT, VALID_PUBLIC_KEY, VALID_PRIVATE_KEY . 'XX');
42 | $this->assertFalse($vapid->isValid());
43 | $this->assertEquals(PNVapid::ERR_INVALID_PRIVATE_KEY_LENGTH, $vapid->getError());
44 |
45 | $vapid = new PNVapid(VALID_SUBJECT, self::COMPRESSED_PUBLIC_KEY, VALID_PRIVATE_KEY);
46 | $this->assertFalse($vapid->isValid());
47 | $this->assertEquals(PNVapid::ERR_NO_COMPRESSED_KEY_SUPPORTED, $vapid->getError());
48 | }
49 |
50 | public function test_getHeaders() : void
51 | {
52 | $vapid = new PNVapid(VALID_SUBJECT, VALID_PUBLIC_KEY, VALID_PRIVATE_KEY);
53 | $result = $vapid->getHeaders(VALID_ENDPOINT);
54 | $this->assertTrue(is_array($result));
55 | // content of the keys can not be tested - containing processed time() value
56 | $this->assertArrayHasKey('Authorization', $result);
57 | $this->assertArrayHasKey('Crypto-Key', $result);
58 |
59 | // cause an error
60 | $vapid = new PNVapid(VALID_SUBJECT, '', VALID_PRIVATE_KEY);
61 | $this->expectError();
62 | $result = $vapid->getHeaders(VALID_ENDPOINT);
63 | }
64 | }
65 |
66 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/TestHelperTrait.php:
--------------------------------------------------------------------------------
1 |
11 | * @copyright MIT License - see the LICENSE file for details
12 | */
13 | trait TestHelperTrait
14 | {
15 | protected static string $strSQLiteDBFilename = 'testdb.sqlite';
16 |
17 | protected function loadSubscription(string $strFilename) : string
18 | {
19 | return file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . 'testdata' . DIRECTORY_SEPARATOR . $strFilename);
20 | }
21 |
22 | protected static function getTempDataDir() : string
23 | {
24 | $strTmpDir = __DIR__ . DIRECTORY_SEPARATOR . 'tempdata';
25 | if (!file_exists($strTmpDir)) {
26 | mkdir($strTmpDir);
27 | }
28 | chmod($strTmpDir, 0777);
29 | return $strTmpDir;
30 | }
31 |
32 | protected static function deleteTempDataFile() : void
33 | {
34 | $strFullPath = self::getTempDataDir() . DIRECTORY_SEPARATOR . self::$strSQLiteDBFilename;
35 | if (file_exists($strFullPath)) {
36 | unlink($strFullPath);
37 | }
38 | }
39 | }
--------------------------------------------------------------------------------
/SKien/Test/PNServer/UtilsCurveTest.php:
--------------------------------------------------------------------------------
1 |
13 | * @copyright MIT License - see the LICENSE file for details
14 | */
15 | class UtilsCurveTest extends TestCase
16 | {
17 | const VALID_X = '47106871675546646998941975368965491997260522375820007229838508869987366040004';
18 | const VALID_Y = '52376802520912566842951846993469970345548560339531881043822207040165842947197';
19 | const VALID_2X = '98427826524936846061109340525176511327555564326016491717443420300780106750337';
20 | const VALID_2Y = '84323609886562361330352263233225787919964540102923882392673776457782612785828';
21 | const CURVE256 = 'curve(115792089210356248762697446949407573530086143415290314195533631308867097853948, 41058363725152142129326129780047268409114441015993725554835256314039467401291, 115792089210356248762697446949407573530086143415290314195533631308867097853951)';
22 |
23 | protected Curve $cv;
24 |
25 | public function setUp() : void
26 | {
27 | $this->cv = NistCurve::curve256();
28 | }
29 |
30 | public function test_create() : void
31 | {
32 | $this->assertEquals($this->cv->getSize(), 256);
33 | $this->assertEquals((int) $this->cv->getA(), 9223372036854775804);
34 | $this->assertEquals((int) $this->cv->getB(), 4309448131093880907);
35 | $this->assertEquals((int) $this->cv->getPrime(), 9223372036854775807);
36 | }
37 |
38 | public function test_getPoint() : void
39 | {
40 | $pt = $this->cv->getPoint(gmp_init(self::VALID_X), gmp_init(self::VALID_Y));
41 | $this->assertEquals(self::VALID_X, gmp_strval($pt->getX()));
42 | $this->assertEquals(self::VALID_Y, gmp_strval($pt->getY()));
43 |
44 | // don't realy know what affect the $order param should bring...
45 | $pt = $this->cv->getPoint(gmp_init(self::VALID_X), gmp_init(self::VALID_Y), gmp_init('23874367496743855'));
46 | $this->assertEquals(self::VALID_X, gmp_strval($pt->getX()));
47 | $this->assertEquals(self::VALID_Y, gmp_strval($pt->getY()));
48 |
49 | $this->expectException('RuntimeException');
50 | $pt = $this->cv->getPoint(gmp_init(0), gmp_init(0));
51 | }
52 |
53 | public function test_getPublicKeyFrom() : void
54 | {
55 | $pt = $this->cv->getPublicKeyFrom(gmp_init(self::VALID_X), gmp_init(self::VALID_Y));
56 | $this->assertEquals(self::VALID_X, gmp_strval($pt->getX()));
57 | $this->assertEquals(self::VALID_Y, gmp_strval($pt->getY()));
58 |
59 | $this->expectException('RuntimeException');
60 | $pt = $this->cv->getPublicKeyFrom(gmp_init(-1), gmp_init(0));
61 | }
62 |
63 | public function test_contains() : void
64 | {
65 | $this->assertTrue($this->cv->contains(gmp_init(self::VALID_X), gmp_init(self::VALID_Y)));
66 | }
67 |
68 | public function test_add() : void
69 | {
70 | $pt1 = Point::create(gmp_init(10), gmp_init(20));
71 | $pt2 = Point::create(gmp_init(10), gmp_init(40));
72 | // result must be $pt1
73 | $pt = $this->cv->add($pt1, Point::infinity());
74 | $this->assertSame((int) $pt1->getX(), (int) $pt->getX());
75 | $this->assertSame((int) $pt1->getY(), (int) $pt->getY());
76 | // result must be $pt2
77 | $pt = $this->cv->add(Point::infinity(), $pt2);
78 | $this->assertSame((int) $pt2->getX(), (int) $pt->getX());
79 | $this->assertSame((int) $pt2->getY(), (int) $pt->getY());
80 | // result must be infinity
81 | $pt = $this->cv->add($pt1, $pt2);
82 | $this->assertTrue($pt->isInfinity());
83 |
84 | $pt = $this->cv->add(Point::create(gmp_init(self::VALID_X), gmp_init(self::VALID_Y)), Point::create(gmp_init(self::VALID_X), gmp_init(self::VALID_Y)));
85 | $this->assertEquals(self::VALID_2X, gmp_strval($pt->getX()));
86 | $this->assertEquals(self::VALID_2Y, gmp_strval($pt->getY()));
87 | }
88 |
89 | public function test_mul() : void
90 | {
91 | $pt = $this->cv->mul(Point::create(gmp_init(self::VALID_X), gmp_init(self::VALID_Y)), gmp_init(2));
92 | $this->assertEquals(self::VALID_2X, gmp_strval($pt->getX()));
93 | $this->assertEquals(self::VALID_2Y, gmp_strval($pt->getY()));
94 | $pt = $this->cv->mul(Point::infinity(), gmp_init(2));
95 | $this->assertTrue($pt->isInfinity());
96 | }
97 |
98 | public function test_toString() : void
99 | {
100 | $this->assertEquals(self::CURVE256, $this->cv->__toString());
101 | }
102 |
103 | public function test_getDouble() : void
104 | {
105 | $pt = $this->cv->getDouble(Point::create(gmp_init(self::VALID_X), gmp_init(self::VALID_Y)));
106 | $this->assertEquals(self::VALID_2X, gmp_strval($pt->getX()));
107 | $this->assertEquals(self::VALID_2Y, gmp_strval($pt->getY()));
108 | }
109 | }
110 |
111 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/UtilsMathTest.php:
--------------------------------------------------------------------------------
1 |
11 | * @copyright MIT License - see the LICENSE file for details
12 | */
13 | class UtilsMathTest extends TestCase
14 | {
15 | /**
16 | * @dataProvider cmpProvider
17 | */
18 | public function test_cmp(\GMP $a, \GMP $b, int $expected) : void
19 | {
20 | $this->assertEquals($expected, Math::cmp($a, $b));
21 | }
22 |
23 | public function cmpProvider() : array
24 | {
25 | return [
26 | [gmp_init(2), gmp_init(2), 0],
27 | [gmp_init(3), gmp_init(2), 1],
28 | [gmp_init(2), gmp_init(3), -1],
29 | [gmp_init(5), gmp_init(0), 1],
30 | [gmp_init(0), gmp_init(3), -1],
31 | [gmp_init(-2), gmp_init(2), -2],
32 | [gmp_init(2), gmp_init(-2), 2],
33 | [gmp_init(-5), gmp_init(2), -2],
34 | [gmp_init(5), gmp_init(-2), 2]
35 | ];
36 | }
37 |
38 | /**
39 | * @dataProvider equalsProvider
40 | */
41 | public function test_equals(\GMP $a, \GMP $b, bool $expected) : void
42 | {
43 | $this->assertEquals($expected, Math::equals($a, $b));
44 | }
45 |
46 | public function equalsProvider() : array
47 | {
48 | return [
49 | '2 == 2' => [gmp_init(2), gmp_init(2), true],
50 | '2 == 3' => [gmp_init(2), gmp_init(3), false],
51 | '2 == -2' => [gmp_init(2), gmp_init(-2), false]
52 | ];
53 | }
54 |
55 | public function test_mod() : void
56 | {
57 | $this->assertEquals((int) Math::mod(gmp_init(7), gmp_init(2)), 1);
58 | }
59 |
60 | public function test_add() : void
61 | {
62 | $this->assertEquals((int) Math::add(gmp_init(2), gmp_init(3)), 5);
63 | }
64 |
65 | public function test_sub() : void
66 | {
67 | $this->assertEquals((int) Math::sub(gmp_init(2), gmp_init(3)), -1);
68 | }
69 |
70 | /**
71 | * @dataProvider mulProvider
72 | */
73 | public function test_mul(\GMP $a, \GMP $b, int $expected) : void
74 | {
75 | $this->assertEquals($expected, (int) Math::mul($a, $b));
76 | }
77 |
78 | public function mulProvider() : array
79 | {
80 | return [
81 | [gmp_init(5), gmp_init(11), 55],
82 | [gmp_init(-5), gmp_init(-3), 15],
83 | [gmp_init(4), gmp_init(-15), -60],
84 | [gmp_init(-4), gmp_init(15), -60]
85 | ];
86 | }
87 |
88 | /**
89 | * @dataProvider powProvider
90 | */
91 | public function test_pow(\GMP $a, int $b, int $expected) : void
92 | {
93 | $this->assertEquals($expected, (int) Math::pow($a, $b));
94 | }
95 |
96 | public function powProvider() : array
97 | {
98 | return [
99 | [gmp_init(5), 2, 25],
100 | [gmp_init(-5), 2, 25],
101 | [gmp_init(2), 3, 8],
102 | [gmp_init(-2), 3, -8]
103 | ];
104 | }
105 |
106 | public function test_bitwiseAnd() : void
107 | {
108 | $this->assertEquals((int) Math::bitwiseAnd(gmp_init('110011', 2), gmp_init('011110', 2)), (int)gmp_init('010010', 2));
109 | }
110 |
111 | public function test_bitwiseXor() : void
112 | {
113 | $this->assertEquals((int) Math::bitwiseXor(gmp_init('110011', 2), gmp_init('011110', 2)), (int)gmp_init('101101', 2));
114 | }
115 |
116 | public function test_rightShift() : void
117 | {
118 | $this->assertEquals((int) Math::rightShift(gmp_init('110011', 2), 3), (int)gmp_init('110', 2));
119 | }
120 |
121 | public function test_toString() : void
122 | {
123 | $this->assertEquals(Math::toString(gmp_init('100', 16)), '256');
124 | }
125 |
126 | public function test_baseConvert() : void
127 | {
128 | $this->assertEquals(Math::baseConvert('0x1B', 16, 2), '11011');
129 | }
130 |
131 | public function test_inverseMod() : void
132 | {
133 | $this->assertEquals(9, (int) Math::inverseMod(gmp_init(5), gmp_init(11)));
134 | $this->assertEquals(11, (int) Math::inverseMod(gmp_init(-4), gmp_init(15)));
135 | $this->assertFalse(Math::inverseMod(gmp_init(5), gmp_init(10)));
136 | }
137 |
138 | public function test_modSub() : void
139 | {
140 | $this->assertEquals((int) Math::modSub(gmp_init(12), gmp_init(4), gmp_init(5)), 3);
141 | }
142 |
143 | public function test_modMul() : void
144 | {
145 | $this->assertEquals((int) Math::modMul(gmp_init(3), gmp_init(4), gmp_init(5)), 2);
146 | }
147 |
148 | public function test_modDiv() : void
149 | {
150 | $this->assertEquals((int) Math::modDiv(gmp_init(12), gmp_init(2), gmp_init(5)), 36);
151 | }
152 | }
153 |
154 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/UtilsPointTest.php:
--------------------------------------------------------------------------------
1 |
11 | * @copyright MIT License - see the LICENSE file for details
12 | */
13 | class UtilsPointTest extends TestCase
14 | {
15 | public function test_create() : void
16 | {
17 | $pt = Point::create(gmp_init(20, 10), gmp_init(30, 10));
18 | $this->assertEquals(gmp_cmp($pt->getX(), gmp_init(20, 10)), 0);
19 | $this->assertEquals(gmp_cmp($pt->getY(), gmp_init(30, 10)), 0);
20 | $this->assertEquals(gmp_cmp($pt->getOrder(), gmp_init(0, 10)), 0);
21 | $this->assertFalse($pt->isInfinity());
22 | }
23 |
24 | public function test_infinity() : void
25 | {
26 | $pt = Point::infinity();
27 | $this->assertEquals(gmp_cmp($pt->getX(), gmp_init(0, 10)), 0);
28 | $this->assertEquals(gmp_cmp($pt->getY(), gmp_init(0, 10)), 0);
29 | $this->assertTrue($pt->isInfinity());
30 | }
31 |
32 | public function test_csswap() : void
33 | {
34 | $ptA = Point::create(gmp_init(20, 10), gmp_init(30, 10));
35 | $ptB = Point::create(gmp_init(40, 10), gmp_init(50, 10));
36 | Point::cswap($ptA, $ptB, 1);
37 | $this->assertEquals(gmp_cmp($ptA->getX(), gmp_init(40, 10)), 0);
38 | $this->assertEquals(gmp_cmp($ptA->getY(), gmp_init(50, 10)), 0);
39 | $this->assertEquals(gmp_cmp($ptB->getX(), gmp_init(20, 10)), 0);
40 | $this->assertEquals(gmp_cmp($ptB->getY(), gmp_init(30, 10)), 0);
41 | }
42 | }
43 |
44 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/testdata/expired_subscription.json:
--------------------------------------------------------------------------------
1 | {
2 | "endpoint": "https://fcm.googleapis.com/fcm/send/f8PIq7EL6xI:APA91bFgD2qA0Goo_6sWgWVDKclh5Sm1Gf1BtYZw3rePs_GHqmC9l2N92I4QhLQtPmyB18HYYseFHLhvMbpq-oGz2Jtt8AVExmNU9R3K9Z-Gaiq6rQxig1WT4ND_5PSXTjuth-GoGggt",
3 | "expirationTime": "1589291569000",
4 | "keys": {
5 | "p256dh": "BEQrfuNX-ZrXPf0Mm-IdVMO1LMpu5N3ifgcyeUD2nYwuUhRUDmn_wVOM3eQyYux5vW2B8-TyTYco4-bFKKR02IA",
6 | "auth": "jOfywakW_srfHhMF-NiZ3Q"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/testdata/gone_subscription.json:
--------------------------------------------------------------------------------
1 | {
2 | "endpoint": "https:\/\/updates.push.services.mozilla.com\/wpush\/v2\/gAAAAABfKIqWCBGWyeyqE5H28w-FiTx0Q0QpD8EsXdaVNnfRdULc-op6Fuu9pKWIEmy1ifzb6Xx6c60wupNDOH3gskjbPh-6BY1zoSQ0mBMVHZmmy3Ze7SBTboY6etv7ZKBGGsU9wndBkoFq-OhTnXEHO_OIkEDlWrWYjHI-cyNdZzjNNzzCWlQ",
3 | "keys": {
4 | "auth": "Qvx9yoo1GwnKz7oGZLCU-w",
5 | "p256dh": "BM_EDJoPgnwrfTM5119U4Ptt_eMXnNfGPnOCMoQQldN9JgZYZ5nnMXFgbZ5Sd2gI6xqSD1-amh4Dq4oaAV62kS8"
6 | },
7 | "userAgent": "Mozilla\/5.0 (X11; Ubuntu; Linux x86_64; rv:79.0) Gecko\/20100101 Firefox\/79.0"
8 | }
9 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/testdata/inv_endpoint_subscription.json:
--------------------------------------------------------------------------------
1 | {
2 | "endpoint": "https:\/\/mypush.services.com\/wpush\/v2\/gAAAAABfKE05MszB8eQoPnOogKeJcf_XICRUipfcRqwjA3KeTrefmMWK1IE_yOJNdHERcdM2oXmsgpOuUMOTWw1sBG5AdZAnRwA8rCfIcRFY9x4bNEw50CwfcmET9l_fXprVVg_n-C2NPZmVJDeeEkHyzmUbRy3jKAM5FfwsYdhEMqvLnhRVcRA",
3 | "keys": {
4 | "auth": "ZL_m66okGUY5eiVKg1GQAw",
5 | "p256dh": "BGgli2WV93JZbpmV825B7bPLCsUGCODFMtRJu4MPR3HEc8w4PKObnnVQZA5FIgGfGo5uFguA5m4f6k3q9fxGjH0"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/testdata/invalid_subscription.json:
--------------------------------------------------------------------------------
1 | {
2 | "endpoint": "https:\/\/updates.push.services.mozilla.com\/wpush\/v2\/gAAAAABfKE05MszB8eQoPhsgdthJcf_XICRUipfcRqwjA3KeTrefmMWK1IE_yOJNdHERcdM2oXmsgpOuUMOTWw1sBG5AdZAnRwA8rCfIcRFY9x4bNEw50CwfcmET9l_fXprVVg_n-C2NPZmVJDeeEkHyzmUbRy3jKAM5FfwsYdhEMqvLnhRVcRA",
3 | "keys": {
4 | "auth": "ZL_m66okGUY5eiVKg1GQA",
5 | "p256dh": "BGgli2WV93JZbpmV825B7bPLCsUGCODFMtRJu4MPR3HEc8w4PKObnnVQZA5FIgGfGo5uFguA5m4f6k3q9fxGj"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/testdata/notfound_subscription.json:
--------------------------------------------------------------------------------
1 | {
2 | "endpoint": "https:\/\/updates.push.services.mozilla.com\/wpush\/v2\/gAAAAABfKE05MszB8eQoPnOogKeJcf_XICRUipfcRqwjA3KeTrefedfr1IE_yOJNdHERcdM2oXmsgpOuUMOTWw1sBG5AdZAnRwA8rCfIcRFY9x4bNEw50CwfcmET9l_fXprVVg_n-C2NPZmVJDeeEkHyzmUbRy3jKAM5FfwsYdhEMqvLnhRVcRA",
3 | "keys": {
4 | "auth": "ZL_m66okGUY5eiVKg1GQAw",
5 | "p256dh": "BGgli2WV93JZbpmV825B7bPLCsUGCODFMtRJu4MPR3HEc8w4PKObnnVQZA5FIgGfGo5uFguA5m4f6k3q9fxGjH0"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/SKien/Test/PNServer/testdata/valid_subscription.json:
--------------------------------------------------------------------------------
1 | {
2 | "endpoint":"https://fcm.googleapis.com/fcm/send/fqTW0YYI2nk:APA91bGpz-RiGq_CCBnmMWfVbQv75OSixK3fYpTRJspB65adsW_FrklErDjo8U1AErc6gnBcYPDofXq3zOYjAxRNlgOyFasGDww19igZDJ7Dm5lshD_Ax53Ft-X39UfAzxb2E5AR4dSA",
3 | "keys": {
4 | "auth": "VG9FD5VGfnTzQgi_3YmXHQ",
5 | "p256dh":"BEHgEiQs3mtBUZLj1Rsg4ljpJjfLpFkYt1YlFNik7OfBmp5v0Lz7KUUrcdWte4QRdUa96mODIpg_kVMqYwYUG9Q"
6 | }
7 | }
--------------------------------------------------------------------------------
/autoloader.php:
--------------------------------------------------------------------------------
1 | 1) {
5 | // replace the namespace prefix with the base directory, replace namespace
6 | // separators with directory separators in the relative class name, append
7 | // with .php
8 | $strInclude = str_replace('\\', DIRECTORY_SEPARATOR, $strFullQualifiedClassName) . '.php';
9 | }
10 |
11 | // if the file exists, require it
12 | if (strlen($strInclude) > 0) {
13 | $strInclude = dirname(__FILE__) . '/' . $strInclude;
14 | if (file_exists($strInclude)) {
15 | require $strInclude;
16 | }
17 | }
18 | });
19 |
20 |
--------------------------------------------------------------------------------
/elephpant.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Stefanius67/PNServer/1581c4b0d1296825dcf3a7dd13e31ae747be71c3/elephpant.png
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | level: 7
3 | paths:
4 | - SKien/PNServer
5 | scanDirectories:
6 | - Psr/Log
7 |
--------------------------------------------------------------------------------
/phpunit.xml.org:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 | SKien/Test/PNServer
9 |
10 |
11 |
12 |
13 |
14 | SKien/PNServer
15 | SKien/PNServer/Utils
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # PNServer - Web Push Notifications for your Homepage
2 |
3 | 
4 | 
5 | [](https://www.paypal.me/SKientzler/5.00EUR)
6 | 
7 | [](https://phpstan.org/)
8 | [](https://scrutinizer-ci.com/g/Stefanius67/PNServer/?branch=master)
9 | [](https://codecov.io/gh/Stefanius67/PNServer)
10 |
11 | ----------
12 | With this package, web push notifications can be created, encrypted and sent via HTTP request. The subscriptions can be saved and managed. Optionally, the package automatically deletes expired or no longer valid subscriptions.
13 | The JavaScript code required on the client side is also included in the package - this has to be slightly adapted to your own project.
14 |
15 | > **Important:**
16 | >
17 | > The client side of this package works with the Javascript Notification API, which is only available in a secure context (HTTPS). Thus, the complete package also depends on running in a secure context (also in the test environment - unless both the server and the client run on the ***'localhost'***).
18 | >
19 | > [Here you can read more about how to set up a secure context in the local development environment](https://github.com/Stefanius67/PNServer/wiki/Create-trusted-certificates-for-development-in-secure-context-on-a-local-network).
20 |
21 | ## required PHP Libraries
22 | - cURL (curl)
23 | - Multibyte String (mbstring)
24 | - OpenSSL (openssl)
25 | - GNU Multiple Precision (gmp)
26 | - BC Math (bcmath)
27 |
28 | **there are no dependencies to other external libraries!**
29 |
30 | ## Installation
31 | You can download the Latest [release version ](https://www.phpclasses.org/package/11632-PHP-Queue-and-push-notifications-to-Web-users.html) from PHPClasses.org
32 |
33 | required adaptions for your own project (in *PNServiceworker.js*):
34 | ```javascript
35 | // VAPID appPublic key
36 | const strAppPublicKey = 'create your own VAPID key pair and insert public key here';
37 | // URL to save subscription on server via Fetch API
38 | const strSubscriberURL = 'https://www.your-domain.org/PNSubscriber.php';
39 | // default Notification Title if not pushed by server
40 | const strDefTitle = 'Your company or product';
41 | // default Notification Icon if not pushed by server
42 | const strDefIcon = './elephpant.png';
43 | ```
44 |
45 | There are several websites where you can generate your own VAPID key. E.g.:
46 |
47 | - [https://www.stephane-quantin.com/en/tools/generators/vapid-keys](https://www.stephane-quantin.com/en/tools/generators/vapid-keys).
48 | - [https://tools.reactpwa.com/vapid](https://tools.reactpwa.com/vapid).
49 |
50 |
51 | ## Usage
52 | A [tutorial](https://www.phpclasses.org/blog/package/11632/post/1-How-to-Use-PHP-to-Send-Web-Push-Notifications-for-Your-Web-Site-in-2020.html) describing the individual steps for using the package is available at [PHPclasses.org](https://www.phpclasses.org/blog/package/11632/post/1-How-to-Use-PHP-to-Send-Web-Push-Notifications-for-Your-Web-Site-in-2020.html).
53 |
54 | *PnTestClient.html* shows a simple example Page to subscribe the push notifications.
55 |
56 | *PNTestServer.php* demonstrates, how the Notification Server can be implemented:
57 |
58 | rename *MyVapid.php.org* to *MyVapid.php* and set your own keys:
59 | ```php
60 | $oVapid = new PNVapid(
61 | "mailto:yourmail@yourdomain.de",
62 | "your-generated-public-key",
63 | "your-generated-private-key"
64 | );
65 | ```
66 |
67 | ## Logging
68 | This package can use any PSR-3 compliant logger. The logger is initialized with a NullLogger-object
69 | by default. The logger of your choice have to be passed to the constructor of the PNDataProvider
70 | and set via setLogger() method to the PNServer.
71 |
72 | If you are not working with a PSR-3 compatible logger so far, this is a good opportunity
73 | to deal with this recommendation and may work with it in the future.
74 |
75 | There are several more or less extensive PSR-3 packages available on the Internet.
76 |
77 | You can also take a look at the
78 | [**'XLogger'**](https://www.phpclasses.org/package/11743-PHP-Log-events-to-browser-console-text-and-XML-files.html)
79 | package and the associated blog
80 | [**'PSR-3 logging in a PHP application'**](https://www.phpclasses.org/blog/package/11743/post/1-PSR3-logging-in-a-PHP-application.html)
81 | as an introduction to this topic.
82 |
83 |
84 |
85 |
86 |
--------------------------------------------------------------------------------