Passed
Push — master ( 5b7005...7fe4a3 )
by Stefan
06:45
created

PNServer::pushSingle()   B

Complexity

Conditions 8
Paths 7

Size

Total Lines 54
Code Lines 36

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 8
eloc 36
c 1
b 0
f 1
nc 7
nop 1
dl 0
loc 54
rs 8.0995

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
declare(strict_types=1);
3
4
namespace SKien\PNServer;
5
6
use Psr\Log\LoggerAwareTrait;
7
use Psr\Log\NullLogger;
8
9
/**
10
 * main class of the package to create push notifications.
11
 * 
12
 * #### History
13
 * - *2020-04-12*   initial version
14
 * - *2020-08-03*   PHP 7.4 type hint
15
 * 
16
 * @package SKien/PNServer
17
 * @version 1.1.0
18
 * @author Stefanius <[email protected]>
19
 * @copyright MIT License - see the LICENSE file for details
20
*/
21
class PNServer
22
{
23
    use LoggerAwareTrait;
24
    use PNServerHelper;
25
    
26
    /** @var PNDataProvider dataprovider         */
27
    protected ?PNDataProvider $oDP = null;
28
    /** @var bool set when data has been loaded from DB         */
29
    protected bool $bFromDB = false;
30
    /** @var bool auto remove invalid/expired subscriptions     */
31
    protected bool $bAutoRemove = true;
32
    /** @var PNVapid        */
33
    protected ?PNVapid $oVapid = null;
34
    /** @var string         */
35
    protected string $strPayload = '';
36
    /** @var array          */
37
    protected array $aSubscription = [];
38
    /** @var array          */
39
    protected array $aLog = [];
40
    /** @var int $iAutoRemoved count of items autoremoved in loadSubscriptions */
41
    protected int $iAutoRemoved = 0;
42
    /** @var int $iExpired count of expired items */
43
    protected int $iExpired = 0;
44
    /** @var string last error msg  */
45
    protected string $strError = '';
46
    
47
    /**
48
     * create instance.
49
     * if $oDP specified, subscriptions can be loaded direct from data Source
50
     * and invalid or expired subscriptions will be removed automatically in
51
     * case rejection from the push service.
52
     *    
53
     * @param PNDataProvider $oDP
54
     */
55
    public function __construct(?PNDataProvider $oDP = null)
56
    {
57
        $this->oDP = $oDP;
58
        $this->reset();
59
        $this->logger = new NullLogger();
60
    }
61
    
62
    /**
63
     * @return PNDataProvider
64
     */
65
    public function getDP() : ?PNDataProvider
66
    {
67
        return $this->oDP;
68
    }
69
70
    /**
71
     * reset ll to begin new push notification.
72
     */
73
    public function reset() : void
74
    {
75
        $this->bFromDB = false;
76
        $this->strPayload = '';
77
        $this->oVapid = null;
78
        $this->aSubscription = [];
79
        $this->aLog = [];
80
    }
81
        
82
    /**
83
     * set VAPID subject and keys.
84
     * @param PNVapid $oVapid
85
     */
86
    public function setVapid(PNVapid $oVapid) : void 
87
    {
88
        $this->oVapid = $oVapid;
89
    }
90
    
91
    /**
92
     * set payload used for all push notifications.
93
     * @param mixed $payload    string or PNPayload object
94
     */
95
    public function setPayload($payload) : void 
96
    {
97
        if (is_string($payload) || self::className($payload) == 'PNPayload') {
98
            $this->strPayload = (string) $payload;
99
        }
100
    }
101
    
102
    /**
103
     * @return string
104
     */
105
    public function getPayload() : string
106
    {
107
        return $this->strPayload;
108
    }
109
    
110
    /**
111
     * add subscription to the notification list.
112
     * @param PNSubscription $oSubscription
113
     */
114
    public function addSubscription(PNSubscription $oSubscription) : void
115
    {
116
        if ($oSubscription->isValid()) {
117
            $this->aSubscription[] = $oSubscription;
118
        }
119
        $this->logger->info(__CLASS__ . ': ' . 'added {state} Subscription.', ['state' => $oSubscription->isValid() ? 'valid' : 'invalid']);
120
    }
121
    
122
    /**
123
     * Get the count of valid subscriptions set.
124
     * @return int
125
     */
126
    public function getSubscriptionCount() : int
127
    {
128
        return count($this->aSubscription);
129
    }
130
    
131
    /**
132
     * Load subscriptions from internal DataProvider.
133
     * if $this->bAutoRemove set (default: true), expired subscriptions will
134
     * be automatically removed from the data source.
135
     * @return bool
136
     */
137
    public function loadSubscriptions() : bool
138
    {
139
        $bSucceeded = false;
140
        $this->aSubscription = [];
141
        $this->iAutoRemoved = 0;
142
        $this->iExpired = 0;
143
        if ($this->oDP) {
144
            $iBefore = $this->oDP->count();
145
            if (($bSucceeded = $this->oDP->init($this->bAutoRemove)) !== false) {
146
                $this->bFromDB = true;
147
                $this->iAutoRemoved = $iBefore - $this->oDP->count();
148
                while (($strJsonSub = $this->oDP->fetch()) !== false) {
149
                    $this->addSubscription(PNSubscription::fromJSON((string) $strJsonSub));
150
                }
151
                // if $bAutoRemove is false, $this->iExpired may differs from $this->iAutoRemoved
152
                $this->iExpired = $iBefore - count($this->aSubscription);
153
                $this->logger->info(__CLASS__ . ': ' . 'added {count} Subscriptions from DB.', ['count' => count($this->aSubscription)]);
154
            } else {
155
                $this->strError = $this->oDP->getError();
156
                $this->logger->error(__CLASS__ . ': ' . $this->strError);
157
            }
158
        } else {
159
            $this->strError = 'missing dataprovider!';
160
            $this->logger->error(__CLASS__ . ': ' . $this->strError);
161
        }
162
        return $bSucceeded;
163
    }
164
165
    /**
166
     * auto remove invalid/expired subscriptions.
167
     * has only affect, if data loaded through DataProvider 
168
     * @param bool $bAutoRemove
169
     */
170
    public function setAutoRemove(bool $bAutoRemove = true) : void 
171
    {
172
        $this->bAutoRemove = $bAutoRemove;
173
    }
174
    
175
    /**
176
     * push all notifications.
177
     * 
178
     * Since a large number is expected when sending PUSH notifications, the 
179
     * POST requests are generated asynchronously via a cURL multi handle.
180
     * The response codes are then assigned to the respective end point and a 
181
     * transmission log is generated.
182
     * If the subscriptions comes from the internal data provider, all 
183
     * subscriptions that are no longer valid or that are no longer available 
184
     * with the push service will be removed from the database.
185
     * @return bool
186
     */
187
    public function push() : bool
188
    {
189
        if (!$this->oVapid) {
190
            $this->strError = 'no VAPID-keys set!';
191
            $this->logger->error(__CLASS__ . ': ' . $this->strError);
192
        } elseif (!$this->oVapid->isValid()) {
193
            $this->strError = 'VAPID error: ' . $this->oVapid->getError();
194
            $this->logger->error(__CLASS__ . ': ' . $this->strError);
195
        } elseif (count($this->aSubscription) == 0) {
196
            $this->strError = 'no valid Subscriptions set!';
197
            $this->logger->warning(__CLASS__ . ': ' . $this->strError);
198
        } else {
199
            // create multi requests...
200
            $mcurl = curl_multi_init();
201
            if ($mcurl !== false) {
202
                $aRequests = array();
203
                
204
                foreach ($this->aSubscription as $oSub) {
205
                    $aLog = ['msg' => '', 'curl_response' => '', 'curl_response_code' => -1];
206
                    // payload must be encrypted every time although it does not change, since 
207
                    // each subscription has at least his public key and authentication token of its own ...
208
                    $oEncrypt = new PNEncryption($oSub->getPublicKey(), $oSub->getAuth(), $oSub->getEncoding()); 
209
                    if (($strContent = $oEncrypt->encrypt($this->strPayload)) !== false) {
210
                        // merge headers from encryption and VAPID (maybe both containing 'Crypto-Key')
211
                        if (($aVapidHeaders = $this->oVapid->getHeaders($oSub->getEndpoint())) !== false) {
212
                            $aHeaders = $oEncrypt->getHeaders($aVapidHeaders);
213
                            $aHeaders['Content-Length'] = mb_strlen($strContent, '8bit');
214
                            $aHeaders['TTL'] = 2419200;
215
                
216
                            // build Http - Headers
217
                            $aHttpHeader = array();
218
                            foreach ($aHeaders as $strName => $strValue) {
219
                                $aHttpHeader[] = $strName . ': ' . $strValue; 
220
                            }
221
                            
222
                            // and send request with curl
223
                            $curl = curl_init($oSub->getEndpoint());
224
                            
225
                            if ($curl !== false) {
226
                                curl_setopt($curl, CURLOPT_POST, true);
227
                                curl_setopt($curl, CURLOPT_POSTFIELDS, $strContent);
228
                                curl_setopt($curl, CURLOPT_HTTPHEADER, $aHttpHeader);
229
                                curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
230
                            
231
                                curl_multi_add_handle($mcurl, $curl);
232
                            
233
                                $aRequests[$oSub->getEndpoint()] = $curl;
234
                            }
235
                        } else {
236
                            $aLog['msg'] = 'VAPID error: ' . $this->oVapid->getError();
237
                        }
238
                    } else {
239
                        $aLog['msg'] = 'Payload encryption error: ' . $oEncrypt->getError();
240
                    }
241
                    if (strlen($aLog['msg']) > 0) {
242
                        $this->aLog[$oSub->getEndpoint()] = $aLog;
243
                    }
244
                }
245
                    
246
                if (count($aRequests) > 0) {
247
                    // now performing multi request...
248
                    $iRunning = null;
249
                    do {
250
                        $iMState = curl_multi_exec($mcurl, $iRunning);
251
                    } while ($iRunning && $iMState == CURLM_OK);
252
                    
253
                    if ($iMState == CURLM_OK) {
254
                        // ...and get response of each request
255
                        foreach ($aRequests as $strEndPoint => $curl) {
256
                            $aLog = array();
257
                            $iRescode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
258
                            
259
                            $aLog['msg'] = $this->getPushServiceResponseText($iRescode);
260
                            $aLog['curl_response'] = curl_multi_getcontent($curl);
261
                            $aLog['curl_response_code'] = $iRescode;
262
                            $this->aLog[$strEndPoint] = $aLog;
263
                            // remove handle from multi and close
264
                            curl_multi_remove_handle($mcurl, $curl);
265
                            curl_close($curl);
266
                        }
267
                        
268
                    } else {
269
                        $this->strError = 'curl_multi_exec() Erroro: ' . curl_multi_strerror($iMState);
270
                        $this->logger->error(__CLASS__ . ': ' . $this->strError);
271
                    }
272
                    // ... close the door
273
                    curl_multi_close($mcurl);
274
                }
275
                if ($this->oDP != null && $this->bFromDB && $this->bAutoRemove) {
276
                    foreach ($this->aLog as $strEndPoint => $aLogItem) {
277
                        if ($this->checkAutoRemove($aLogItem['curl_response_code'])) {
278
                            // just remove subscription from DB
279
                            $aLogItem['msg'] .= ' Subscription removed from DB!';
280
                            $this->oDP->removeSubscription($strEndPoint);
281
                        }
282
                    }
283
                }
284
            }
285
        }
286
        $this->logger->info(__CLASS__ . ': ' . 'notifications pushed', $this->getSummary());
287
        return (strlen($this->strError) == 0);
288
    }
289
290
    /**
291
     * Push one single subscription.
292
     * @param PNSubscription $oSub
293
     * @return bool
294
     */
295
    public function pushSingle(PNSubscription $oSub) : bool
296
    {
297
        if (!$this->oVapid) {
298
            $this->strError = 'no VAPID-keys set!';
299
            $this->logger->error(__CLASS__ . ': ' . $this->strError);
300
        } elseif (!$this->oVapid->isValid()) {
301
            $this->strError = 'VAPID error: ' . $this->oVapid->getError();
302
            $this->logger->error(__CLASS__ . ': ' . $this->strError);
303
        } else {
304
            $aLog = ['msg' => '', 'curl_response' => '', 'curl_response_code' => -1];
305
            // payload must be encrypted every time although it does not change, since
306
            // each subscription has at least his public key and authentication token of its own ...
307
            $oEncrypt = new PNEncryption($oSub->getPublicKey(), $oSub->getAuth(), $oSub->getEncoding());
308
            if (($strContent = $oEncrypt->encrypt($this->strPayload)) !== false) {
309
                // merge headers from encryption and VAPID (maybe both containing 'Crypto-Key')
310
                if (($aVapidHeaders = $this->oVapid->getHeaders($oSub->getEndpoint())) !== false) {
0 ignored issues
show
introduced by
The condition $aVapidHeaders = $this->...etEndpoint()) !== false is always true.
Loading history...
311
                    $aHeaders = $oEncrypt->getHeaders($aVapidHeaders);
312
                    $aHeaders['Content-Length'] = mb_strlen($strContent, '8bit');
313
                    $aHeaders['TTL'] = 2419200;
314
                    
315
                    // build Http - Headers
316
                    $aHttpHeader = array();
317
                    foreach ($aHeaders as $strName => $strValue) {
318
                        $aHttpHeader[] = $strName . ': ' . $strValue;
319
                    }
320
                    
321
                    // and send request with curl
322
                    $curl = curl_init($oSub->getEndpoint());
323
                    
324
                    if ($curl !== false) {
325
                        curl_setopt($curl, CURLOPT_POST, true);
326
                        curl_setopt($curl, CURLOPT_POSTFIELDS, $strContent);
327
                        curl_setopt($curl, CURLOPT_HTTPHEADER, $aHttpHeader);
328
                        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
329
                        
330
                        if (($strResponse = curl_exec($curl)) !== false) {
331
                            $iRescode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
332
                            
333
                            $aLog['msg'] = $this->getPushServiceResponseText($iRescode);
334
                            $aLog['curl_response'] = $strResponse;
335
                            $aLog['curl_response_code'] = $iRescode;
336
                            curl_close($curl);
337
                        }
338
                    }
339
                } else {
340
                    $aLog['msg'] = 'VAPID error: ' . $this->oVapid->getError();
341
                }
342
            } else {
343
                $aLog['msg'] = 'Payload encryption error: ' . $oEncrypt->getError();
344
            }
345
            $this->aLog[$oSub->getEndpoint()] = $aLog;
346
        }
347
        $this->logger->info(__CLASS__ . ': ' . 'single notifications pushed.');
348
        return (strlen($this->strError) == 0);
349
    }
