Passed
Branch master (c73d10)
by Stefan
02:51 queued 55s
created

PNServer::checkAutoRemove()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 2
nc 2
nop 1
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
<?php
2
declare(strict_types = 1);
3
4
namespace SKien\PNServer;
5
6
/**
7
 * main class of the package to create push notifications.
8
 * 
9
 * #### History
10
 * - *2020-04-12*   initial version
11
 * - *2020-08-03*   PHP 7.4 type hint
12
 * 
13
 * @package SKien/PNServer
14
 * @version 1.1.0
15
 * @author Stefanius <[email protected]>
16
 * @copyright MIT License - see the LICENSE file for details
17
*/
18
class PNServer
19
{
20
    use PNServerHelper;
21
    
22
    /** @var PNDataProvider dataprovider         */
23
    protected ?PNDataProvider $oDP = null;
24
    /** @var bool set when data has been loaded from DB         */
25
    protected bool $bFromDB = false;
26
    /** @var bool auto remove invalid/expired subscriptions     */
27
    protected bool $bAutoRemove = true;
28
    /** @var PNVapid        */
29
    protected ?PNVapid $oVapid = null;
30
    /** @var string         */
31
    protected string $strPayload = '';
32
    /** @var array          */
33
    protected array $aSubscription = [];
34
    /** @var array          */
35
    protected array $aLog = [];
36
    /** @var int $iAutoRemoved count of items autoremoved in loadSubscriptions */
37
    protected int $iAutoRemoved = 0;
38
    /** @var int $iExpired count of expired items */
39
    protected int $iExpired = 0;
40
    /** @var string last error msg  */
41
    protected string $strError = '';
42
    
43
    /**
44
     * create instance.
45
     * if $oDP specified, subscriptions can be loaded direct from data Source
46
     * and invalid or expired subscriptions will be removed automatically in
47
     * case rejection from the push service.
48
     *    
49
     * @param PNDataProvider $oDP
50
     */
51
    public function __construct(?PNDataProvider $oDP=null)
52
    {
53
        $this->oDP = $oDP;
54
        $this->reset();
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
    }
115
    
116
    /**
117
     * Get the count of valid subscriptions set.
118
     * @return int
119
     */
120
    public function getSubscriptionCount() : int
121
    {
122
        return count($this->aSubscription);
123
    }
124
    
125
    /**
126
     * Load subscriptions from internal DataProvider.
127
     * if $this->bAutoRemove set (default: true), expired subscriptions will
128
     * be automatically removed from the data source.
129
     * @return bool
130
     */
131
    public function loadSubscriptions() : bool
132
    {
133
        $bSucceeded = false;
134
        $this->aSubscription = [];
135
        $this->iAutoRemoved = 0;
136
        $this->iExpired = 0;
137
        if ($this->oDP) {
138
            $iBefore = $this->oDP->count();
139
            if (($bSucceeded = $this->oDP->init($this->bAutoRemove)) !== false) {
140
                $this->bFromDB = true;
141
                $this->iAutoRemoved = $iBefore - $this->oDP->count();
142
                while (($strJsonSub = $this->oDP->fetch()) !== false) {
143
                    $this->addSubscription(PNSubscription::fromJSON($strJsonSub));
0 ignored issues
show
Bug introduced by
It seems like $strJsonSub can also be of type true; however, parameter $strJSON of SKien\PNServer\PNSubscription::fromJSON() does only seem to accept string, 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

143
                    $this->addSubscription(PNSubscription::fromJSON(/** @scrutinizer ignore-type */ $strJsonSub));
Loading history...
144
                }
145
                // if $bAutoRemove is false, $this->iExpired may differs from $this->iAutoRemoved
146
                $this->iExpired = $iBefore - count($this->aSubscription);
147
            } else {
148
                $this->strError = $this->oDP->getError();
149
            }
150
        } else {
151
            $this->strError = 'missing dataprovider!';
152
        }
153
        return $bSucceeded;
154
    }
155
156
    /**
157
     * auto remove invalid/expired subscriptions.
158
     * has only affect, if data loaded through DataProvider 
159
     * @param bool $bAutoRemove
160
     */
161
    public function setAutoRemove(bool $bAutoRemove=true) : void 
162
    {
163
        $this->bAutoRemove = $bAutoRemove;
164
    }
165
    
166
    /**
167
     * push all notifications.
168
     * 
169
     * Since a large number is expected when sending PUSH notifications, the 
170
     * POST requests are generated asynchronously via a cURL multi handle.
171
     * The response codes are then assigned to the respective end point and a 
172
     * transmission log is generated.
173
     * If the subscriptions comes from the internal data provider, all 
174
     * subscriptions that are no longer valid or that are no longer available 
175
     * with the push service will be removed from the database.
176
     * @return bool
177
     */
178
    public function push() : bool
179
    {
180
        if (!$this->oVapid) {
181
            $this->strError = 'no VAPID-keys set!';
182
        } elseif(!$this->oVapid->isValid()) {
183
            $this->strError = 'VAPID error: ' . $this->oVapid->getError();
184
        } elseif(count($this->aSubscription) == 0) {
185
            $this->strError = 'no valid Subscriptions set!';
186
        } else {
187
            // create multi requests...
188
            $mcurl = curl_multi_init();
189
            $aRequests = array();
190
            
191
            foreach ($this->aSubscription as $oSub) {
192
                $aLog = ['msg' => '', 'curl_response' => '', 'curl_response_code' => -1];
193
                // payload must be encrypted every time although it does not change, since 
194
                // each subscription has at least his public key and authentication token of its own ...
195
                $oEncrypt = new PNEncryption($oSub->getPublicKey(), $oSub->getAuth(), $oSub->getEncoding()); 
196
                if (($strContent = $oEncrypt->encrypt($this->strPayload)) !== false) {
197
                    // merge headers from encryption and VAPID (maybe both containing 'Crypto-Key')
198
                    if (($aVapidHeaders = $this->oVapid->getHeaders($oSub->getEndpoint())) !== false) {
199
                        $aHeaders = $oEncrypt->getHeaders($aVapidHeaders);
200
                        $aHeaders['Content-Length'] = mb_strlen($strContent, '8bit');
201
                        $aHeaders['TTL'] = 2419200;
202
            
203
                        // build Http - Headers
204
                        $aHttpHeader = array();
205
                        foreach ($aHeaders as $strName => $strValue) {
206
                            $aHttpHeader[] = $strName . ': ' . $strValue; 
207
                        }
208
                        
209
                        // and send request with curl
210
                        $curl = curl_init($oSub->getEndpoint());
211
                        
212
                        curl_setopt($curl, CURLOPT_POST, true);
0 ignored issues
show
Bug introduced by
It seems like $curl can also be of type false; however, parameter $ch of curl_setopt() does only seem to accept 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

212
                        curl_setopt(/** @scrutinizer ignore-type */ $curl, CURLOPT_POST, true);
Loading history...
213
                        curl_setopt($curl, CURLOPT_POSTFIELDS, $strContent);
214
                        curl_setopt($curl, CURLOPT_HTTPHEADER, $aHttpHeader);
215
                        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
216
                        
217
                        curl_multi_add_handle($mcurl, $curl);
0 ignored issues
show
Bug introduced by
It seems like $curl can also be of type false; however, parameter $ch of curl_multi_add_handle() does only seem to accept 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

217
                        curl_multi_add_handle($mcurl, /** @scrutinizer ignore-type */ $curl);
Loading history...
218
                        
219
                        $aRequests[$oSub->getEndpoint()] = $curl;
220
                    } else {
221
                        $aLog['msg'] = 'VAPID error: ' . $this->oVapid->getError();
222
                    }
223
                } else {
224
                    $aLog['msg'] = 'Payload encryption error: ' . $oEncrypt->getError();
225
                }
226
                if (strlen($aLog['msg']) > 0) {
227
                    $this->aLog[$oSub->getEndpoint()] = $aLog;
228
                }
229
            }
230
                
231
            if (count($aRequests) > 0) {
232
                // now performing multi request...
233
                $iRunning = null;
234
                do {
235
                    curl_multi_exec($mcurl, $iRunning);
236
                } while ($iRunning);
237
238
                // ...and get response of each request
239
                foreach ($aRequests as $strEndPoint => $curl) {
240
                    $aLog = array();
241
                    $iRescode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
242
                    
243
                    $aLog['msg'] = $this->getPushServiceResponseText($iRescode);
244
                    $aLog['curl_response'] = curl_multi_getcontent($curl);
245
                    $aLog['curl_response_code'] = $iRescode;
246
                    $this->aLog[$strEndPoint] = $aLog;
247
                    // remove handle from multi and close
248
                    curl_multi_remove_handle($mcurl, $curl);
249
                    curl_close($curl);
250
                }
251
                // ... close the door
252
                curl_multi_close($mcurl);
253
            }
254
            if ($this->bFromDB && $this->bAutoRemove) {
255
                foreach ($this->aLog as $strEndPoint => $aLogItem) {
256
                    if ($this->checkAutoRemove($aLogItem['curl_response_code'])) {
257
                        // just remove subscription from DB
258
                        $aLogItem['msg'] .= ' Subscription removed from DB!';
259
                        $this->oDP->removeSubscription($strEndPoint);
0 ignored issues
show
Bug introduced by
The method removeSubscription() does not exist on null. ( Ignorable by Annotation )

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

259
                        $this->oDP->/** @scrutinizer ignore-call */ 
260
                                    removeSubscription($strEndPoint);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
260
                    }
261
                }
262
            }
263
        }
264
        return (strlen($this->strError) == 0);
265
    }
266
        
267
    /**
268
     * @return array
269
     */
270
    public function getLog() : array
271
    {
272
        return $this->aLog;
273
    }
274
    
275
    /**
276
     * Build summary for the log of the last push operation.
277
     * - total count of subscriptions processed<br/>
278
     * - count of successfull pushed messages<br/>
279
     * - count of failed messages (subscriptions couldn't be pushed of any reason)<br/>
280
     * - count of expired subscriptions<br/>
281
     * - count of removed subscriptions (expired, gone, not found, invalid)<br/>
282
     * The count of expired entries removed in the loadSubscriptions() is added to
283
     * the count of responsecode caused removed items.
284
     * The count of failed and removed messages may differ even if $bAutoRemove is set
285
     * if there are transferns with responsecode 413 or 429    
286
     * @return array
287
     */
288
    public function getSummary() : array
289
    {
290
        $aSummary = [
291
            'total' => $this->iExpired, 
292
            'pushed' => 0, 
293
            'failed' => 0, 
294
            'expired' => $this->iExpired, 
295
            'removed' => $this->iAutoRemoved,
296
        ];
297
        foreach ($this->aLog as $aLogItem) {
298
            $aSummary['total']++;
299
            if ($aLogItem['curl_response_code'] == 201) {
300
                $aSummary['pushed']++;
301
            } else {
302
                $aSummary['failed']++;
303
                if ($this->checkAutoRemove($aLogItem['curl_response_code'])) {
304
                    $aSummary['removed']++;
305
                }
306
            }
307
        }
308
        return $aSummary;
309
    }
310
311
    /**
312
     * @return string last error
313
     */
314
    public function getError() : string
315
    {
316
        return $this->strError;
317
    }
318
    
319
    /**
320
     * Check if item should be removed.
321
     * We remove items with responsecode<br/>
322
     * -> 0: unknown responsecode (usually unknown/invalid endpoint origin)<br/>
323
     * -> -1: Payload encryption error<br/>
324
     * -> 400: Invalid request<br/>
325
     * -> 404: Not Found<br/>
326
     * -> 410: Gone<br/>
327
     * 
328
     * @param int $iRescode
329
     * @return bool
330
     */
331
    protected function checkAutoRemove(int $iRescode) : bool
332
    {
333
        $aRemove = $this->bAutoRemove ? [-1, 0, 400, 404, 410] : [];
334
        return in_array($iRescode, $aRemove);
335
    }
336
    
337
    /**
338
     * get text according to given push service responsecode
339
     *
340
     * push service response codes
341
     * 201:     The request to send a push message was received and accepted.
342
     * 400:     Invalid request. This generally means one of your headers is invalid or improperly formatted.
343
     * 404:     Not Found. This is an indication that the subscription is expired and can't be used. In this case
344
     *          you should delete the PushSubscription and wait for the client to resubscribe the user.
345
     * 410:     Gone. The subscription is no longer valid and should be removed from application server. This can
346
     *          be reproduced by calling `unsubscribe()` on a `PushSubscription`.
347
     * 413:     Payload size too large. The minimum size payload a push service must support is 4096 bytes (or 4kb).
348
     * 429:     Too many requests. Meaning your application server has reached a rate limit with a push service.
349
     *          The push service should include a 'Retry-After' header to indicate how long before another request
350
     *          can be made.
351
     * 
352
     * @param int $iRescode
353
     * @return string
354
     */
355
    protected function getPushServiceResponseText(int $iRescode) : string 
356
    {
357
        $strText = 'unknwown Rescode from push service: ' . $iRescode;
358
        $aText = array(
359
            201 => "The request to send a push message was received and accepted.",
360
            400 => "Invalid request. Invalid headers or improperly formatted.",
361
            404 => "Not Found. Subscription is expired and can't be used anymore.",
362
            410 => "Gone. Subscription is no longer valid.", // This can be reproduced by calling 'unsubscribe()' on a 'PushSubscription'.
363
            413 => "Payload size too large.",
364
            429 => "Too many requests. Your application server has reached a rate limit with a push service."
365
        );
366
        if (isset($aText[$iRescode])) {
367
            $strText = $aText[$iRescode];
368
        }
369
        return $strText;
370
    }
371
}
372