File Coverage

blib/lib/Plack/Middleware/Throttle/Lite.pm
Criterion Covered Total %
statement 124 124 100.0
branch 36 40 90.0
condition 16 22 72.7
subroutine 25 25 100.0
pod 9 9 100.0
total 210 220 95.4


line stmt bran cond sub pod time code
1             package Plack::Middleware::Throttle::Lite;
2              
3             # ABSTRACT: Requests throttling for Plack
4              
5 6     6   143733 use strict;
  6         15  
  6         271  
6 6     6   33 use warnings;
  6         12  
  6         205  
7 6     6   36 use parent 'Plack::Middleware';
  6         11  
  6         107  
8 6     6   487 use Plack::Util::Accessor qw(limits maxreq units backend routes blacklist whitelist defaults privileged header_prefix);
  6         12  
  6         1333  
9 6     6   16285 use List::MoreUtils qw(any);
  6         11339  
  6         592  
10 6     6   45 use Plack::Util;
  6         12  
  6         125  
11 6     6   29 use Carp ();
  6         13  
  6         262  
12 6     6   6485 use Net::CIDR::Lite;
  6         27132  
  6         10786  
13              
14             our $VERSION = '0.05'; # VERSION
15             our $AUTHORITY = 'cpan:CHIM'; # AUTHORITY
16              
17             #
18             # Some important routines
19             sub prepare_app {
20 50     50 1 147491 my ($self) = @_;
21              
22             # setting up defaults
23 50         447 $self->defaults({
24             requests => 199,
25             units => 'req/hour',
26             backend => 'Simple',
27             header_prefix => 'Throttle-Lite',
28             username => 'nobody',
29             });
30              
31 50         2322 $self->_normalize_header_prefix;
32 50         434 $self->_normalize_limits;
33 50         528 $self->_initialize_backend;
34 45         416 $self->_normalize_routes;
35 40         305 $self->blacklist($self->_initialize_accesslist($self->blacklist));
36 40         310 $self->whitelist($self->_initialize_accesslist($self->whitelist));
37              
38 40         302 $self->backend->reqs_max($self->maxreq);
39 40         135 $self->backend->units($self->units);
40             }
41              
42             #
43             # Execute middleware
44             sub call {
45 107     107 1 516058 my ($self, $env) = @_;
46              
47 107         186 my $response;
48              
49 107 100       394 if ($self->have_to_throttle($env)) {
50              
51 105 100       371 return $self->reject_request(blacklist => 403) if $self->is_remote_blacklisted($env);
52              
53             # update client id
54 95         3159 $self->backend->requester_id($self->requester_id($env));
55              
56             # update
57 95         361 $self->privileged($self->is_remote_whitelisted($env));
58              
59 95 100       2463 $response = $self->is_allowed
60             ? $self->app->($env)
61             : $self->reject_request(ratelimit => 429);
62              
63             $self->response_cb($response, sub {
64 95     95   2211 $self->modify_headers(@_);
65 95         1758 });
66             }
67             else {
68 2         13 $response = $self->app->($env);
69             }
70              
71 97         2818 $response;
72             }
73              
74             #
75             # Rejects incoming request with some reason
76             sub reject_request {
77 43     43 1 708 my ($self, $reason, $code) = @_;
78              
79 43         190 my $reasons = {
80             blacklist => 'IP Address Blacklisted',
81             ratelimit => 'Rate Limit Exceeded',
82             };
83              
84 43         306 [ $code, [ 'Content-Type' => 'text/plain', ], [ $reasons->{$reason} ] ];
85             }
86              
87             #
88             # Set prefix for headers
89             sub _normalize_header_prefix {
90 50     50   137 my ($self) = @_;
91              
92 50         226 my $prefix = $self->defaults->{header_prefix};
93              
94 50 100       406 if ($self->header_prefix) {
95 10         77 $prefix = $self->header_prefix;
96              
97             # remove invalid chars
98 10         101 $prefix =~ s/[^0-9a-zA-Z\s]//g;
99              
100             # trim spaces
101 10         41 $prefix =~ s/^\s+//g;
102 10         33 $prefix =~ s/\s+$//g;
103              
104             # camelize
105 10         37 $prefix = join '-' => map { ucfirst } split /\s+/, $prefix;
  21         62  
106              
107             # set default value in case of empty prefix
108 10   66     41 $prefix = $prefix || $self->defaults->{header_prefix};
109             }
110              
111 50         390 $self->header_prefix($prefix);
112             }
113              
114             #
115             # Rate limit normalization
116             sub _normalize_limits {
117 50     50   93 my ($self) = @_;
118              
119 50         256 my $units = {
120             'm' => 'req/min',
121             'h' => 'req/hour',
122             'd' => 'req/day',
123             };
124              
125 50         226 my $limits_re = qr{^(\d*)(\s*)(r|req)(\/|\sper\s)(h|hour|d|day|m|min).*};
126              
127 50 100       228 if ($self->limits) {
128 43         319 my $t_limits = lc($self->limits);
129 43         470 $t_limits =~ s/\s+/ /g;
130 43         425 $t_limits =~ /$limits_re/;
131 43   33     324 $self->maxreq($1 || $self->defaults->{requests});
132 43   33     488 $self->units($units->{$5} || $self->defaults->{units})
133             }
134             else {
135 7         115 $self->maxreq($self->defaults->{requests});
136 7         62 $self->units($self->defaults->{units})
137             }
138             }
139              
140             #
141             # Storage backend
142             sub _initialize_backend {
143 50     50   98 my ($self) = @_;
144              
145 50         171 my ($class, $args) = ($self->defaults->{backend}, {});
146              
147 50 100       380 if ($self->backend) {
148 29   100     227 my $reft = uc(ref($self->backend) || 'NA');
149 29 100       314 if ($reft eq 'NA') { # SCALAR
    100          
150 26         229 ($class, $args) = ($self->backend, {});
151             }
152             elsif ($reft eq 'ARRAY') {
153 1         2 ($class, $args) = @{ $self->backend };
  1         3  
154             }
155             else {
156 2         40 Carp::croak 'Expected scalar or array reference!';
157             }
158             }
159              
160 48         705 my $backend = Plack::Util::load_class($class, 'Plack::Middleware::Throttle::Lite::Backend');
161              
162 45         1554 $self->backend($backend->new($args));
163             }
164              
165             #
166             # Routes' normalization
167             sub _normalize_routes {
168 45     45   91 my ($self) = @_;
169              
170 45         99 my $routes = [];
171              
172 45 50       170 if ($self->routes) {
173 45   100     435 my $reft = uc(ref($self->routes) || 'NA');
174 45 100       454 if ($reft eq 'NA') { # SCALAR
    100          
    100          
175 36         113 $routes = [ $self->routes ];
176             }
177             elsif ($reft eq 'REGEXP') {
178 1         4 $routes = [ $self->routes ];
179             }
180             elsif ($reft eq 'ARRAY') {
181 3         7 $routes = $self->routes;
182             }
183             else {
184 5         89 Carp::croak 'Expected scalar, regex or array reference!';
185             }
186             }
187              
188 40         331 $self->routes($routes);
189             }
190              
191             #
192             # Adds extra headers to response
193             sub modify_headers {
194 95     95 1 164 my ($self, $response) = @_;
195 95         153 my $headers = $response->[1];
196              
197 95         340 my $prefix = $self->header_prefix;
198              
199 95 100       716 my %inject = (
200             "X-${prefix}-Limit" => $self->privileged ? 'unlimited' : $self->maxreq,
201             "X-${prefix}-Units" => $self->units,
202             "X-${prefix}-Used" => $self->backend->reqs_done,
203             );
204              
205 95 100 100     618 if (($self->backend->reqs_done >= $self->maxreq) && !$self->privileged) {
206 59         1091 $inject{"X-${prefix}-Expire"} = $inject{"Retry-After"} = $self->backend->expire_in;
207             }
208              
209 95         1094 map { Plack::Util::header_set($headers, $_, $inject{$_}) } sort keys %inject;
  403         20448  
210              
211 95         4004 $response;
212             }
213              
214             #
215             # Checks if requested path should be throttled
216             sub have_to_throttle {
217 107     107 1 186 my ($self, $env) = @_;
218              
219 107     107   494 any { $env->{PATH_INFO} =~ /$_/ } @{ $self->routes };
  107         1824  
  107         547  
220             }
221              
222             #
223             # Checks if the requester's IP in the blacklist
224             sub is_remote_blacklisted {
225 105     105 1 205 my ($self, $env) = @_;
226              
227 105         349 $self->_is_listed_in(blacklist => $env);
228             }
229              
230             #
231             # Checks if the requester's IP in the whitelist
232             sub is_remote_whitelisted {
233 95     95 1 160 my ($self, $env) = @_;
234              
235 95         209 $self->_is_listed_in(whitelist => $env);
236             }
237              
238             #
239             # Checks if remote IP address in accesslist
240             sub _is_listed_in {
241 200     200   333 my ($self, $list, $env) = @_;
242              
243 200 50       682 return unless $self->$list;
244 200         1690 return $self->$list->find($env->{REMOTE_ADDR});
245             }
246              
247             #
248             # Populates the blacklist/whitelist
249             sub _initialize_accesslist {
250 80     80   501 my ($self, $items) = @_;
251              
252 80         412 my $list = Net::CIDR::Lite->new;
253              
254 80 100       820 if ($items) {
255 3 50       17 map { $list->add_any($_) } ref($items) eq 'ARRAY' ? @$items : ( $items );
  6         400  
256             }
257              
258 80         834 $list;
259             }
260              
261             #
262             # Check if limits is not exceeded
263             sub is_allowed {
264 95     95 1 190 my ($self) = @_;
265              
266 95 100 100     251 if (($self->backend->reqs_done < $self->backend->reqs_max) || $self->privileged) {
267 62         184 $self->backend->increment;
268 62 50       354 return $self->privileged
    100          
269             ? 1 : $self->backend->reqs_done <= $self->backend->reqs_max ? 1 : 0;
270             }
271             else {
272 33         386 return 0;
273             }
274             }
275              
276             #
277             # Requester's ID
278             sub requester_id {
279 95     95 1 630 my ($self, $env) = @_;
280 95   66     801 join ':' => 'throttle', $env->{REMOTE_ADDR}, ($env->{REMOTE_USER} || $self->defaults->{username});
281             }
282              
283             1; # End of Plack::Middleware::Throttle::Lite
284              
285             __END__