Passed
Push — 2.x ( e12ed9...43bb91 )
by Terry
03:35 queued 32s
created

Kernel::prepareMessengerBody()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 22
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 14
c 0
b 0
f 0
nc 2
nop 2
dl 0
loc 22
rs 9.7998
1
<?php
2
/*
3
 * @name        Shieldon Firewall
4
 * @author      Terry Lin
5
 * @link        https://github.com/terrylinooo/shieldon
6
 * @package     Shieldon
7
 * @since       1.0.0
8
 * @version     2.0.0
9
 * @license     MIT
10
 *
11
 * Permission is hereby granted, free of charge, to any person obtaining a copy
12
 * of this software and associated documentation files (the "Software"), to deal
13
 * in the Software without restriction, including without limitation the rights
14
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
 * copies of the Software, and to permit persons to whom the Software is
16
 * furnished to do so, subject to the following conditions:
17
 *
18
 * The above copyright notice and this permission notice shall be included in
19
 * all copies or substantial portions of the Software.
20
 *
21
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
27
 * THE SOFTWARE.
28
 */
29
30
declare(strict_types=1);
31
32
namespace Shieldon\Firewall;
33
34
use Psr\Http\Message\ServerRequestInterface;
35
use Psr\Http\Message\ResponseInterface;
36
use Shieldon\Firewall\Captcha\CaptchaInterface;
37
use Shieldon\Firewall\Captcha\Foundation;
38
use Shieldon\Firewall\Component\ComponentInterface;
39
use Shieldon\Firewall\Component\ComponentProvider;
40
use Shieldon\Firewall\Driver\DriverProvider;
41
use Shieldon\Firewall\Helpers;
42
use Shieldon\Firewall\HttpFactory;
43
use Shieldon\Firewall\Log\ActionLogger;
44
use Shieldon\Firewall\Utils\Container;
45
use Shieldon\Firewall\IpTrait;
46
use Shieldon\Firewall\Kernel\FilterTrait;
47
use Shieldon\Firewall\Kernel\RuleTrait;
48
use Shieldon\Messenger\Messenger\MessengerInterface;
49
use function Shieldon\Firewall\__;
50
use function Shieldon\Firewall\get_cpu_usage;
51
use function Shieldon\Firewall\get_default_properties;
52
use function Shieldon\Firewall\get_memory_usage;
53
use function Shieldon\Firewall\get_request;
54
use function Shieldon\Firewall\get_response;
55
use function Shieldon\Firewall\get_session;
56
57
58
use Closure;
59
use InvalidArgumentException;
60
use LogicException;
61
use RuntimeException;
62
use function file_exists;
63
use function file_put_contents;
64
use function filter_var;
65
use function get_class;
66
use function gethostbyaddr;
67
use function is_dir;
68
use function is_writable;
69
use function microtime;
70
use function ob_end_clean;
71
use function ob_get_contents;
72
use function ob_start;
73
use function str_replace;
74
use function strpos;
75
use function strrpos;
76
use function substr;
77
use function time;
78
79
/**
80
 * The primary Shiendon class.
81
 */
