Completed
Push — master ( 23153f...eb881d )
by Angus
03:14
created

Limiter::add_user_data()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 8
nc 4
nop 1
dl 0
loc 13
rs 9.4285
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * CodeIgniter limiter
5
 *
6
 * @license     MIT License
7
 * @package     Limiter
8
 * @author      Mechazawa
9
 * @version     1.0.0
10
 */
11
12
13
class Limiter {
14
15
    /** @type CI_Controller */
16
    protected $CI;
17
    protected $table      = 'rate_limit';
18
    protected $base_limit = 0; // infinite
19
    protected $whitelist  = array('127.0.0.1');
20
    protected $header_show        = TRUE;
21
    protected $checksum_algorithm = 'md4';
22
    protected $header_prefix      = 'X-RateLimit-';
23
    protected $flush_on_abort     = FALSE;
24
25
    protected $user_data = array();
26
    protected $user_hash = FALSE;
27
28
    private $_truncated  = FALSE;
29
    private $_info_cache = array();
30
31
    private $_sql_truncate  = 'DELETE FROM `RATE_TABLE` WHERE `start` < (NOW() - INTERVAL 1 HOUR)';
32
    private $_sql_info      = 'SELECT `count`, `start`, (`start` + INTERVAL (1 - TIMESTAMPDIFF(HOUR, UTC_TIMESTAMP(), NOW())) HOUR) \'reset_epoch\' FROM `RATE_TABLE` WHERE `client` = ? AND `target` = ?';
33
    private $_sql_update    = 'INSERT INTO `RATE_TABLE` (`client`, `target`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `count` = `count` + 1';
34
    private $_config_fields = array(
35
        'table', 'base_limit', 'checksum_algorithm',
36
        'header', 'header_prefix', 'flush_on_abort'
37
    );
38
39
    public function __construct($config = array()) {
40
        $this->CI = &get_instance();
41
42
        if(!is_array($config)) {
43
            $config = array();
44
        }
45
46
        foreach($this->_config_fields as $field) {
47
            if(array_key_exists($field, $config)) {
48
                $this->{$field} = $config[$field];
49
            }
50
        }
51
52
        $sql = array('truncate', 'info', 'update');
53
        foreach($sql as $s) {
54
            $this->{"_sql_$s"} = str_replace('RATE_TABLE', $this->table, $this->{"_sql_$s"});
55
        }
56
57
        $this->add_user_data($this->CI->input->ip_address());
58
59
        log_message('debug', 'Limiter Class Initialized');
60
    }
61
62
    /**
63
     * Rate limits the amount of requests that can be sent by a client.
64
     *
65
     * @param string $target
66
     * @param int $req_per_hour Overrides base_limit setting if set
67
     * @param bool $flush_on_abort Overrides flush_on_abort setting if set
68
     * @param bool $show_header Overrides header_show setting if set
69
     * @return bool Should request be aborted
70
     */
71
    public function limit($target = '_global', $req_per_hour = null, $flush_on_abort = null, $show_header = null) {
72
        $req_per_hour   = $req_per_hour !== null ? $req_per_hour : $this->base_limit;
73
        $flush_on_abort = $flush_on_abort !== null ? $flush_on_abort : $this->flush_on_abort;
74
        $show_header    = $show_header !== null ? $show_header : $this->header_show;
75
76
        $truncated = $this->_truncate();
77
        if(!$truncated) {
78
            log_message('DEBUG', 'WARN: Could not truncate rate limit table');
79
        }
80
81
        if($this->is_whitelisted()) {
82
            $req_per_hour = 0;
83
        }
84
85
        $abort = FALSE;
86
        if($req_per_hour > 0) {
87
            $info = $this->get_limit_info($target);
88
89
            if($info === FALSE) {
90
                $info              = new stdClass();
91
                $info->count       = 0;
92
                $info->reset_epoch = gmdate('d M Y H:i:s', time() + (60 * 60));
93
                $info->start       = date('d M Y H:i:s');
94
            }
95
96
            if($req_per_hour - $info->count > 0) {
97
                $data = array('client' => $this->get_hash(), 'target' => $target);
98
                $this->CI->db->query($this->_sql_update, $data);
99
                $info->count++;
100
            } else {
101
                $abort = TRUE;
102
            }
103
104
            if($show_header === TRUE) {
105
                $headers = array(
106
                    'Limit' => $req_per_hour,
107
                    'Remaining' => $req_per_hour - $info->count,
108
                    'Reset' => strtotime($info->reset_epoch),
109
                );
110
111
                foreach(array_keys($headers) as $h) {
112
                    $this->CI->output->set_header("$this->header_prefix$h: $headers[$h]");
113
                }
114
            }
115
116
            $this->_info_cache[$target] = $info;
117
118
119
            if($abort) {
120
                $retry_seconds = strtotime($info->reset_epoch) - strtotime(gmdate('d M Y H:i:s'));
121
                $this->CI->output->set_header("Retry-After: $retry_seconds");
122
                $this->CI->output->set_status_header(503, 'Rate limit reached');
123
124
                if($flush_on_abort) {
125
                    $this->CI->output->_display();
126
                    exit;
0 ignored issues
show
Coding Style Compatibility introduced by
The method limit() contains an exit expression.

An exit expression should only be used in rare cases. For example, if you write a short command line script.

In most cases however, using an exit expression makes the code untestable and often causes incompatibilities with other libraries. Thus, unless you are absolutely sure it is required here, we recommend to refactor your code to avoid its usage.

Loading history...
127
                }
128
            }
129
        }
130
131
        return $abort;
132
    }
133
134
    /**
135
     * Forget the client ever visited $target.
136
     *
137
     * @param string $target
138
     */
139
    public function reset_rate($target = '_global') {
140
        $this->CI->db->delete($this->table, array(
0 ignored issues
show
Documentation introduced by
array('client' => $this-...), 'target' => $target) is of type array<string,string|bool...an","target":"string"}>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
141
            'client' => $this->get_hash(),
142
            'target' => $target
143
        ));
144
    }
