File Coverage

blib/lib/CGI/Application/Plugin/RateLimit.pm
Criterion Covered Total %
statement 24 113 21.2
branch 0 44 0.0
condition 0 19 0.0
subroutine 7 21 33.3
pod 5 10 50.0
total 36 207 17.3


line stmt bran cond sub pod time code
1             package CGI::Application::Plugin::RateLimit;
2              
3 1     1   24953 use 5.006;
  1         4  
  1         39  
4 1     1   6 use strict;
  1         2  
  1         39  
5 1     1   6 use warnings;
  1         13  
  1         48  
6              
7 1     1   6 use Carp qw(croak);
  1         1  
  1         118  
8              
9             our $VERSION = '1.0';
10              
11             # export the rate_limit method into the using CGI::App and setup the
12             # prerun callback
13             sub import {
14 1     1   12 my $pkg = shift;
15 1         4 my $callpkg = caller;
16              
17             {
18 1     1   5 no strict qw(refs);
  1         11  
  1         78  
  1         2  
19 1         4 *{$callpkg . '::rate_limit'} = \&rate_limit;
  1         7  
20             }
21              
22 1         11 $callpkg->add_callback(prerun => \&prerun_callback);
23             }
24              
25             # setup accessor/mutators for simple stuff
26 1     1   6 use base qw(Class::Accessor::Fast);
  1         2  
  1         944  