82
class Kernel
83
{
84
    use IpTrait;
85
    use FilterTrait;
86
    use RuleTrait;
87
88
    // Reason codes (allow)
89
    const REASON_IS_SEARCH_ENGINE = 100;
90
    const REASON_IS_GOOGLE = 101;
91
    const REASON_IS_BING = 102;
92
    const REASON_IS_YAHOO = 103;
93
    const REASON_IS_SOCIAL_NETWORK = 110;
94
    const REASON_IS_FACEBOOK = 111;
95
    const REASON_IS_TWITTER = 112;
96
97
    // Reason codes (deny)
98
    const REASON_TOO_MANY_SESSIONS = 1;
99
    const REASON_TOO_MANY_ACCESSES = 2; // (not used)
100
    const REASON_EMPTY_JS_COOKIE = 3;
101
    const REASON_EMPTY_REFERER = 4;
102
    
103
    const REASON_REACHED_LIMIT_DAY = 11;
104
    const REASON_REACHED_LIMIT_HOUR = 12;
105
    const REASON_REACHED_LIMIT_MINUTE = 13;
106
    const REASON_REACHED_LIMIT_SECOND = 14;
107
108
    const REASON_INVALID_IP = 40;
109
    const REASON_DENY_IP = 41;
110
    const REASON_ALLOW_IP = 42;
111
112
    const REASON_COMPONENT_IP = 81;
113
    const REASON_COMPONENT_RDNS = 82;
114
    const REASON_COMPONENT_HEADER = 83;
115
    const REASON_COMPONENT_USERAGENT = 84;
116
    const REASON_COMPONENT_TRUSTED_ROBOT = 85;
117
118
    const REASON_MANUAL_BAN = 99;
119
120
    // Action codes
121
    const ACTION_DENY = 0;
122
    const ACTION_ALLOW = 1;
123
    const ACTION_TEMPORARILY_DENY = 2;
124
    const ACTION_UNBAN = 9;
125
126
    // Result codes
127
    const RESPONSE_DENY = 0;
128
    const RESPONSE_ALLOW = 1;
129
    const RESPONSE_TEMPORARILY_DENY = 2;
130
    const RESPONSE_LIMIT_SESSION = 3;
131
132
    const LOG_LIMIT = 3;
133
    const LOG_PAGEVIEW = 11;
134
    const LOG_BLACKLIST = 98;
135
    const LOG_CAPTCHA = 99;
136
137
    const KERNEL_DIR = __DIR__;
138
139
    /**
140
     * Driver for storing data.
141
     *
142
     * @var \Shieldon\Firewall\Driver\DriverProvider
143
     */
144
    public $driver;
145
146
    /**
147
     * Container for Shieldon components.
148
     *
149
     * @var array
150
     */
151
    public $component = [];
152
153
    /**
154
     * Logger instance.
155
     *
156
     * @var ActionLogger
157
     */
158
    public $logger;
159
160
    /**
161
     * The closure functions that will be executed in this->run()
162
     *
163
     * @var array
164
     */
165
    protected $closures = [];
166
167
    /**
168
     * default settings
169
     *
170
     * @var array
171
     */
172
    protected $properties = [];
173
174
    /**
175
     * This is for creating data tables automatically
176
     * Turn it off, if you don't want to check data tables every connection.
177
     *
178
     * @var bool
179
     */
180
    protected $autoCreateDatabase = true;
181
182
    /**
183
     * Container for captcha addons.
184
     * The collection of \Shieldon\Firewall\Captcha\CaptchaInterface
185
     *
186
     * @var array
187
     */
188
    protected $captcha = [];
189
190
    /**
191
     * The ways Shieldon send a message to when someone has been blocked.
192
     * The collection of \Shieldon\Messenger\Messenger\MessengerInterface
193
     *
194
     * @var array
195
     */
196
    protected $messenger = [];
197
198
    /**
199
     * Is to limit traffic?
200
     *
201
     * @var array
202
     */
203
    protected $sessionLimit = [
204
205
        // How many sessions will be available?
206
        // 0 = no limit.
207
        'count' => 0,
208
209
        // How many minutes will a session be availe to visit?
210
        // 0 = no limit.
211
        'period' => 0, 
212
    ];
213
214
    /**
215
     * Record the online session status.
216
     * This will be enabled when $sessionLimit[count] > 0
217
     *
218
     * @var array
219
     */
220
    protected $sessionStatus = [
221
222
        // Online session count.
223
        'count' => 0,
224
225
        // Current session order.
226
        'order' => 0,
227
228
        // Current waiting queue.
229
        'queue' => 0,
230
    ];
231
232
    /**
233
     * The events.
234
     *
235
     * @var array
236
     */
237
    protected $event = [
238
239
        // Update rule table when this value true.
240
        'update_rule_table' => false,
241
242
        // Send notifications when this value true.
243
        'trigger_messengers' => false,
244
    ];
245
246
    /**
247
     * Result.
248
     *
249
     * @var int
250
     */
251
    protected $result = 1;
252
253
    /**
254
     * URLs that are excluded from Shieldon's protection.
255
     *
256
     * @var array
257
     */
258
    protected $excludedUrls = [];
259
260
    /**
261
     * Which type of configuration source that Shieldon firewall managed?
262
     *
263
     * @var string
264
     */
265
    protected $firewallType = 'self'; // managed | config | self | demo
266
267
    /**
268
     * Custom dialog UI settings.
269
     *
270
     * @var array
271
     */
272
    protected $dialogUI = [];
273
274
    /**
275
     * Store the class information used in Shieldon.
276
     *
277
     * @var array
278
     */
279
    protected $registrar = [];
280
281
    /**
282
     * Strict mode.
283
     * 
284
     * Set by `strictMode()` only. The default value of this propertry is undefined.
285
     *
286
     * @var bool
287
     */
288
    protected $strictMode;
289
290
    /**
291
     * The directory in where the frontend template files are placed.
292
     *
293
     * @var string
294
     */
295
    protected $templateDirectory = '';
296
297
    /**
298
     * The message that will be sent to the third-party API.
299
     *
300
     * @var string
301
     */
302
    protected $msgBody = '';
303
304
    /**
305
     * Shieldon constructor.
306
     * 
307
     * @param ServerRequestInterface|null $request  A PSR-7 server request.
308
     * 
309
     * @return void
310
     */
311
    public function __construct(?ServerRequestInterface $request  = null, ?ResponseInterface $response = null)
312
    {
313
        // Load helper functions. This is the must.
314
        new Helpers();
315
316
        if (is_null($request)) {
317
            $request = HttpFactory::createRequest();
318
        }
319
320
        if (is_null($response)) {
321
            $response = HttpFactory::createResponse();
322
        }
323
324
        $session = HttpFactory::createSession();
325
326
        $this->properties = get_default_properties();
327
        $this->add(new Foundation());
328
329
        Container::set('request', $request);
330
        Container::set('response', $response);
331
        Container::set('session', $session);
332
        Container::set('shieldon', $this);
333
    }
334
335
    /**
336
     * Log actions.
337
     *
338
     * @param int $actionCode The code number of the action.
339
     *
340
     * @return void
341
     */
342
    protected function log(int $actionCode): void
343
    {
344
        if (null !== $this->logger) {
345
            $logData = [];
346
            $logData['ip'] = $this->getIp();
347
            $logData['session_id'] = get_session()->get('id');
348
            $logData['action_code'] = $actionCode;
349
            $logData['timesamp'] = time();
350
    
351
            $this->logger->add($logData);
352
        }
353
    }
354
355
    /**
356
     * Initialize components.
357
     *
358
     * @return void
359
     */
360
    private function initComponents()
361
    {
362
        foreach (array_keys($this->component) as $name) {
363
            $this->component[$name]->setIp($this->ip);
364
            $this->component[$name]->setRdns($this->rdns);
365
366
            // Apply global strict mode to all components by `strictMode()` if nesscessary.
367
            if (isset($this->strictMode)) {
368
                $this->component[$name]->setStrict($this->strictMode);
369
            }
370
        }
371
    }
372
373
    /**
374
     * Check if current IP is trusted or not.
375
     *
376
     * @return bool
377
     */
378
    private function isTrustedBot()
379
    {
380
        if ($this->getComponent('TrustedBot')) {
381
382
            // We want to put all the allowed robot into the rule list, so that the checking of IP's resolved hostname 
383
            // is no more needed for that IP.
384
            if ($this->getComponent('TrustedBot')->isAllowed()) {
0 ignored issues
show
Bug introduced by
The method isAllowed() does not exist on Shieldon\Firewall\Component\ComponentInterface. It seems like you code against a sub-type of Shieldon\Firewall\Component\ComponentInterface such as Shieldon\Firewall\Component\Ip or Shieldon\Firewall\Component\TrustedBot. ( Ignorable by Annotation )

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

384
            if ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isAllowed()) {
Loading history...
385
386
                if ($this->getComponent('TrustedBot')->isGoogle()) {
0 ignored issues
show
Bug introduced by
The method isGoogle() does not exist on Shieldon\Firewall\Component\ComponentInterface. It seems like you code against a sub-type of Shieldon\Firewall\Component\ComponentInterface such as Shieldon\Firewall\Component\TrustedBot. ( Ignorable by Annotation )

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

386
                if ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isGoogle()) {
Loading history...
387
                    // Add current IP into allowed list, because it is from real Google domain.
388
                    $this->action(
389
                        self::ACTION_ALLOW,
390
                        self::REASON_IS_GOOGLE
391
                    );
392
393
                } elseif ($this->getComponent('TrustedBot')->isBing()) {
0 ignored issues
show
Bug introduced by
The method isBing() does not exist on Shieldon\Firewall\Component\ComponentInterface. It seems like you code against a sub-type of Shieldon\Firewall\Component\ComponentInterface such as Shieldon\Firewall\Component\TrustedBot. ( Ignorable by Annotation )

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

393
                } elseif ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isBing()) {
Loading history...
394
                    // Add current IP into allowed list, because it is from real Bing domain.
395
                    $this->action(
396
                        self::ACTION_ALLOW,
397
                        self::REASON_IS_BING
398
                    );
399
400
                } elseif ($this->getComponent('TrustedBot')->isYahoo()) {
0 ignored issues
show
Bug introduced by
The method isYahoo() does not exist on Shieldon\Firewall\Component\ComponentInterface. It seems like you code against a sub-type of Shieldon\Firewall\Component\ComponentInterface such as Shieldon\Firewall\Component\TrustedBot. ( Ignorable by Annotation )

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

400
                } elseif ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isYahoo()) {
Loading history...
401
                    // Add current IP into allowed list, because it is from real Yahoo domain.
402
                    $this->action(
403
                        self::ACTION_ALLOW,
404
                        self::REASON_IS_YAHOO
405
                    );
406
407
                } else {
408
                    // Add current IP into allowed list, because you trust it.
409
                    // You have already defined it in the settings.
410
                    $this->action(
411
                        self::ACTION_ALLOW,
412
                        self::REASON_IS_SEARCH_ENGINE
413
                    );
414
                }
415
                // Allowed robots not join to our traffic handler.
416
                $this->result = self::RESPONSE_ALLOW;
417
                return true;
418
            }
419
        }