145
146
    /**
147
     * Forgets all rate limits attached to the client.
148
     */
149
    public function forget_client() {
150
        $this->CI->db->delete($this->table, array('client' => $this->get_hash()));
0 ignored issues
show
Documentation introduced by
array('client' => $this->get_hash()) is of type array<string,string|bool...ent":"string|boolean"}>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
151
    }
152
153
    /**
154
     * Used to obtain the client hash.
155
     *
156
     * Returns false if hash generation failed
157
     * @return string
158
     */
159
    public function get_hash() {
160
        if($this->user_hash === FALSE) {
161
            $this->user_hash = $this->_generate_hash();
0 ignored issues
show
Documentation Bug introduced by
The property $user_hash was declared of type boolean, but $this->_generate_hash() is of type string. Maybe add a type cast?

This check looks for assignments to scalar types that may be of the wrong type.

To ensure the code behaves as expected, it may be a good idea to add an explicit type cast.

$answer = 42;

$correct = false;

$correct = (bool) $answer;
Loading history...
162
        }
163
        return $this->user_hash;
164
    }
165
166
    /**
167
     * Adds entropy to the client hash. Make
168
     * sure that this is some sort of static
169
     * data such as a username/id.
170
     *
171
     * @param string $data Any seeding data
172
     */
173
    public function add_user_data($data) {
174
        array_push($this->user_data, (string) $data);
175
176
        if(count($this->_info_cache) !== 0) {
177
            log_message('DEBUG', 'WARN: Emptying info cache due to user data changing');
178
            $this->_info_cache = array(); // Empty cache
179
        }
180
181
        if($this->user_hash !== FALSE) {
182
            log_message('DEBUG', 'WARN: Adding user data after hash was generated');
183
            $this->user_hash = $this->_generate_hash();
0 ignored issues
show
Documentation Bug introduced by
The property $user_hash was declared of type boolean, but $this->_generate_hash() is of type string. Maybe add a type cast?

This check looks for assignments to scalar types that may be of the wrong type.

To ensure the code behaves as expected, it may be a good idea to add an explicit type cast.

$answer = 42;

$correct = false;

$correct = (bool) $answer;
Loading history...
184
        }
185
    }
186
187
    /**
188
     * Get target rate info
189
     *
190
     * @param string $target
191
     * @return stdClass|false Info object, returns false if no info is present
192
     */
193
    public function get_limit_info($target = '_global') {
194
        if(!array_key_exists($target, $this->_info_cache)) {
195
            $data = array('client' => $this->get_hash(), 'target' => $target);
196
            $info = $this->CI->db->query($this->_sql_info, $data)->row();
197
198
            $this->_info_cache[$target] = $info;
199
        } else {
200
            $info = $this->_info_cache[$target];
201
        }
202
203
        $valid_data = isset($info->count);
204
        return $valid_data ? $info : FALSE;
205
    }
206
207
    /**
208
     * @param $target
209
     * @return integer Amount of attempts
210
     */
211
    public function get_attempts($target) {
212
        return $this->get_limit_info($target)->count;
213
    }
214
215
    /**
216
     * @param null $ip If null current IP
217
     * @return bool Is whitelisted
218
     */
219
    public function is_whitelisted($ip = null) {
220
        $ip = $ip ?:  $this->CI->input->ip_address();
221
        return in_array($ip, $this->whitelist);
222
    }
223
224
    private function _truncate() {
225
        if(!$this->_truncated) {
226
            $this->_truncated = $this->CI->db->query($this->_sql_truncate);
227
        }
228
        return $this->_truncated;
229
    }
230
231
    private function _generate_hash() {
232
        return hash($this->checksum_algorithm, join('%', $this->user_data));
233
    }
234
}
235