diff options
Diffstat (limited to 'actionpack/lib/action_dispatch/http')
-rw-r--r-- | actionpack/lib/action_dispatch/http/headers.rb | 33 | ||||
-rw-r--r-- | actionpack/lib/action_dispatch/http/mime_type.rb | 212 | ||||
-rw-r--r-- | actionpack/lib/action_dispatch/http/mime_types.rb | 21 | ||||
-rwxr-xr-x | actionpack/lib/action_dispatch/http/request.rb | 531 | ||||
-rw-r--r-- | actionpack/lib/action_dispatch/http/response.rb | 251 | ||||
-rw-r--r-- | actionpack/lib/action_dispatch/http/status_codes.rb | 40 |
6 files changed, 1088 insertions, 0 deletions
diff --git a/actionpack/lib/action_dispatch/http/headers.rb b/actionpack/lib/action_dispatch/http/headers.rb new file mode 100644 index 0000000000..2a41b4dbad --- /dev/null +++ b/actionpack/lib/action_dispatch/http/headers.rb @@ -0,0 +1,33 @@ +require 'active_support/memoizable' + +module ActionDispatch + module Http + class Headers < ::Hash + extend ActiveSupport::Memoizable + + def initialize(*args) + if args.size == 1 && args[0].is_a?(Hash) + super() + update(args[0]) + else + super + end + end + + def [](header_name) + if include?(header_name) + super + else + super(env_name(header_name)) + end + end + + private + # Converts a HTTP header name to an environment variable name. + def env_name(header_name) + "HTTP_#{header_name.upcase.gsub(/-/, '_')}" + end + memoize :env_name + end + end +end diff --git a/actionpack/lib/action_dispatch/http/mime_type.rb b/actionpack/lib/action_dispatch/http/mime_type.rb new file mode 100644 index 0000000000..02ad7f7d94 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/mime_type.rb @@ -0,0 +1,212 @@ +require 'set' + +module Mime + SET = [] + EXTENSION_LOOKUP = Hash.new { |h, k| h[k] = Type.new(k) unless k.blank? } + LOOKUP = Hash.new { |h, k| h[k] = Type.new(k) unless k.blank? } + + def self.[](type) + Type.lookup_by_extension(type.to_s) + end + + # Encapsulates the notion of a mime type. Can be used at render time, for example, with: + # + # class PostsController < ActionController::Base + # def show + # @post = Post.find(params[:id]) + # + # respond_to do |format| + # format.html + # format.ics { render :text => post.to_ics, :mime_type => Mime::Type["text/calendar"] } + # format.xml { render :xml => @people.to_xml } + # end + # end + # end + class Type + @@html_types = Set.new [:html, :all] + cattr_reader :html_types + + # These are the content types which browsers can generate without using ajax, flash, etc + # i.e. following a link, getting an image or posting a form. CSRF protection + # only needs to protect against these types. + @@browser_generated_types = Set.new [:html, :url_encoded_form, :multipart_form, :text] + cattr_reader :browser_generated_types + attr_reader :symbol + + @@unverifiable_types = Set.new [:text, :json, :csv, :xml, :rss, :atom, :yaml] + def self.unverifiable_types + ActiveSupport::Deprecation.warn("unverifiable_types is deprecated and has no effect", caller) + @@unverifiable_types + end + + # A simple helper class used in parsing the accept header + class AcceptItem #:nodoc: + attr_accessor :order, :name, :q + + def initialize(order, name, q=nil) + @order = order + @name = name.strip + q ||= 0.0 if @name == Mime::ALL # default wilcard match to end of list + @q = ((q || 1.0).to_f * 100).to_i + end + + def to_s + @name + end + + def <=>(item) + result = item.q <=> q + result = order <=> item.order if result == 0 + result + end + + def ==(item) + name == (item.respond_to?(:name) ? item.name : item) + end + end + + class << self + def lookup(string) + LOOKUP[string] + end + + def lookup_by_extension(extension) + EXTENSION_LOOKUP[extension] + end + + # Registers an alias that's not used on mime type lookup, but can be referenced directly. Especially useful for + # rendering different HTML versions depending on the user agent, like an iPhone. + def register_alias(string, symbol, extension_synonyms = []) + register(string, symbol, [], extension_synonyms, true) + end + + def register(string, symbol, mime_type_synonyms = [], extension_synonyms = [], skip_lookup = false) + Mime.instance_eval { const_set symbol.to_s.upcase, Type.new(string, symbol, mime_type_synonyms) } + + SET << Mime.const_get(symbol.to_s.upcase) + + ([string] + mime_type_synonyms).each { |string| LOOKUP[string] = SET.last } unless skip_lookup + ([symbol.to_s] + extension_synonyms).each { |ext| EXTENSION_LOOKUP[ext] = SET.last } + end + + def parse(accept_header) + if accept_header !~ /,/ + [Mime::Type.lookup(accept_header)] + else + # keep track of creation order to keep the subsequent sort stable + list = [] + accept_header.split(/,/).each_with_index do |header, index| + params, q = header.split(/;\s*q=/) + if params + params.strip! + list << AcceptItem.new(index, params, q) unless params.empty? + end + end + list.sort! + + # Take care of the broken text/xml entry by renaming or deleting it + text_xml = list.index("text/xml") + app_xml = list.index(Mime::XML.to_s) + + if text_xml && app_xml + # set the q value to the max of the two + list[app_xml].q = [list[text_xml].q, list[app_xml].q].max + + # make sure app_xml is ahead of text_xml in the list + if app_xml > text_xml + list[app_xml], list[text_xml] = list[text_xml], list[app_xml] + app_xml, text_xml = text_xml, app_xml + end + + # delete text_xml from the list + list.delete_at(text_xml) + + elsif text_xml + list[text_xml].name = Mime::XML.to_s + end + + # Look for more specific XML-based types and sort them ahead of app/xml + + if app_xml + idx = app_xml + app_xml_type = list[app_xml] + + while(idx < list.length) + type = list[idx] + break if type.q < app_xml_type.q + if type.name =~ /\+xml$/ + list[app_xml], list[idx] = list[idx], list[app_xml] + app_xml = idx + end + idx += 1 + end + end + + list.map! { |i| Mime::Type.lookup(i.name) }.uniq! + list + end + end + end + + def initialize(string, symbol = nil, synonyms = []) + @symbol, @synonyms = symbol, synonyms + @string = string + end + + def to_s + @string + end + + def to_str + to_s + end + + def to_sym + @symbol || @string.to_sym + end + + def ===(list) + if list.is_a?(Array) + (@synonyms + [ self ]).any? { |synonym| list.include?(synonym) } + else + super + end + end + + def ==(mime_type) + return false if mime_type.blank? + (@synonyms + [ self ]).any? do |synonym| + synonym.to_s == mime_type.to_s || synonym.to_sym == mime_type.to_sym + end + end + + def =~(mime_type) + return false if mime_type.blank? + regexp = Regexp.new(Regexp.quote(mime_type.to_s)) + (@synonyms + [ self ]).any? do |synonym| + synonym.to_s =~ regexp + end + end + + # Returns true if Action Pack should check requests using this Mime Type for possible request forgery. See + # ActionController::RequestForgeryProtection. + def verify_request? + @@browser_generated_types.include?(to_sym) + end + + def html? + @@html_types.include?(to_sym) || @string =~ /html/ + end + + private + def method_missing(method, *args) + if method.to_s =~ /(\w+)\?$/ + $1.downcase.to_sym == to_sym + else + super + end + end + end +end + +require 'action_dispatch/http/mime_types' diff --git a/actionpack/lib/action_dispatch/http/mime_types.rb b/actionpack/lib/action_dispatch/http/mime_types.rb new file mode 100644 index 0000000000..2d7fba1173 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/mime_types.rb @@ -0,0 +1,21 @@ +# Build list of Mime types for HTTP responses +# http://www.iana.org/assignments/media-types/ + +Mime::Type.register "*/*", :all +Mime::Type.register "text/plain", :text, [], %w(txt) +Mime::Type.register "text/html", :html, %w( application/xhtml+xml ), %w( xhtml ) +Mime::Type.register "text/javascript", :js, %w( application/javascript application/x-javascript ) +Mime::Type.register "text/css", :css +Mime::Type.register "text/calendar", :ics +Mime::Type.register "text/csv", :csv +Mime::Type.register "application/xml", :xml, %w( text/xml application/x-xml ) +Mime::Type.register "application/rss+xml", :rss +Mime::Type.register "application/atom+xml", :atom +Mime::Type.register "application/x-yaml", :yaml, %w( text/yaml ) + +Mime::Type.register "multipart/form-data", :multipart_form +Mime::Type.register "application/x-www-form-urlencoded", :url_encoded_form + +# http://www.ietf.org/rfc/rfc4627.txt +# http://www.json.org/JSONRequest.html +Mime::Type.register "application/json", :json, %w( text/x-json application/jsonrequest )
\ No newline at end of file diff --git a/actionpack/lib/action_dispatch/http/request.rb b/actionpack/lib/action_dispatch/http/request.rb new file mode 100755 index 0000000000..523ab32b35 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/request.rb @@ -0,0 +1,531 @@ +require 'tempfile' +require 'stringio' +require 'strscan' + +require 'active_support/memoizable' + +module ActionDispatch + class Request < Rack::Request + + %w[ AUTH_TYPE GATEWAY_INTERFACE + PATH_TRANSLATED REMOTE_HOST + REMOTE_IDENT REMOTE_USER REMOTE_ADDR + SERVER_NAME SERVER_PROTOCOL + + HTTP_ACCEPT HTTP_ACCEPT_CHARSET HTTP_ACCEPT_ENCODING + HTTP_ACCEPT_LANGUAGE HTTP_CACHE_CONTROL HTTP_FROM + HTTP_NEGOTIATE HTTP_PRAGMA HTTP_REFERER HTTP_USER_AGENT ].each do |env| + define_method(env.sub(/^HTTP_/n, '').downcase) do + @env[env] + end + end + + def key?(key) + @env.key?(key) + end + + HTTP_METHODS = %w(get head put post delete options) + HTTP_METHOD_LOOKUP = HTTP_METHODS.inject({}) { |h, m| h[m] = h[m.upcase] = m.to_sym; h } + + # Returns the true HTTP request \method as a lowercase symbol, such as + # <tt>:get</tt>. If the request \method is not listed in the HTTP_METHODS + # constant above, an UnknownHttpMethod exception is raised. + def request_method + @request_method ||= HTTP_METHOD_LOOKUP[super] || raise(ActionController::UnknownHttpMethod, "#{super}, accepted HTTP methods are #{HTTP_METHODS.to_sentence(:locale => :en)}") + end + + # Returns the HTTP request \method used for action processing as a + # lowercase symbol, such as <tt>:post</tt>. (Unlike #request_method, this + # method returns <tt>:get</tt> for a HEAD request because the two are + # functionally equivalent from the application's perspective.) + def method + request_method == :head ? :get : request_method + end + + # Is this a GET (or HEAD) request? Equivalent to <tt>request.method == :get</tt>. + def get? + method == :get + end + + # Is this a POST request? Equivalent to <tt>request.method == :post</tt>. + def post? + request_method == :post + end + + # Is this a PUT request? Equivalent to <tt>request.method == :put</tt>. + def put? + request_method == :put + end + + # Is this a DELETE request? Equivalent to <tt>request.method == :delete</tt>. + def delete? + request_method == :delete + end + + # Is this a HEAD request? Since <tt>request.method</tt> sees HEAD as <tt>:get</tt>, + # this \method checks the actual HTTP \method directly. + def head? + request_method == :head + end + + # Provides access to the request's HTTP headers, for example: + # + # request.headers["Content-Type"] # => "text/plain" + def headers + Http::Headers.new(@env) + end + + # Returns the content length of the request as an integer. + def content_length + super.to_i + end + + # The MIME type of the HTTP request, such as Mime::XML. + # + # For backward compatibility, the post \format is extracted from the + # X-Post-Data-Format HTTP header if present. + def content_type + @content_type ||= begin + if @env['CONTENT_TYPE'] =~ /^([^,\;]*)/ + Mime::Type.lookup($1.strip.downcase) + else + nil + end + end + end + + # Returns the accepted MIME type for the request. + def accepts + @accepts ||= begin + header = @env['HTTP_ACCEPT'].to_s.strip + + fallback = xhr? ? Mime::JS : Mime::HTML + + if header.empty? + [content_type, fallback, Mime::ALL].compact + else + ret = Mime::Type.parse(header) + if ret.last == Mime::ALL + ret.insert(-2, fallback) + end + ret + end + end + end + + def if_modified_since + if since = env['HTTP_IF_MODIFIED_SINCE'] + Time.rfc2822(since) rescue nil + end + end + + def if_none_match + env['HTTP_IF_NONE_MATCH'] + end + + def not_modified?(modified_at) + if_modified_since && modified_at && if_modified_since >= modified_at + end + + def etag_matches?(etag) + if_none_match && if_none_match == etag + end + + # Check response freshness (Last-Modified and ETag) against request + # If-Modified-Since and If-None-Match conditions. If both headers are + # supplied, both must match, or the request is not considered fresh. + def fresh?(response) + case + when if_modified_since && if_none_match + not_modified?(response.last_modified) && etag_matches?(response.etag) + when if_modified_since + not_modified?(response.last_modified) + when if_none_match + etag_matches?(response.etag) + else + false + end + end + + ONLY_ALL = [Mime::ALL].freeze + + # Returns the Mime type for the \format used in the request. + # + # GET /posts/5.xml | request.format => Mime::XML + # GET /posts/5.xhtml | request.format => Mime::HTML + # GET /posts/5 | request.format => Mime::HTML or MIME::JS, or request.accepts.first depending on the value of <tt>ActionController::Base.use_accept_header</tt> + + def format(view_path = []) + @format ||= + if parameters[:format] + Mime[parameters[:format]] + elsif ActionController::Base.use_accept_header && !(accepts == ONLY_ALL) + accepts.first + elsif xhr? then Mime::JS + else Mime::HTML + end + end + + def formats + @formats = + if ActionController::Base.use_accept_header + Array(Mime[parameters[:format]] || accepts) + else + [format] + end + end + + # Sets the \format by string extension, which can be used to force custom formats + # that are not controlled by the extension. + # + # class ApplicationController < ActionController::Base + # before_filter :adjust_format_for_iphone + # + # private + # def adjust_format_for_iphone + # request.format = :iphone if request.env["HTTP_USER_AGENT"][/iPhone/] + # end + # end + def format=(extension) + parameters[:format] = extension.to_s + @format = Mime::Type.lookup_by_extension(parameters[:format]) + end + + # Returns a symbolized version of the <tt>:format</tt> parameter of the request. + # If no \format is given it returns <tt>:js</tt>for Ajax requests and <tt>:html</tt> + # otherwise. + def template_format + parameter_format = parameters[:format] + + if parameter_format + parameter_format + elsif xhr? + :js + else + :html + end + end + + def cache_format + parameters[:format] + end + + # Returns true if the request's "X-Requested-With" header contains + # "XMLHttpRequest". (The Prototype Javascript library sends this header with + # every Ajax request.) + def xml_http_request? + !(@env['HTTP_X_REQUESTED_WITH'] !~ /XMLHttpRequest/i) + end + alias xhr? :xml_http_request? + + # Which IP addresses are "trusted proxies" that can be stripped from + # the right-hand-side of X-Forwarded-For + TRUSTED_PROXIES = /^127\.0\.0\.1$|^(10|172\.(1[6-9]|2[0-9]|30|31)|192\.168)\./i + + # Determines originating IP address. REMOTE_ADDR is the standard + # but will fail if the user is behind a proxy. HTTP_CLIENT_IP and/or + # HTTP_X_FORWARDED_FOR are set by proxies so check for these if + # REMOTE_ADDR is a proxy. HTTP_X_FORWARDED_FOR may be a comma- + # delimited list in the case of multiple chained proxies; the last + # address which is not trusted is the originating IP. + def remote_ip + remote_addr_list = @env['REMOTE_ADDR'] && @env['REMOTE_ADDR'].scan(/[^,\s]+/) + + unless remote_addr_list.blank? + not_trusted_addrs = remote_addr_list.reject {|addr| addr =~ TRUSTED_PROXIES} + return not_trusted_addrs.first unless not_trusted_addrs.empty? + end + remote_ips = @env['HTTP_X_FORWARDED_FOR'] && @env['HTTP_X_FORWARDED_FOR'].split(',') + + if @env.include? 'HTTP_CLIENT_IP' + if ActionController::Base.ip_spoofing_check && remote_ips && !remote_ips.include?(@env['HTTP_CLIENT_IP']) + # We don't know which came from the proxy, and which from the user + raise ActionController::ActionControllerError.new(<<EOM) +IP spoofing attack?! +HTTP_CLIENT_IP=#{@env['HTTP_CLIENT_IP'].inspect} +HTTP_X_FORWARDED_FOR=#{@env['HTTP_X_FORWARDED_FOR'].inspect} +EOM + end + + return @env['HTTP_CLIENT_IP'] + end + + if remote_ips + while remote_ips.size > 1 && TRUSTED_PROXIES =~ remote_ips.last.strip + remote_ips.pop + end + + return remote_ips.last.strip + end + + @env['REMOTE_ADDR'] + end + + # Returns the lowercase name of the HTTP server software. + def server_software + (@env['SERVER_SOFTWARE'] && /^([a-zA-Z]+)/ =~ @env['SERVER_SOFTWARE']) ? $1.downcase : nil + end + + # Returns the complete URL used for this request. + def url + protocol + host_with_port + request_uri + end + + # Returns 'https://' if this is an SSL request and 'http://' otherwise. + def protocol + ssl? ? 'https://' : 'http://' + end + + # Is this an SSL request? + def ssl? + @env['HTTPS'] == 'on' || @env['HTTP_X_FORWARDED_PROTO'] == 'https' + end + + # Returns the \host for this request, such as "example.com". + def raw_host_with_port + if forwarded = env["HTTP_X_FORWARDED_HOST"] + forwarded.split(/,\s?/).last + else + env['HTTP_HOST'] || "#{env['SERVER_NAME'] || env['SERVER_ADDR']}:#{env['SERVER_PORT']}" + end + end + + # Returns the host for this request, such as example.com. + def host + raw_host_with_port.sub(/:\d+$/, '') + end + + # Returns a \host:\port string for this request, such as "example.com" or + # "example.com:8080". + def host_with_port + "#{host}#{port_string}" + end + + # Returns the port number of this request as an integer. + def port + if raw_host_with_port =~ /:(\d+)$/ + $1.to_i + else + standard_port + end + end + + # Returns the standard \port number for this request's protocol. + def standard_port + case protocol + when 'https://' then 443 + else 80 + end + end + + # Returns a \port suffix like ":8080" if the \port number of this request + # is not the default HTTP \port 80 or HTTPS \port 443. + def port_string + port == standard_port ? '' : ":#{port}" + end + + # Returns the \domain part of a \host, such as "rubyonrails.org" in "www.rubyonrails.org". You can specify + # a different <tt>tld_length</tt>, such as 2 to catch rubyonrails.co.uk in "www.rubyonrails.co.uk". + def domain(tld_length = 1) + return nil unless named_host?(host) + + host.split('.').last(1 + tld_length).join('.') + end + + # Returns all the \subdomains as an array, so <tt>["dev", "www"]</tt> would be + # returned for "dev.www.rubyonrails.org". You can specify a different <tt>tld_length</tt>, + # such as 2 to catch <tt>["www"]</tt> instead of <tt>["www", "rubyonrails"]</tt> + # in "www.rubyonrails.co.uk". + def subdomains(tld_length = 1) + return [] unless named_host?(host) + parts = host.split('.') + parts[0..-(tld_length+2)] + end + + # Returns the query string, accounting for server idiosyncrasies. + def query_string + @env['QUERY_STRING'].present? ? @env['QUERY_STRING'] : (@env['REQUEST_URI'].split('?', 2)[1] || '') + end + + # Returns the request URI, accounting for server idiosyncrasies. + # WEBrick includes the full URL. IIS leaves REQUEST_URI blank. + def request_uri + if uri = @env['REQUEST_URI'] + # Remove domain, which webrick puts into the request_uri. + (%r{^\w+\://[^/]+(/.*|$)$} =~ uri) ? $1 : uri + else + # Construct IIS missing REQUEST_URI from SCRIPT_NAME and PATH_INFO. + uri = @env['PATH_INFO'].to_s + + if script_filename = @env['SCRIPT_NAME'].to_s.match(%r{[^/]+$}) + uri = uri.sub(/#{script_filename}\//, '') + end + + env_qs = @env['QUERY_STRING'].to_s + uri += "?#{env_qs}" unless env_qs.empty? + + if uri.blank? + @env.delete('REQUEST_URI') + else + @env['REQUEST_URI'] = uri + end + end + end + + # Returns the interpreted \path to requested resource after all the installation + # directory of this application was taken into account. + def path + path = request_uri.to_s[/\A[^\?]*/] + path.sub!(/\A#{ActionController::Base.relative_url_root}/, '') + path + end + + # Read the request \body. This is useful for web services that need to + # work with raw requests directly. + def raw_post + unless @env.include? 'RAW_POST_DATA' + @env['RAW_POST_DATA'] = body.read(@env['CONTENT_LENGTH'].to_i) + body.rewind if body.respond_to?(:rewind) + end + @env['RAW_POST_DATA'] + end + + # Returns both GET and POST \parameters in a single hash. + def parameters + @parameters ||= request_parameters.merge(query_parameters).update(path_parameters).with_indifferent_access + end + alias_method :params, :parameters + + def path_parameters=(parameters) #:nodoc: + @env["rack.routing_args"] = parameters + @symbolized_path_parameters = @parameters = nil + end + + # The same as <tt>path_parameters</tt> with explicitly symbolized keys. + def symbolized_path_parameters + @symbolized_path_parameters ||= path_parameters.symbolize_keys + end + + # Returns a hash with the \parameters used to form the \path of the request. + # Returned hash keys are strings: + # + # {'action' => 'my_action', 'controller' => 'my_controller'} + # + # See <tt>symbolized_path_parameters</tt> for symbolized keys. + def path_parameters + @env["rack.routing_args"] ||= {} + end + + # The request body is an IO input stream. If the RAW_POST_DATA environment + # variable is already set, wrap it in a StringIO. + def body + if raw_post = @env['RAW_POST_DATA'] + raw_post.force_encoding(Encoding::BINARY) if raw_post.respond_to?(:force_encoding) + StringIO.new(raw_post) + else + @env['rack.input'] + end + end + + def form_data? + FORM_DATA_MEDIA_TYPES.include?(content_type.to_s) + end + + # Override Rack's GET method to support indifferent access + def GET + @env["action_controller.request.query_parameters"] ||= normalize_parameters(super) + end + alias_method :query_parameters, :GET + + # Override Rack's POST method to support indifferent access + def POST + @env["action_controller.request.request_parameters"] ||= normalize_parameters(super) + end + alias_method :request_parameters, :POST + + def body_stream #:nodoc: + @env['rack.input'] + end + + def session + @env['rack.session'] ||= {} + end + + def session=(session) #:nodoc: + @env['rack.session'] = session + end + + def reset_session + @env['rack.session.options'].delete(:id) + @env['rack.session'] = {} + end + + def session_options + @env['rack.session.options'] ||= {} + end + + def session_options=(options) + @env['rack.session.options'] = options + end + + def server_port + @env['SERVER_PORT'].to_i + end + + private + def named_host?(host) + !(host.nil? || /\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.match(host)) + end + + module UploadedFile + def self.extended(object) + object.class_eval do + attr_accessor :original_path, :content_type + alias_method :local_path, :path + end + end + + # Take the basename of the upload's original filename. + # This handles the full Windows paths given by Internet Explorer + # (and perhaps other broken user agents) without affecting + # those which give the lone filename. + # The Windows regexp is adapted from Perl's File::Basename. + def original_filename + unless defined? @original_filename + @original_filename = + unless original_path.blank? + if original_path =~ /^(?:.*[:\\\/])?(.*)/m + $1 + else + File.basename original_path + end + end + end + @original_filename + end + end + + # Convert nested Hashs to HashWithIndifferentAccess and replace + # file upload hashs with UploadedFile objects + def normalize_parameters(value) + case value + when Hash + if value.has_key?(:tempfile) + upload = value[:tempfile] + upload.extend(UploadedFile) + upload.original_path = value[:filename] + upload.content_type = value[:type] + upload + else + h = {} + value.each { |k, v| h[k] = normalize_parameters(v) } + h.with_indifferent_access + end + when Array + value.map { |e| normalize_parameters(e) } + else + value + end + end + end +end diff --git a/actionpack/lib/action_dispatch/http/response.rb b/actionpack/lib/action_dispatch/http/response.rb new file mode 100644 index 0000000000..ecf40b8103 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/response.rb @@ -0,0 +1,251 @@ +require 'digest/md5' + +module ActionDispatch # :nodoc: + # Represents an HTTP response generated by a controller action. One can use + # an ActionController::Response object to retrieve the current state + # of the response, or customize the response. An Response object can + # either represent a "real" HTTP response (i.e. one that is meant to be sent + # back to the web browser) or a test response (i.e. one that is generated + # from integration tests). See CgiResponse and TestResponse, respectively. + # + # Response is mostly a Ruby on Rails framework implement detail, and + # should never be used directly in controllers. Controllers should use the + # methods defined in ActionController::Base instead. For example, if you want + # to set the HTTP response's content MIME type, then use + # ActionControllerBase#headers instead of Response#headers. + # + # Nevertheless, integration tests may want to inspect controller responses in + # more detail, and that's when Response can be useful for application + # developers. Integration test methods such as + # ActionController::Integration::Session#get and + # ActionController::Integration::Session#post return objects of type + # TestResponse (which are of course also of type Response). + # + # For example, the following demo integration "test" prints the body of the + # controller response to the console: + # + # class DemoControllerTest < ActionController::IntegrationTest + # def test_print_root_path_to_console + # get('/') + # puts @response.body + # end + # end + class Response < Rack::Response + DEFAULT_HEADERS = { "Cache-Control" => "no-cache" } + attr_accessor :request + + attr_accessor :session, :assigns, :template, :layout + attr_accessor :redirected_to, :redirected_to_method_params + + delegate :default_charset, :to => 'ActionController::Base' + + def initialize + super + @header = Rack::Utils::HeaderHash.new(DEFAULT_HEADERS) + @session, @assigns = [], [] + end + + def body + str = '' + each { |part| str << part.to_s } + str + end + + def body=(body) + @body = + if body.is_a?(String) + [body] + else + body + end + end + + def body_parts + @body + end + + def location; headers['Location'] end + def location=(url) headers['Location'] = url end + + + # Sets the HTTP response's content MIME type. For example, in the controller + # you could write this: + # + # response.content_type = "text/plain" + # + # If a character set has been defined for this response (see charset=) then + # the character set information will also be included in the content type + # information. + def content_type=(mime_type) + self.headers["Content-Type"] = + if mime_type =~ /charset/ || (c = charset).nil? + mime_type.to_s + else + "#{mime_type}; charset=#{c}" + end + end + + # Returns the response's content MIME type, or nil if content type has been set. + def content_type + content_type = String(headers["Content-Type"] || headers["type"]).split(";")[0] + content_type.blank? ? nil : content_type + end + + # Set the charset of the Content-Type header. Set to nil to remove it. + # If no content type is set, it defaults to HTML. + def charset=(charset) + headers["Content-Type"] = + if charset + "#{content_type || Mime::HTML}; charset=#{charset}" + else + content_type || Mime::HTML.to_s + end + end + + def charset + charset = String(headers["Content-Type"] || headers["type"]).split(";")[1] + charset.blank? ? nil : charset.strip.split("=")[1] + end + + def last_modified + if last = headers['Last-Modified'] + Time.httpdate(last) + end + end + + def last_modified? + headers.include?('Last-Modified') + end + + def last_modified=(utc_time) + headers['Last-Modified'] = utc_time.httpdate + end + + def etag + headers['ETag'] + end + + def etag? + headers.include?('ETag') + end + + def etag=(etag) + if etag.blank? + headers.delete('ETag') + else + headers['ETag'] = %("#{Digest::MD5.hexdigest(ActiveSupport::Cache.expand_cache_key(etag))}") + end + end + + def redirect(url, status) + self.status = status + self.location = url.gsub(/[\r\n]/, '') + self.body = "<html><body>You are being <a href=\"#{CGI.escapeHTML(url)}\">redirected</a>.</body></html>" + end + + def sending_file? + headers["Content-Transfer-Encoding"] == "binary" + end + + def assign_default_content_type_and_charset! + self.content_type ||= Mime::HTML + self.charset ||= default_charset unless sending_file? + end + + def prepare! + assign_default_content_type_and_charset! + handle_conditional_get! + set_content_length! + convert_content_type! + convert_language! + convert_cookies! + end + + def each(&callback) + if @body.respond_to?(:call) + @writer = lambda { |x| callback.call(x) } + @body.call(self, self) + elsif @body.is_a?(String) + callback.call(@body) + else + @body.each(&callback) + end + + @writer = callback + @block.call(self) if @block + end + + def write(str) + str = str.to_s + @writer.call str + str + end + + def set_cookie(key, value) + if value.has_key?(:http_only) + ActiveSupport::Deprecation.warn( + "The :http_only option in ActionController::Response#set_cookie " + + "has been renamed. Please use :httponly instead.", caller) + value[:httponly] ||= value.delete(:http_only) + end + + super(key, value) + end + + private + def handle_conditional_get! + if etag? || last_modified? + set_conditional_cache_control! + elsif nonempty_ok_response? + self.etag = body + + if request && request.etag_matches?(etag) + self.status = '304 Not Modified' + self.body = [] + end + + set_conditional_cache_control! + end + end + + def nonempty_ok_response? + ok = !status || status.to_s[0..2] == '200' + ok && string_body? + end + + def string_body? + !body_parts.respond_to?(:call) && body_parts.any? && body_parts.all? { |part| part.is_a?(String) } + end + + def set_conditional_cache_control! + if headers['Cache-Control'] == DEFAULT_HEADERS['Cache-Control'] + headers['Cache-Control'] = 'private, max-age=0, must-revalidate' + end + end + + def convert_content_type! + headers['Content-Type'] ||= "text/html" + headers['Content-Type'] += "; charset=" + headers.delete('charset') if headers['charset'] + end + + # Don't set the Content-Length for block-based bodies as that would mean + # reading it all into memory. Not nice for, say, a 2GB streaming file. + def set_content_length! + if status && status.to_s[0..2] == '204' + headers.delete('Content-Length') + elsif length = headers['Content-Length'] + headers['Content-Length'] = length.to_s + elsif string_body? && (!status || status.to_s[0..2] != '304') + headers["Content-Length"] = Rack::Utils.bytesize(body).to_s + end + end + + def convert_language! + headers["Content-Language"] = headers.delete("language") if headers["language"] + end + + def convert_cookies! + headers['Set-Cookie'] = Array(headers['Set-Cookie']).compact + end + end +end diff --git a/actionpack/lib/action_dispatch/http/status_codes.rb b/actionpack/lib/action_dispatch/http/status_codes.rb new file mode 100644 index 0000000000..830de2a6db --- /dev/null +++ b/actionpack/lib/action_dispatch/http/status_codes.rb @@ -0,0 +1,40 @@ +module ActionDispatch + module StatusCodes #:nodoc: + STATUS_CODES = Rack::Utils::HTTP_STATUS_CODES.merge({ + 102 => "Processing", + 207 => "Multi-Status", + 226 => "IM Used", + 422 => "Unprocessable Entity", + 423 => "Locked", + 424 => "Failed Dependency", + 426 => "Upgrade Required", + 507 => "Insufficient Storage", + 510 => "Not Extended" + }).freeze + + # Provides a symbol-to-fixnum lookup for converting a symbol (like + # :created or :not_implemented) into its corresponding HTTP status + # code (like 200 or 501). + SYMBOL_TO_STATUS_CODE = STATUS_CODES.inject({}) { |hash, (code, message)| + hash[message.gsub(/ /, "").underscore.to_sym] = code + hash + }.freeze + + private + # Given a status parameter, determine whether it needs to be converted + # to a string. If it is a fixnum, use the STATUS_CODES hash to lookup + # the default message. If it is a symbol, use the SYMBOL_TO_STATUS_CODE + # hash to convert it. + def interpret_status(status) + case status + when Fixnum then + "#{status} #{STATUS_CODES[status]}".strip + when Symbol then + interpret_status(SYMBOL_TO_STATUS_CODE[status] || + "500 Unknown Status #{status.inspect}") + else + status.to_s + end + end + end +end
\ No newline at end of file |