require 'rack/mime' module Rack class Rewrite class RuleSet attr_reader :rules def initialize #:nodoc: @rules = [] end protected # We're explicitly defining private functions for our DSL rather than # using method_missing # Creates a rewrite rule that will simply rewrite the REQUEST_URI, # PATH_INFO, and QUERY_STRING headers of the Rack environment. The # user's browser will continue to show the initially requested URL. # # rewrite '/wiki/John_Trupiano', '/john' # rewrite %r{/wiki/(\w+)_\w+}, '/$1' # rewrite %r{(.*)}, '/maintenance.html', :if => lambda { File.exists?('maintenance.html') } def rewrite(*args) add_rule :rewrite, *args end # Creates a redirect rule that will send a 301 when matching. # # r301 '/wiki/John_Trupiano', '/john' # r301 '/contact-us.php', '/contact-us' # # You can use +moved_permanently+ or just +p+ instead of +r301+. def r301(*args) add_rule :r301, *args end alias :moved_permanently :r301 alias :p :r301 # Creates a redirect rule that will send a 302 when matching. # # r302 '/wiki/John_Trupiano', '/john' # r302 '/wiki/(.*)', 'http://www.google.com/?q=$1' # # You can use +found+ instead of +r302+. def r302(*args) add_rule :r302, *args end alias :found :r302 # Creates a redirect rule that will send a 303 when matching. # # r303 '/wiki/John_Trupiano', '/john' # r303 '/wiki/(.*)', 'http://www.google.com/?q=$1' # # You can use +see_other+ instead of +r303+. def r303(*args) add_rule :r303, *args end alias :see_other :r303 # Creates a redirect rule that will send a 307 when matching. # # r307 '/wiki/John_Trupiano', '/john' # r307 '/wiki/(.*)', 'http://www.google.com/?q=$1' # # You can use +temporary_redirect+ or +t+ instead of +r307+. def r307(*args) add_rule :r307, *args end alias :temporary_redirect :r307 alias :t :r307 # Creates a rule that will render a file if matched. # # send_file /*/, 'public/system/maintenance.html', # :if => Proc.new { File.exists?('public/system/maintenance.html') } def send_file(*args) add_rule :send_file, *args end # Creates a rule that will render a file using x-send-file # if matched. # # x_send_file /*/, 'public/system/maintenance.html', # :if => Proc.new { File.exists?('public/system/maintenance.html') } def x_send_file(*args) add_rule :x_send_file, *args end private def add_rule(method, from, to, options = {}) #:nodoc: @rules << Rule.new(method.to_sym, from, to, options) end end # TODO: Break rules into subclasses class Rule #:nodoc: attr_reader :rule_type, :from, :to, :options def initialize(rule_type, from, to, options={}) #:nodoc: @rule_type, @from, @to, @options = rule_type, from, to, normalize_options(options) end def matches?(rack_env) #:nodoc: return false if options[:if].respond_to?(:call) && !options[:if].call(rack_env) path = build_path_from_env(rack_env) self.match_options?(rack_env) && string_matches?(path, self.from) end # Either (a) return a Rack response (short-circuiting the Rack stack), or # (b) alter env as necessary and return true def apply!(env) #:nodoc: interpreted_to = self.interpret_to(env) additional_headers = {} if @options[:headers] if @options[:headers].respond_to?(:call) additional_headers = @options[:headers].call || {} else additional_headers = @options[:headers] || {} end end case self.rule_type when :r301 [301, {'Location' => interpreted_to, 'Content-Type' => Rack::Mime.mime_type(::File.extname(interpreted_to))}.merge!(additional_headers), [redirect_message(interpreted_to)]] when :r302 [302, {'Location' => interpreted_to, 'Content-Type' => Rack::Mime.mime_type(::File.extname(interpreted_to))}.merge!(additional_headers), [redirect_message(interpreted_to)]] when :r303 [303, {'Location' => interpreted_to, 'Content-Type' => Rack::Mime.mime_type(::File.extname(interpreted_to))}.merge!(additional_headers), [redirect_message(interpreted_to)]] when :r307 [307, {'Location' => interpreted_to, 'Content-Type' => Rack::Mime.mime_type(::File.extname(interpreted_to))}.merge!(additional_headers), [redirect_message(interpreted_to)]] when :rewrite # return [200, {}, {:content => env.inspect}] env['REQUEST_URI'] = interpreted_to if q_index = interpreted_to.index('?') env['PATH_INFO'] = interpreted_to[0..q_index-1] env['QUERY_STRING'] = interpreted_to[q_index+1..interpreted_to.size-1] else env['PATH_INFO'] = interpreted_to env['QUERY_STRING'] = '' end true when :send_file [200, { 'Content-Length' => ::File.size(interpreted_to).to_s, 'Content-Type' => Rack::Mime.mime_type(::File.extname(interpreted_to)) }.merge!(additional_headers), [::File.read(interpreted_to)]] when :x_send_file [200, { 'X-Sendfile' => interpreted_to, 'Content-Length' => ::File.size(interpreted_to).to_s, 'Content-Type' => Rack::Mime.mime_type(::File.extname(interpreted_to)) }.merge!(additional_headers), []] else raise Exception.new("Unsupported rule: #{self.rule_type}") end end protected def interpret_to(env) #:nodoc: path = build_path_from_env(env) return interpret_to_proc(path, env) if self.to.is_a?(Proc) return computed_to(path) if compute_to?(path) self.to end def is_a_regexp?(obj) obj.is_a?(Regexp) || (Object.const_defined?(:Oniguruma) && obj.is_a?(Oniguruma::ORegexp)) end def match_options?(env, path = build_path_from_env(env)) matches = [] request = Rack::Request.new(env) # negative matches matches << !string_matches?(path, options[:not]) if options[:not] # possitive matches matches << string_matches?(env['REQUEST_METHOD'], options[:method]) if options[:method] matches << string_matches?(request.host, options[:host]) if options[:host] matches.all? end private def normalize_options(arg) options = arg.respond_to?(:call) ? {:if => arg} : arg options.symbolize_keys! if options.respond_to? :symbolize_keys! options.freeze end def interpret_to_proc(path, env) return self.to.call(match(path), env) if self.from.is_a?(Regexp) self.to.call(self.from, env) end def compute_to?(path) self.is_a_regexp?(from) && match(path) end def match(path) self.from.match(path) end def string_matches?(string, matcher) if self.is_a_regexp?(matcher) string =~ matcher elsif matcher.is_a?(String) string == matcher else false end end def computed_to(path) # is there a better way to do this? computed_to = self.to.dup computed_to.gsub!("$&",match(path).to_s) (match(path).size - 1).downto(1) do |num| computed_to.gsub!("$#{num}", match(path)[num].to_s) end return computed_to end # Construct the URL (without domain) from PATH_INFO and QUERY_STRING def build_path_from_env(env) path = env['PATH_INFO'] path += "?#{env['QUERY_STRING']}" unless env['QUERY_STRING'].nil? || env['QUERY_STRING'].empty? path end def redirect_message(location) %Q(Redirecting to #{location}) end end end end