27             __PACKAGE__->mk_accessors(
28             qw(dbh table violation_mode violation_callback identity_callback
29             violated_mode violated_action violated_limits));
30              
31             # setup a new object the first time it's called
32             sub rate_limit {
33 0     0 0   my $cgi_app = shift;
34 0 0         return $cgi_app->{__rate_limit_obj} if $cgi_app->{__rate_limit_obj};
35              
36 0           my $rate_limit = $cgi_app->{__rate_limit_obj} = __PACKAGE__->new();
37              
38             # setup defaults
39 0           $rate_limit->table('rate_limit_hits');
40             $rate_limit->identity_callback(
41             sub {
42 0   0 0     return $ENV{REMOTE_USER} || $ENV{REMOTE_IP};
43 0           });
44              
45 0           return $rate_limit;
46             }
47              
48             # intercept the run-mode call
49             sub prerun_callback {
50 0     0 0   my $cgi_app = shift;
51 0           my $self = $cgi_app->rate_limit;
52 0           my $query = $cgi_app->query;
53              
54             # see if this mode is protected
55 0   0       my $mode = $query->param($cgi_app->mode_param)
56             || $cgi_app->start_mode;
57 0   0       my $protected = $self->protected_modes || {};
58 0           my $limits = $protected->{$mode};
59 0 0         return unless $limits;
60              
61 0           $self->_verify_attributes();
62              
63             # record the hit
64 0           my $action = ref($cgi_app) . "::$mode";
65 0           $self->record_hit(action => $action);
66              
67             # check for a violation
68 0 0         if ($self->check_violation(action => $action, limits => $limits)) {
69              
70             # deal with it by jumping to violation_mode or calling the
71             # violation callback
72 0 0         if ($self->violation_mode) {
73 0           $cgi_app->prerun_mode($self->violation_mode);
74             } else {
75 0           my $violation_callback = $self->violation_callback();
76 0           $cgi_app->prerun_mode($violation_callback->($cgi_app));
77             }
78             }
79             }
80              
81             # make sure we're ready to rumble
82             sub _verify_attributes {
83 0     0     my $self = shift;
84              
85 0           for my $name (qw(dbh table identity_callback)) {
86 0 0         croak( "You forgot to set the required '$name' attribute on your "
87             . __PACKAGE__
88             . " object.")
89             unless $self->{$name};
90             }
91 0 0 0       croak( "You forgot to set the required 'violation_mode' or "
92             . "'violation_callback' attribute on your "
93             . __PACKAGE__
94             . " object.")
95             unless $self->{violation_mode}
96             or $self->{violation_callback};
97             }
98              
99             # translate a timeframe like 10s, 5m or 1h into seconds
100             sub _timeframe_to_seconds {
101 0     0     my $time = shift;
102 0           my ($digits, $modifier) = $time =~ /^(\d+)([smh])$/;
103 0 0 0       croak( "Invalid timeframe found: '$time'. "
104             . "Should be a number followed by s, m or h.")
105             unless $digits and $modifier;
106              
107 0 0         return $digits if $modifier eq 's';
108 0 0         return $digits * 60 if $modifier eq 'm';
109 0 0         return $digits * 60 * 60 if $modifier eq 'h';
110             }
111              
112             sub protected_modes {
113 0     0 1   my ($self, %args) = @_;
114 0 0         return $self->{protected_modes} unless @_ > 1;
115 0           $self->_check_limits(\%args);
116 0           $self->{protected_modes} = \%args;
117             }
118              
119             sub protected_actions {
120 0     0 1   my ($self, %args) = @_;
121 0 0         return $self->{protected_actions} unless @_ > 1;
122 0           $self->_check_limits(\%args);
123 0           $self->{protected_actions} = \%args;
124             }
125              
126             sub _check_limits {
127 0     0     my ($self, $args) = @_;
128 0           foreach my $limits (values %$args) {
129             defined $limits->{$_}
130             or croak("Missing required value in protected limits hash: '$_'.")
131 0   0       for (qw(timeframe max_hits));
132 0 0         croak("Unknown keys found in protected limits hash.")
133             unless keys(%$limits) == 2;
134             }
135             }
136              
137             sub record_hit {
138 0     0 1   my ($self, %args) = @_;
139 0           $self->_verify_attributes();
140              
141 0           my $dbh = $self->dbh;
142 0           my $timestamp = time;
143              
144 0           my $id_callback = $self->identity_callback();
145 0           my $user_id = $id_callback->();
146 0 0         croak( "Identity callback failed to return a value to "
147             . __PACKAGE__
148             . "::record_hit.")
149             unless $user_id;
150              
151 0 0         $self->record_hit_sth($dbh)->execute($user_id, $args{action}, $timestamp)
152             or croak( "Failed to insert hit into table '"
153             . $self->table . "': "
154             . $dbh->errstr);
155              
156             # record particulars of last hit for revoke
157 0           $self->{last_hit} = {user_id => $user_id,
158             action => $args{action},
159             timestamp => $timestamp};
160             }
161              
162             sub revoke_hit {
163 0     0 1   my $self = shift;
164 0           $self->_verify_attributes();
165              
166 0           my $dbh = $self->dbh;
167 0 0         my $last_hit = $self->{last_hit}
168             or croak("revoke_hit called without previous hit!");
169              
170 0           my $sth = $self->revoke_hit_sth($dbh);
171 0 0         $sth->execute($last_hit->{user_id}, $last_hit->{action},
172             $last_hit->{timestamp})
173             or croak( "Failed to delete hit from table '"
174             . $self->table . "': "
175             . $dbh->errstr);
176              
177             }
178              
179             sub check_violation {
180 0     0 1   my ($self, %args) = @_;
181              
182 0           my $dbh = $self->dbh;
183              
184 0           my $id_callback = $self->identity_callback();
185 0           my $user_id = $id_callback->();
186 0 0         croak( "Identity callback failed to return a value to "
187             . __PACKAGE__
188             . "::check_violation.")
189             unless $user_id;
190              
191             # get limits passed-in for protected modes, else lookup for actions
192 0           my ($limits, $is_mode);
193 0 0         if ($args{limits}) {
194 0           $limits = $args{limits};
195 0           $is_mode = 1;
196             } else {
197 0   0       my $protected_actions = $self->protected_actions() || {};
198 0           $limits = $protected_actions->{$args{action}};
199 0 0         croak( "Called check_violation() for unknown protected action "
200             . "'$args{action}'.")
201             unless $limits;
202             }
203              
204 0           my $seconds = _timeframe_to_seconds($limits->{timeframe});
205              
206 0           my $sth = $self->check_violation_sth($dbh);
207 0           $sth->execute($user_id, $args{action}, time - $seconds);
208 0           my ($count) = $sth->fetchrow_array();
209 0           $sth->finish;
210              
211 0 0         if ($count > $limits->{max_hits}) {
212              
213             # setup violation details
214 0 0         if ($is_mode) {
215 0           $self->violated_mode($args{action});
216             } else {
217 0           $self->violated_action($args{action});
218             }
219 0           $self->violated_limits($limits);
220              
221 0           return 1;
222             }
223              
224 0           return 0;
225             }
226              
227             #
228             # SQL code. If you want to port this module to a new DB, add some
229             # magic here. With any luck you won't have to - this SQL is pretty
230             # bland.
231             #
232              
233             sub record_hit_sth {
234 0     0 0   my ($self, $dbh) = @_;
235              
236 0           return $dbh->prepare_cached('INSERT INTO '
237             . $dbh->quote_identifier($self->table)
238             . ' (user_id, action, timestamp) VALUES (?,?,?)');
239             }
240              
241             sub check_violation_sth {
242 0     0 0   my ($self, $dbh) = @_;
243              
244 0           return $dbh->prepare_cached('SELECT COUNT(*) FROM '
245             . $dbh->quote_identifier($self->table)
246             . ' WHERE user_id = ? AND action = ? AND timestamp > ?');
247             }
248              
249             sub revoke_hit_sth {
250 0     0 0   my ($self, $dbh) = @_;
251              
252 0           return $dbh->prepare_cached('DELETE FROM '
253             . $dbh->quote_identifier($self->table)
254             . ' WHERE user_id = ? AND action = ? AND timestamp = ?');
255             }
256              
257             1;
258             __END__