module Curbit module Controller CacheKeyPrefix = "crl_key" def self.included(controller) controller.extend ClassMethods end module ClassMethods # Establishes a before filter for the specified method that will limit # calls to it based on the given options: # # ==== Options # * +key+ - A symbol representing an instance method or Proc that will return the key used to identify calls. This is what is used to destinguish one call from another. If not specified, the client ip derived from the request will be used. This will check for a HTTP_X_FORWARDED_FOR header first before using request.remote_addr. The Proc will be passed the controller instance as it is out of scope when the Proc is initially created (so you can get at request, params, etc.). # * +max_calls+ - maximum number of calls allowed. Required. # * +time_limit+ - only :max_calls will be allowed within the specific time frame (in seconds). If :max_calls is reached within this time, the call will be halted. Required. # * +wait_time+ - The time to wait if :max_calls has been reached before being able to pass. # * +message+ - The message to render to the client if the call is being limited. The message will be rendered as a correspondingly formatted response with a default status if given a String. If the argument is a symbol, a method with the same name will be invoked with the specified wait_time (in seconds). The called method should take care of rendering the response. # * +status+ - The response status to set when the call is being limited. # * +if+ - A symbol representing a method or Proc that returns true if the rate limiting should be applied. # * +unless+ - A symbol representing a method or Proc that returns true if the rate limiting should NOT be applied. # # ==== Examples # # class InviteController < ApplicationController # # include Curbit::Controller # # def validate # # validate code # end # # rate_limit :validate, :max_calls => 10, # :time_limit => 1.minute, # :wait_time => 1.minute, # :message => 'Too many attempts to validate your invitation code. Please wait 1 minute before trying again.' # # # def invite # # invite code # end # # rate_limit :invite, :key => proc {|c| c.session[:userid]}, # :max_calls => 2, # :time_limit => 30.seconds, # :wait_time => 1.minute # end # def rate_limit(method, opts) return unless rate_limit_opts_valid?(opts) self.class_eval do define_method "rate_limit_#{method}" do rate_limit_filter(method, opts) end end self.before_filter("rate_limit_#{method}", :only => method) end private def rate_limit_opts_valid?(opts = {}) new_opts = {:status => 503}.merge! opts opts.merge! new_opts if opts.key?(:if) and opts.key?(:unless) raise ":unless and :if are mutually exclusive parameters" end if !opts.key?(:max_calls) or !opts.key?(:time_limit) or !opts.key?(:wait_time) raise ":max_calls, :time_limit, and :wait_time are required parameters" end true end end private def curbit_cache_key(key, method) # TODO: this won't work if there are more than one controller with # the same name in the same app "#{CacheKeyPrefix}_#{self.class.name}_#{method}_#{key}" end def rate_limit_conditional(opts) if opts.key?(:unless) if opts[:unless].is_a? Proc return true if opts[:unless].call(self) elsif opts[:unless].is_a? Symbol return true if self.send(opts[:unless]) end end if opts.key?(:if) if opts[:if].is_a? Proc return true unless opts[:if].call(self) elsif opts[:if].is_a? Symbol return true unless self.send(opts[:if]) end end return false end def rate_limit_filter(method, opts) return true if rate_limit_conditional(opts) key = get_key(opts[:key]) unless (key) return true end cache_key = curbit_cache_key(key, method) val = Rails.cache.read(cache_key) if (val) val = val.dup started_at = val[:started] count = val[:count] val[:count] = count + 1 started_waiting = val[:started_waiting] # did we start making the user wait before being allowed to make # another call? if started_waiting # did we exceed the wait time? if Time.now.to_i > (started_waiting.to_i + opts[:wait_time]) Rails.cache.delete(cache_key) return true else get_message(opts) return false end elsif within_time_limit? started_at, opts[:time_limit] # did we exceed max calls? if val[:count] > opts[:max_calls] # start waiting and render the message val[:started_waiting] = Time.now Rails.cache.write(cache_key, val, :expires_in => opts[:wait_time]) get_message(opts) return false else # just update the count Rails.cache.write(cache_key, val, :expires_in => opts[:wait_time]) return true end else # we exceeded the time limit, so just reset val = {:started => Time.now, :count => 1} Rails.cache.write(cache_key, val, :expires_in => opts[:time_limit]) return true end else val = {:started => Time.now, :count => 1} Rails.cache.write(cache_key, val, :expires_in => opts[:time_limit]) end end def within_time_limit?(started_at, limit) Time.now.to_i < (started_at.to_i + limit) end # attempts to get the key based on the given option or # will attempt to use the remote address def get_key(opt) key = nil if (opt) if opt.is_a? Proc key = opt.call(self) # passing it the controller instance elsif opt.is_a? Symbol key = self.send(opt) if self.respond_to? opt end else if request.env['HTTP_X_FORWARDED_FOR'] key = request.env['HTTP_X_FORWARDED_FOR'] else addr = request.remote_addr if (addr == "0.0.0.0" or addr == "127.0.0.1") Rails.logger.warn "attempting to rate limit with a localhost address. Ignoring." return nil else key = addr end end end key end def get_message(opts) message = opts[:message] if message if message.is_a? Proc respond_to do |format| message.call(self, opts[:wait_time]) end elsif message.is_a? Symbol self.send(message, opts[:wait_time]) elsif message.is_a? String render_curbit_message(message, opts) end else message = "Too many requests within the allowed time. Please wait #{opts[:wait_time]} seconds before submitting your request again." render_curbit_message(message, opts) end end def render_curbit_message(message, opts) rendered = false respond_to {|format| format.html { render :text => message, :status => opts[:status] rendered = true } format.json { render :json => %[{"error":"#{message}"}], :status => opts[:status] rendered = true } format.xml { render :xml => "#{message}", :status => opts[:status] rendered = true } } if (!rendered) render :text => message, :status => opts[:status] end end end end