├── .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 | ![Latest Stable Version](https://img.shields.io/badge/release-v1.2.0-brightgreen.svg) 4 | ![License](https://img.shields.io/packagist/l/gomoob/php-pushwoosh.svg) 5 | [![Donate](https://img.shields.io/static/v1?label=Donate&message=PayPal&color=orange)](https://www.paypal.me/SKientzler/5.00EUR) 6 | ![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%207.4-8892BF.svg) 7 | [![PHPStan](https://img.shields.io/badge/PHPStan-enabled-brightgreen.svg?style=flat)](https://phpstan.org/) 8 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/Stefanius67/PNServer/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/Stefanius67/PNServer/?branch=master) 9 | [![codecov](https://codecov.io/gh/Stefanius67/PNServer/branch/master/graph/badge.svg?token=OPM76QIKWG)](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 | --------------------------------------------------------------------------------