Completed
Push — master ( 222f63...75778e )
by Angus
02:21
created

Limiter   A

Complexity

Total Complexity 34

Size/Duplication

Total Lines 222
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 4

Importance

Changes 0
Metric Value
dl 0
loc 222
rs 9.2
c 0
b 0
f 0
wmc 34
lcom 1
cbo 4
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;
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(
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()));
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();
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();
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