350
    
351
    /**
352
     * @return array
353
     */
354
    public function getLog() : array
355
    {
356
        return $this->aLog;
357
    }
358
    
359
    /**
360
     * Build summary for the log of the last push operation.
361
     * - total count of subscriptions processed<br/>
362
     * - count of successfull pushed messages<br/>
363
     * - count of failed messages (subscriptions couldn't be pushed of any reason)<br/>
364
     * - count of expired subscriptions<br/>
365
     * - count of removed subscriptions (expired, gone, not found, invalid)<br/>
366
     * The count of expired entries removed in the loadSubscriptions() is added to
367
     * the count of responsecode caused removed items.
368
     * The count of failed and removed messages may differ even if $bAutoRemove is set
369
     * if there are transferns with responsecode 413 or 429    
370
     * @return array
371
     */
372
    public function getSummary() : array
373
    {
374
        $aSummary = [
375
            'total' => $this->iExpired, 
376
            'pushed' => 0, 
377
            'failed' => 0, 
378
            'expired' => $this->iExpired, 
379
            'removed' => $this->iAutoRemoved,
380
        ];
381
        foreach ($this->aLog as $aLogItem) {
382
            $aSummary['total']++;
383
            if ($aLogItem['curl_response_code'] == 201) {
384
                $aSummary['pushed']++;
385
            } else {
386
                $aSummary['failed']++;
387
                if ($this->checkAutoRemove($aLogItem['curl_response_code'])) {
388
                    $aSummary['removed']++;
389
                }
390
            }
391
        }
392
        return $aSummary;
393
    }