420
        return false;
421
    }
422
423
    /**
424
     * Check whether the IP is fake search engine or not.
425
     * The method "isTrustedBot()" must be executed before this method.
426
     *
427
     * @return bool
428
     */
429
    private function isFakeRobot(): bool
430
    {
431
        if ($this->getComponent('TrustedBot')) {
432
            if ($this->getComponent('TrustedBot')->isFakeRobot()) {
0 ignored issues
show
Bug introduced by
The method isFakeRobot() does not exist on Shieldon\Firewall\Component\ComponentInterface. It seems like you code against a sub-type of Shieldon\Firewall\Component\ComponentInterface such as Shieldon\Firewall\Component\TrustedBot. ( Ignorable by Annotation )

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

432
            if ($this->getComponent('TrustedBot')->/** @scrutinizer ignore-call */ isFakeRobot()) {
Loading history...
433
                $this->action(
434
                    self::ACTION_DENY,
435
                    self::REASON_COMPONENT_TRUSTED_ROBOT
436
                );
437
                $this->result = self::RESPONSE_DENY;
438
                return true;
439
            }
440
        }
441
        return false;
442
    }
443
444
    /**
445
     * Run, run, run!
446
     *
447
     * Check the rule tables first, if an IP address has been listed.
448
     * Call function filter() if an IP address is not listed in rule tables.
449
     *
450
     * @return int The response code.
451
     */
452
    protected function process(): int
453
    {
454
        $this->driver->init($this->autoCreateDatabase);
455
456
        $this->initComponents();
457
458
        /*
459
        |--------------------------------------------------------------------------
460
        | Stage - Looking for rule table.
461
        |--------------------------------------------------------------------------
462
        */
463
464
        if ($this->DoesRuleExist()) {
465
            return $this->result;
466
        }
467
468
        /*
469
        |--------------------------------------------------------------------------
470
        | Statge - Detect popular search engine.
471
        |--------------------------------------------------------------------------
472
        */
473
474
        if ($this->isTrustedBot()) {
475
            return $this->result;
476
        }
477
478
        if ($this->isFakeRobot()) {
479
            return $this->result;
480
        }
481
        
482
        /*
483
        |--------------------------------------------------------------------------
484
        | Stage - IP component.
485
        |--------------------------------------------------------------------------
486
        */
487
488
        if ($this->getComponent('Ip')) {
489
490
            $result = $this->getComponent('Ip')->check();
0 ignored issues
show
Bug introduced by
The method check() does not exist on Shieldon\Firewall\Component\ComponentInterface. It seems like you code against a sub-type of Shieldon\Firewall\Component\ComponentInterface such as Shieldon\Firewall\Component\Ip. ( Ignorable by Annotation )

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

490
            $result = $this->getComponent('Ip')->/** @scrutinizer ignore-call */ check();
Loading history...
491
            $actionCode = self::ACTION_DENY;
492
493
            if (!empty($result)) {
494
495
                switch ($result['status']) {
496
497
                    case 'allow':
498
                        $actionCode = self::ACTION_ALLOW;
499
                        $reasonCode = $result['code'];
500
                        break;
501
    
502
                    case 'deny':
503
                        $actionCode = self::ACTION_DENY;
504
                        $reasonCode = $result['code']; 
505
                        break;
506
                }
507
508
                $this->action($actionCode, $reasonCode);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $reasonCode does not seem to be defined for all execution paths leading up to this point.
Loading history...
509
510
                // $resultCode = $actionCode
511
                return $this->result = $this->sessionHandler($actionCode);
512
            }
513
        }
514
515
        /*
516
        |--------------------------------------------------------------------------
517
        | Stage - Check all other components.
518
        |--------------------------------------------------------------------------
519
        */
520
521
        foreach ($this->component as $component) {
522
523
            // check if is a a bad robot already defined in settings.
524
            if ($component->isDenied()) {
525
526
                // @since 0.1.8
527
                $this->action(
528
                    self::ACTION_DENY,
529
                    $component->getDenyStatusCode()
530
                );
531
532
                return $this->result = self::RESPONSE_DENY;
533
            }
534
        }
535
        
536
537
        /*
538
        |--------------------------------------------------------------------------
539
        | Stage - Filters
540
        |--------------------------------------------------------------------------
541
        | This IP address is not listed in rule table, let's detect it.
542
        |
543
        */
544
545
        if (
546
            $this->filterStatus['frequency'] ||
547
            $this->filterStatus['referer'] ||
548
            $this->filterStatus['session'] ||
549
            $this->filterStatus['cookie']
550
        ) {
551
            return $this->result = $this->sessionHandler($this->filter());
552
        }
553
554
        return $this->result = $this->sessionHandler(self::RESPONSE_ALLOW);
555
    }
556
557
    /**
558
     * Start an action for this IP address, allow or deny, and give a reason for it.
559
     *
560
     * @param int    $actionCode - 0: deny, 1: allow, 9: unban.
561
     * @param string $reasonCode
562
     * @param string $assignIp
563
     * 
564
     * @return void
565
     */
566
    protected function action(int $actionCode, int $reasonCode, string $assignIp = ''): void
567
    {
568
        $ip = $this->ip;
569
        $rdns = $this->rdns;
570
        $now = time();
571
        $logData = [];
572
    
573
        if ('' !== $assignIp) {
574
            $ip = $assignIp;
575
            $rdns = gethostbyaddr($ip);
576
        }
577
578
        switch ($actionCode) {
579
            case self::ACTION_ALLOW: // acutally not used.
580
            case self::ACTION_DENY:  // actually not used.
581
            case self::ACTION_TEMPORARILY_DENY:
582
                $logData['log_ip']     = $ip;
583
                $logData['ip_resolve'] = $rdns;
584
                $logData['time']       = $now;
585
                $logData['type']       = $actionCode;
586
                $logData['reason']     = $reasonCode;
587
                $logData['attempts']   = 0;
588
589
                $this->driver->save($ip, $logData, 'rule');
590
                break;
591
            
592
            case self::ACTION_UNBAN:
593
                $this->driver->delete($ip, 'rule');
594
                break;
595
        }
596
597
        // Remove logs for this IP address because It already has it's own rule on system.
598
        // No need to count it anymore.
599
        $this->driver->delete($ip, 'filter');
600
601
        if (null !== $this->logger) {
602
            $log['ip']          = $ip;
0 ignored issues
show
Comprehensibility Best Practice introduced by
$log was never initialized. Although not strictly required by PHP, it is generally a good practice to add $log = array(); before regardless.
Loading history...
603
            $log['session_id']  = get_session()->get('id');
604
            $log['action_code'] = $actionCode;
605
            $log['timesamp']    = $now;
606
607
            $this->logger->add($log);
608
        }
609
    }
610
611
    /**
612
     * Deal with online sessions.
613
     *
614
     * @param int $statusCode The response code.
615
     *
616
     * @return int The response code.
617
     */
618
    protected function sessionHandler($statusCode): int
619
    {
620
        if (self::RESPONSE_ALLOW !== $statusCode) {
621
            return $statusCode;
622
        }
623
624
        // If you don't enable `limit traffic`, ignore the following steps.
625
        if (empty($this->sessionLimit['count'])) {
626
            return self::RESPONSE_ALLOW;
627
628
        } else {
629
630
            // Get the proerties.
631
            $limit = (int) ($this->sessionLimit['count'] ?? 0);
632
            $period = (int) ($this->sessionLimit['period'] ?? 300);
633
            $now = time();
634
635
            $sessionData = $this->driver->getAll('session');
636
            $sessionPools = [];
637
638
            $i = 1;
639
            $sessionOrder = 0;
640
641
            if (!empty($sessionData)) {
642
                foreach ($sessionData as $v) {
643
                    $sessionPools[] = $v['id'];
644
                    $lasttime = (int) $v['time'];
645
    
646
                    if (get_session()->get('id') === $v['id']) {
647
                        $sessionOrder = $i;
648
                    }
649
    
650
                    // Remove session if it expires.
651
                    if ($now - $lasttime > $period) {
652
                        $this->driver->delete($v['id'], 'session');
653
                    }
654
                    $i++;
655
                }
656
657
                if (0 === $sessionOrder) {
658
                    $sessionOrder = $i;
659
                }
660
            } else {
661
                $sessionOrder = 0;
662
            }
663
664
            // Count the online sessions.
665
            $this->sessionStatus['count'] = count($sessionPools);
666
            $this->sessionStatus['order'] = $sessionOrder;
667
            $this->sessionStatus['queue'] = $sessionOrder - $limit;
668
669
            if (!in_array(get_session()->get('id'), $sessionPools)) {
670
                $this->sessionStatus['count']++;
671
672
                // New session, record this data.
673
                $data['id'] = get_session()->get('id');
0 ignored issues
show
Comprehensibility Best Practice introduced by
$data was never initialized. Although not strictly required by PHP, it is generally a good practice to add $data = array(); before regardless.
Loading history...
674
                $data['ip'] = $this->ip;
675
                $data['time'] = $now;
676
677
                $microtimesamp = explode(' ', microtime());
678
                $microtimesamp = $microtimesamp[1] . str_replace('0.', '', $microtimesamp[0]);
679
                $data['microtimesamp'] = $microtimesamp;
680
681
                $this->driver->save(get_session()->get('id'), $data, 'session');
682
            }
683
684
            // Online session count reached the limit. So return RESPONSE_LIMIT_SESSION response code.
685
            if ($sessionOrder >= $limit) {
686
                return self::RESPONSE_LIMIT_SESSION;
687
            }
688
        }
689
690
        return self::RESPONSE_ALLOW;
691
    }
692
693
694
695
    // @codeCoverageIgnoreStart
696
697
    /**
698
     * For testing propose.
699
     *
700
     * @param string $sessionId
701
     *
702
     * @return void
703
     */
704
    protected function setSessionId(string $sessionId = ''): void
705
    {
706
        if ('' !== $sessionId) {
707
            get_session()->set('id', $sessionId);
708
        }
709
    }
710
711
    // @codeCoverageIgnoreEnd
712
713
    /*
714
    | -------------------------------------------------------------------
715
    |                            Public APIs
716
    | -------------------------------------------------------------------
717
    */
718
719
    /**
720
     * Register classes to Shieldon core.
721
     * setDriver, setLogger, setComponent and setCaptcha are deprecated methods
722
     * and no more used.
723
     *
724
     * @param object $instance Component classes that used on Shieldon.
725
     *
726
     * @return void
727
     */
728
    public function add($instance)
729
    {
730
        static $i = 2;
731
732
        $class = $this->getClassName($instance);
733
734
        if ($instance instanceof DriverProvider) {
735
            $this->driver = $instance;
736
            $this->registrar[0] = [
737
                'category' => 'driver',
738
                'class' => $class,
739
            ];
740
        }
741
742
        if ($instance instanceof ActionLogger) {
743
            $this->logger = $instance;
744
            $this->registrar[1] = [
745
                'category' => 'logger',
746
                'class' => $class,
747
            ];
748
        }
749
750
        if ($instance instanceof CaptchaInterface) {
751
            $this->captcha[$class] = $instance;
752
            $this->registrar[$i] = [
753
                'category' => 'captcha',
754
                'class' => $class,
755
            ];
756
            $i++;
757
        }
758
759
        if ($instance instanceof ComponentProvider) {
760
            $this->component[$class] = $instance;
761
            $this->registrar[$i] = [
762
                'category' => 'component',
763
                'class' => $class,
764
            ];
765
            $i++;
766
        }
767
768
        if ($instance instanceof MessengerInterface) {
769
            $this->messenger[] = $instance;
770
            $this->registrar[$i] = [
771
                'category' => 'messenger',
772
                'class' => $class,
773
            ];
774
            $i++;
775
        }
776
    }
777
778
    /**
779
     * Remove registered classes from the Kernel.
780
     *
781
     * @param string $category  The class category.
782
     * @param string $className The class name.
783
     *
784
     * @return void
785
     */
786
    public function remove(string $category, string $className = '')
787
    {
788
        if ($className !== '') {
789
            foreach ($this->getRegistrar() as $k => $v) {
790
                if ($category === $v['category'] && $className === $v['class']) {
791
                    if (is_array($this->{$category})) {
792
                        foreach ($this->{$category} as $k2 => $instance) {
793
                            if ($this->getClassName($instance) === $className) {
794
                                unset($this->{$category}[$k2]);
795
                            }
796
                        }
797
                    } else {
798
                        $this->{$category} = null;
799
                    }
800
                    unset($this->registrar[$k]);
801
                }
802
            }
803
        } else {
804
            foreach ($this->getRegistrar() as $k => $v) {
805
                if ($category === $v['category']) {
806
                    if (is_array($this->{$category})) {
807
                        $this->{$category} = [];
808
                    } else {
809
                        $this->{$category} = null;
810
                    }
811
                    unset($this->registrar[$k]);
812
                }
813
            }
814
        }
815
    }
816
817
    /**
818
     * Fetch the class list from registrar.
819
     *
820
     * @return array
821
     */
822
    public function getRegistrar(): array
823
    {
824
        return $this->registrar;
825
    }
826
827
    /**
828
     * Get a component instance from component's container.
829
     *
830
     * @param string $name The component's class name.
831
     *
832
     * @return ComponentInterface|null
833
     */
834
    public function getComponent(string $name)
835
    {
836
        if (isset($this->component[$name])) {
837
            return $this->component[$name];
838
        }
839
840
        return null;
841
    }
842
843
    /**
844
     * Strict mode.
845
     * This option will take effects to all components.
846
     * 
847
     * @param bool $bool Set true to enble strict mode, false to disable it overwise.
848
     *
849
     * @return void
850
     */
851
    public function setStrict(bool $bool)
852
    {
853
        $this->strictMode = $bool;
854
    }
855
856
    /**
857
     * Disable filters.
858
     */
859
    public function disableFilters(): void
860
    {
861
        $this->setFilters([
862
            'session'   => false,
863
            'cookie'    => false,
864
            'referer'   => false,
865
            'frequency' => false,
866
        ]);
867
    }
868
869
    /**
870
     * For first time installation only. This is for creating data tables automatically.
871
     * Turning it on will check the data tables exist or not at every single pageview, 
872
     * it's not good for high traffic websites.
873
     *
874
     * @param bool $bool
875
     * 
876
     * @return void
877
     */
878
    public function createDatabase(bool $bool)
879
    {
880
        $this->autoCreateDatabase = $bool;
881
    }
882
883
    /**
884
     * Set a data channel.
885
     *
886
     * This will create databases for the channel.
887
     *
888
     * @param string $channel Specify a channel.
889
     *
890
     * @return void
891
     */
892
    public function setChannel(string $channel)
893
    {
894
        if (!$this->driver instanceof DriverProvider) {
0 ignored issues
show
introduced by
$this->driver is always a sub-type of Shieldon\Firewall\Driver\DriverProvider.
Loading history...
895
            throw new LogicException('setChannel method requires setDriver set first.');
896
        } else {
897
            $this->driver->setChannel($channel);
898
        }
899
    }
900
901
    /**
902
     * Return the result from Captchas.
903
     *
904
     * @return bool
905
     */
906
    public function captchaResponse(): bool
907
    {
908
        foreach ($this->captcha as $captcha) {
909
            
910
            if (!$captcha->response()) {
911
                return false;
912
            }
913
        }
914
915
        if (!empty($this->sessionLimit['count'])) {
916
            $this->result = $this->sessionHandler(self::RESPONSE_ALLOW);
917
        }
918
919
        return true;
920
    }
921
922
    /**
923
     * Ban an IP.
924
     *
925
     * @param string $ip A valid IP address.
926
     *
927
     * @return void
928
     */
929
    public function ban(string $ip = ''): void
930
    {
931
        if ('' === $ip) {
932
            $ip = $this->ip;
933
        }
934
 
935
        $this->action(
936
            self::ACTION_DENY,
937
            self::REASON_MANUAL_BAN, $ip
938
        );
939
    }
940
941
    /**
942
     * Unban an IP.
943
     *
944
     * @param string $ip A valid IP address.
945
     *
946
     * @return void
947
     */
948
    public function unban(string $ip = ''): void
949
    {
950
        if ('' === $ip) {
951
            $ip = $this->ip;
952
        }
953
954
        $this->action(
955
            self::ACTION_UNBAN,
956
            self::REASON_MANUAL_BAN, $ip
957
        );
958
        $this->log(self::ACTION_UNBAN);
959
960
        $this->result = self::RESPONSE_ALLOW;
961
    }
962
963
    /**
964
     * Set a property setting.
965
     *
966
     * @param string $key   The key of a property setting.
967
     * @param mixed  $value The value of a property setting.
968
     *
969
     * @return void
970
     */
971
    public function setProperty(string $key = '', $value = '')
972
    {
973
        if (isset($this->properties[$key])) {
974
            $this->properties[$key] = $value;
975
        }
976
    }
977
978
    /**
979
     * Set the property settings.
980
     * 
981
     * @param array $settings The settings.
982
     *
983
     * @return void
984
     */
985
    public function setProperties(array $settings): void
986
    {
987
        foreach (array_keys($this->properties) as $k) {
988
            if (isset($settings[$k])) {
989
                $this->properties[$k] = $settings[$k];
990
            }
991
        }
992
    }
993
994
    /**
995
     * Limt online sessions.
996
     *
997
     * @param int $count
998
     * @param int $period
999
     *
1000
     * @return void
1001
     */
1002
    public function limitSession(int $count = 1000, int $period = 300): void
1003
    {
1004
        $this->sessionLimit = [
1005
            'count' => $count,
1006
            'period' => $period
1007
        ];
1008
    }
1009
1010
    /**
1011
     * Customize the dialog UI.
1012
     *
1013
     * @return void
1014
     */
1015
    public function setDialogUI(array $settings): void
1016
    {
1017
        $this->dialogUI = $settings;
1018
    }
1019
1020
    /**
1021
     * Set the frontend template directory.
1022
     *
1023
     * @param string $directory
1024
     *
1025
     * @return void
1026
     */
1027
    public function setTemplateDirectory(string $directory)
1028
    {
1029
        if (!is_dir($directory)) {
1030
            throw new InvalidArgumentException('The template directory does not exist.');
1031
        }
1032
        $this->templateDirectory = $directory;
1033
    }
1034
1035
    /**
1036
     * Get a template PHP file.
1037
     *
1038
     * @param string $type The template type.
1039
     *
1040
     * @return string
1041
     */
1042
    protected function getTemplate(string $type): string
1043
    {
1044
        $directory = self::KERNEL_DIR . '/../../templates/frontend';
1045
1046
        if (!empty($this->templateDirectory)) {
1047
            $directory = $this->templateDirectory;
1048
        }
1049
1050
        $path = $directory . '/' . $type . '.php';
1051
1052
        if (!file_exists($path)) {
1053
            throw new RuntimeException(
1054
                sprintf(
1055
                    'The templeate file is missing. (%s)',
1056
                    $path
1057
                )
1058
            );
1059
        }
1060
1061
        return $path;
1062
    }
1063
1064
    /**
1065
     * Get a class name without namespace string.
1066
     *
1067
     * @param object $instance Class
1068
     * 
1069
     * @return void
1070
     */
1071
    protected function getClassName($instance): string
1072
    {
1073
        $class = get_class($instance);
1074
        return substr($class, strrpos($class, '\\') + 1); 
0 ignored issues
show
Bug Best Practice introduced by
The expression return substr($class, strrpos($class, '\') + 1) returns the type string which is incompatible with the documented return type void.
Loading history...
1075
    }
1076
1077
    /**
1078
     * Respond the result.
1079
     *
1080
     * @return ResponseInterface
1081
     */
1082
    public function respond(): ResponseInterface
1083
    {
1084
        $response = get_response();
1085
        $type = '';
1086
1087
        if (self::RESPONSE_TEMPORARILY_DENY === $this->result) {
1088
            $type = 'captcha';
1089
            $statusCode = 403; // Forbidden.
1090
1091
        } elseif (self::RESPONSE_LIMIT_SESSION === $this->result) {
1092
            $type = 'session_limitation';
1093
            $statusCode = 429; // Too Many Requests.
1094
1095
        } elseif (self::RESPONSE_DENY === $this->result) {
1096
            $type = 'rejection';
1097
            $statusCode = 400; // Bad request.
1098
        }
1099
1100
        // Nothing happened. Return.
1101
        if (empty($type)) {
1102
            // @codeCoverageIgnoreStart
1103
            return $response;
1104
            // @codeCoverageIgnoreEnd
1105
        }
1106
1107
        $viewPath = $this->getTemplate($type);
1108
1109
        // The language of output UI. It is used on views.
1110
        $langCode = get_session()->get('shieldon_ui_lang') ?? 'en';
1111
        // Show online session count. It is used on views.
1112
        $showOnlineInformation = true;
1113
        // Show user information such as IP, user-agent, device name.
1114
        $showUserInformation = true;
1115
1116
        if (empty($this->properties['display_online_info'])) {
1117
            $showOnlineInformation = false;
1118
        }
1119
1120
        if (empty($this->properties['display_user_info'])) {
1121
            $showUserInformation = false;
1122
        }
1123
1124
        if ($showUserInformation) {
1125
            $dialoguserinfo['ip'] = $this->ip;
0 ignored issues
show
Comprehensibility Best Practice introduced by
$dialoguserinfo was never initialized. Although not strictly required by PHP, it is generally a good practice to add $dialoguserinfo = array(); before regardless.
Loading history...
1126
            $dialoguserinfo['rdns'] = $this->rdns;
1127
            $dialoguserinfo['user_agent'] = get_request()->getHeaderLine('user-agent');
1128
        }
1129
1130
        $ui = [
1131
            'background_image' => $this->dialogUI['background_image'] ?? '',
1132
            'bg_color'         => $this->dialogUI['bg_color']         ?? '#ffffff',
1133
            'header_bg_color'  => $this->dialogUI['header_bg_color']  ?? '#212531',
1134
            'header_color'     => $this->dialogUI['header_color']     ?? '#ffffff',
1135
            'shadow_opacity'   => $this->dialogUI['shadow_opacity']   ?? '0.2',
1136
        ];
1137
1138
        if (!defined('SHIELDON_VIEW')) {
1139
            define('SHIELDON_VIEW', true);
1140
        }
1141
1142
        $css = require $this->getTemplate('css/default');
1143
1144
        ob_start();
1145
        require $viewPath;
1146
        $output = ob_get_contents();
1147
        ob_end_clean();
1148
1149
        // Remove unused variable notices generated from PHP intelephense.
1150
        unset(
1151
            $css,
1152
            $ui,
1153
            $langCode,
1154
            $showOnlineInformation,
1155
            $showLineupInformation,
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $showLineupInformation seems to be never defined.
Loading history...
1156
            $showUserInformation
1157
        );
1158
1159
        $stream = $response->getBody();
1160
        $stream->write($output);
1161
        $stream->rewind();
1162
1163
        return $response->
1164
            withHeader('X-Protected-By', 'shieldon.io')->
1165
            withBody($stream)->
1166
            withStatus($statusCode);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $statusCode does not seem to be defined for all execution paths leading up to this point.
Loading history...
1167
    }
1168
1169
    /**
1170
     * Run, run, run!
1171
     *
1172
     * Check the rule tables first, if an IP address has been listed.
1173
     * Call function filter() if an IP address is not listed in rule tables.
1174
     *
1175
     * @return 
1176
     */
1177
    public function run(): int
1178
    {
1179
        if (!isset($this->registrar[0])) {
1180
            throw new RuntimeException(
1181
                'Must register at least one data driver.'
1182
            );
1183
        }
1184
        
1185
        // Ignore the excluded urls.
1186
        if (!empty($this->excludedUrls)) {
1187
            foreach ($this->excludedUrls as $url) {
1188
                if (0 === strpos(get_request()->getUri()->getPath(), $url)) {
1189
                    return $this->result = self::RESPONSE_ALLOW;
1190
                }
1191
            }
1192
        }
1193
1194
        // Execute closure functions.
1195
        foreach ($this->closures as $closure) {
1196
            $closure();
1197
        }
1198
1199
        $result = $this->process();
1200
1201
        if ($result !== self::RESPONSE_ALLOW) {
1202
1203
            // Current session did not pass the CAPTCHA, it is still stuck in CAPTCHA page.
1204
            $actionCode = self::LOG_CAPTCHA;
1205
1206
            // If current session's respone code is RESPONSE_DENY, record it as `blacklist_count` in our logs.
1207
            // It is stuck in warning page, not CAPTCHA.
1208
            if ($result === self::RESPONSE_DENY) {
1209
                $actionCode = self::LOG_BLACKLIST;
1210
            }
1211
1212
            if ($result === self::RESPONSE_LIMIT_SESSION) {
1213
                $actionCode = self::LOG_LIMIT;
1214
            }
1215
1216
            $this->log($actionCode);
1217
1218
        } else {
1219
1220
            $this->log(self::LOG_PAGEVIEW);
1221
        }
1222
1223
 
1224
        if (!empty($this->msgBody)) {
1225
 
1226
            // @codeCoverageIgnoreStart
1227
1228
            try {
1229
                foreach ($this->messenger as $messenger) {
1230
                    $messenger->setTimeout(2);
1231
                    $messenger->send($this->msgBody);
1232
                }
1233
            } catch (RuntimeException $e) {
1234
                // Do not throw error, becasue the third-party services might be unavailable.
1235
            }
1236
1237
            // @codeCoverageIgnoreEnd
1238
        }
1239
1240
1241
        return $result;
1242
    }
1243
1244
    /**
1245
     * Set the filters.
1246
     *
1247
     * @param array $settings filter settings.
1248
     *
1249
     * @return void
1250
     */
1251
    public function setFilters(array $settings)
1252
    {
1253
        foreach (array_keys($this->filterStatus) as $k) {
1254
            if (isset($settings[$k])) {
1255
                $this->filterStatus[$k] = $settings[$k] ?? false;
1256
            }
1257
        }
1258
    }
1259
1260
    /**
1261
     * Set a filter.
1262
     *
1263
     * @param string $filterName The filter's name.
1264
     * @param bool   $value      True for enabling the filter, overwise.
1265
     *
1266
     * @return void
1267
     */
1268
    public function setFilter(string $filterName, bool $value): void
1269
    {
1270
        if (isset($this->filterStatus[$filterName])) {
1271
            $this->filterStatus[$filterName] = $value;
1272
        }
1273
    }
1274
1275
    /**
1276
     * Get online people count. If enable limitSession.
1277
     *
1278
     * @return int
1279
     */
1280
    public function getSessionCount(): int
1281
    {
1282
        return $this->sessionStatus['count'];
1283
    }
1284
1285
    /**
1286
     * Set the URLs you want them to be excluded them from protection.
1287
     *
1288
     * @param array $urls The list of URL want to be excluded.
1289
     *
1290
     * @return void
1291
     */
1292
    public function setExcludedUrls(array $urls = []): void
1293
    {
1294
        $this->excludedUrls = $urls;
1295
    }
1296
1297
    /**
1298
     * Set a closure function.
1299
     *
1300
     * @param string  $key     The name for the closure class.
1301
     * @param Closure $closure An instance will be later called.
1302
     *
1303
     * @return void
1304
     */
1305
    public function setClosure(string $key, Closure $closure): void
1306
    {
1307
        $this->closures[$key] = $closure;
1308
    }
1309
1310
    /**
1311
     * Print a JavasSript snippet in your webpages.
1312
     * 
1313
     * This snippet generate cookie on client's browser,then we check the 
1314
     * cookie to identify the client is a rebot or not.
1315
     *
1316
     * @return string
1317
     */
1318
    public function outputJsSnippet(): string
1319
    {
1320
        $tmpCookieName = $this->properties['cookie_name'];
1321
        $tmpCookieDomain = $this->properties['cookie_domain'];
1322
1323
        if (empty($tmpCookieDomain) && get_request()->getHeaderLine('host')) {
1324
            $tmpCookieDomain = get_request()->getHeaderLine('host');
1325
        }
1326
1327
        $tmpCookieValue = $this->properties['cookie_value'];
1328
1329
        $jsString = '
1330
            <script>
1331
                var d = new Date();
1332
                d.setTime(d.getTime()+(60*60*24*30));
1333
                document.cookie = "' . $tmpCookieName . '=' . $tmpCookieValue . ';domain=.' . $tmpCookieDomain . ';expires="+d.toUTCString();
1334
            </script>
1335
        ';
1336
1337
        return $jsString;
1338
    }
1339
1340
    /**
1341
     * Get current visior's path.
1342
     *
1343
     * @return string
1344
     */
1345
    public function getCurrentUrl(): string
1346
    {
1347
        return get_request()->getUri()->getPath();
1348
    }
1349
1350
    /**
1351
     * Displayed on Firewall Panel, tell you current what type of current
1352
     * configuration is used for.
1353
     * 
1354
     * @param string $type The type of configuration.
1355
     *                     demo | managed | config
1356
     *
1357
     * @return void
1358
     */
1359
    public function managedBy(string $type = ''): void
1360
    {
1361
        if (in_array($type, ['managed', 'config', 'demo'])) {
1362
            $this->firewallType = $type;
1363
        }
1364
    }
1365
}
1366