PNServer   C
last analyzed

Complexity

Total Complexity 56

Size/Duplication

Total Lines 433
Duplicated Lines 0 %

Importance

Changes 3
Bugs 0 Features 1
Metric Value
eloc 181
c 3
b 0
f 1
dl 0
loc 433
rs 5.5199
wmc 56

17 Methods

Rating   Name   Duplication   Size   Complexity  
A reset() 0 7 1
A getDP() 0 3 1
A getPayload() 0 3 1
A setVapid() 0 3 1
A setPayload() 0 4 3
A setAutoRemove() 0 3 1
A getLog() 0 3 1
A __construct() 0 5 1
A getSubscriptionCount() 0 3 1
A loadSubscriptions() 0 26 4
A getPushServiceResponseText() 0 15 2
A getError() 0 3 1
A getSummary() 0 21 4
A checkAutoRemove() 0 4 2
D push() 0 101 21
B pushSingle() 0 54 8
A addSubscription() 0 6 3

How to fix   Complexity   

Complex Class

Complex classes like PNServer often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use PNServer, and based on these observations, apply Extract Interface, too.

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
 * @package PNServer
13
 * @author Stefanius <[email protected]>
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<PNSubscription>          */
32
    protected array $aSubscription = [];
33
    /** @var array<string,array<string,mixed>>          */
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);
0 ignored issues
show
Bug introduced by
It seems like $mcurl can also be of type true; however, parameter $multi_handle of curl_multi_add_handle() does only seem to accept CurlMultiHandle|resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

226
                                curl_multi_add_handle(/** @scrutinizer ignore-type */ $mcurl, $curl);
Loading history...
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);
0 ignored issues
show
Bug introduced by
It seems like $mcurl can also be of type true; however, parameter $multi_handle of curl_multi_exec() does only seem to accept CurlMultiHandle|resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

245
                        $iMState = curl_multi_exec(/** @scrutinizer ignore-type */ $mcurl, $iRunning);
Loading history...
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);
0 ignored issues
show
Bug introduced by
It seems like $mcurl can also be of type true; however, parameter $multi_handle of curl_multi_remove_handle() does only seem to accept CurlMultiHandle|resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

259
                            curl_multi_remove_handle(/** @scrutinizer ignore-type */ $mcurl, $curl);
Loading history...
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);
0 ignored issues
show
Bug introduced by
It seems like $mcurl can also be of type true; however, parameter $multi_handle of curl_multi_close() does only seem to accept CurlMultiHandle|resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

268
                    curl_multi_close(/** @scrutinizer ignore-type */ $mcurl);
Loading history...
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<string,array<string,mixed>>
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<br/>
357
     * - count of successfull pushed messages<br/>
358
     * - count of failed messages (subscriptions couldn't be pushed of any reason)<br/>
359
     * - count of expired subscriptions<br/>
360
     * - count of removed subscriptions (expired, gone, not found, invalid)<br/>
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<string,int>
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<br/>
401
     * -> 0: unknown responsecode (usually unknown/invalid endpoint origin)<br/>
402
     * -> -1: Payload encryption error<br/>
403
     * -> 400: Invalid request<br/>
404
     * -> 404: Not Found<br/>
405
     * -> 410: Gone<br/>
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