394
395
    /**
396
     * @return string last error
397
     */
398
    public function getError() : string
399
    {
400
        return $this->strError;
401
    }
402
    
403
    /**
404
     * Check if item should be removed.
405
     * We remove items with responsecode<br/>
406
     * -> 0: unknown responsecode (usually unknown/invalid endpoint origin)<br/>
407
     * -> -1: Payload encryption error<br/>
408
     * -> 400: Invalid request<br/>
409
     * -> 404: Not Found<br/>
410
     * -> 410: Gone<br/>
411
     * 
412
     * @param int $iRescode
413
     * @return bool
414
     */
415
    protected function checkAutoRemove(int $iRescode) : bool
416
    {
417
        $aRemove = $this->bAutoRemove ? [-1, 0, 400, 404, 410] : [];
418
        return in_array($iRescode, $aRemove);
419
    }
420
    
421
    /**
422
     * get text according to given push service responsecode
423
     *
424
     * push service response codes
425
     * 201:     The request to send a push message was received and accepted.
426
     * 400:     Invalid request. This generally means one of your headers is invalid or improperly formatted.
427
     * 404:     Not Found. This is an indication that the subscription is expired and can't be used. In this case
428
     *          you should delete the PushSubscription and wait for the client to resubscribe the user.
429
     * 410:     Gone. The subscription is no longer valid and should be removed from application server. This can
430
     *          be reproduced by calling `unsubscribe()` on a `PushSubscription`.
431
     * 413:     Payload size too large. The minimum size payload a push service must support is 4096 bytes (or 4kb).
432
     * 429:     Too many requests. Meaning your application server has reached a rate limit with a push service.
433
     *          The push service should include a 'Retry-After' header to indicate how long before another request
434
     *          can be made.
435
     * 
436
     * @param int $iRescode
437
     * @return string
438
     */
439
    protected function getPushServiceResponseText(int $iRescode) : string 
440
    {
441
        $strText = 'unknwown Rescode from push service: ' . $iRescode;
442
        $aText = array(
443
            201 => "The request to send a push message was received and accepted.",
444
            400 => "Invalid request. Invalid headers or improperly formatted.",
445
            404 => "Not Found. Subscription is expired and can't be used anymore.",
446
            410 => "Gone. Subscription is no longer valid.", // This can be reproduced by calling 'unsubscribe()' on a 'PushSubscription'.
447
            413 => "Payload size too large.",
448
            429 => "Too many requests. Your application server has reached a rate limit with a push service."
449
        );
450
        if (isset($aText[$iRescode])) {
451
            $strText = $aText[$iRescode];
452
        }
453
        return $strText;
454
    }
455
}
456