require 'hanami/utils/hash' module Hanami module Action # A set of HTTP Cookies # # It acts as an Hash # # @since 0.1.0 # # @see Hanami::Action::Cookies#cookies class CookieJar # The key that returns raw cookies from the Rack env # # @since 0.1.0 # @api private HTTP_HEADER = 'HTTP_COOKIE'.freeze # The key used by Rack to set the session cookie # # We let CookieJar to NOT take care of this cookie, but it leaves the # responsibility to the Rack middleware that handle sessions. # # This prevents Set-Cookie to be sent twice. # # @since 0.5.1 # @api private # # @see https://github.com/hanami/controller/issues/138 RACK_SESSION_KEY = :'rack.session' # The key used by Rack to set the cookies as an Hash in the env # # @since 0.1.0 # @api private COOKIE_HASH_KEY = 'rack.request.cookie_hash'.freeze # The key used by Rack to set the cookies as a String in the env # # @since 0.1.0 # @api private COOKIE_STRING_KEY = 'rack.request.cookie_string'.freeze # @since 0.4.5 # @api private COOKIE_SEPARATOR = ';,'.freeze # Initialize the CookieJar # # @param env [Hash] a raw Rack env # @param headers [Hash] the response headers # # @return [CookieJar] # # @since 0.1.0 def initialize(env, headers, default_options) @_headers = headers @cookies = Utils::Hash.new(extract(env)).deep_symbolize! @default_options = default_options end # Finalize itself, by setting the proper headers to add and remove # cookies, before the response is returned to the webserver. # # @return [void] # # @since 0.1.0 # # @see Hanami::Action::Cookies#finish def finish @cookies.delete(RACK_SESSION_KEY) @cookies.each do |k,v| next unless changed?(k) v.nil? ? delete_cookie(k) : set_cookie(k, _merge_default_values(v)) end if changed? end # Returns the object associated with the given key # # @param key [Symbol] the key # # @return [Object,nil] return the associated object, if found # # @since 0.2.0 def [](key) @cookies[key] end # Associate the given value with the given key and store them # # @param key [Symbol] the key # @param value [#to_s,Hash] value that can be serialized as a string or # expressed as a Hash # @option value [String] :value - Value of the cookie # @option value [String] :domain - The domain # @option value [String] :path - The path # @option value [Integer] :max_age - Duration expressed in seconds # @option value [Time] :expires - Expiration time # @option value [TrueClass,FalseClass] :secure - Restrict cookie to secure # connections # @option value [TrueClass,FalseClass] :httponly - Restrict JavaScript # access # # @return [void] # # @since 0.2.0 # # @see http://en.wikipedia.org/wiki/HTTP_cookie def []=(key, value) changes << key @cookies[key] = value end # Iterates cookies # # @param blk [Proc] the block to be yielded # @yield [key, value] the key/value pair for each cookie # # @return [void] # # @since 1.1.0 # # @example # require "hanami/controller" # class MyAction # include Hanami::Action # include Hanami::Action::Cookies # # def call(params) # cookies.each do |key, value| # # ... # end # end # end def each(&blk) @cookies.each(&blk) end private # Keep track of changed keys # # @since 0.7.0 # @api private def changes @changes ||= Set.new end # Check if the entire set of cookies has changed within the current request. # If key is given, it checks the associated cookie has changed. # # @since 0.7.0 # @api private def changed?(key = nil) if key.nil? changes.any? else changes.include?(key) end end # Merge default cookies options with values provided by user # # Cookies values provided by user are respected # # @since 0.4.0 # @api private def _merge_default_values(value) cookies_options = if value.is_a? Hash value.merge! _add_expires_option(value) else { value: value } end @default_options.merge cookies_options end # Add expires option to cookies if :max_age presents # # @since 0.4.3 # @api private def _add_expires_option(value) if value.has_key?(:max_age) && !value.has_key?(:expires) { expires: (Time.now + value[:max_age]) } else {} end end # Extract the cookies from the raw Rack env. # # This implementation is borrowed from Rack::Request#cookies. # # @since 0.1.0 # @api private def extract(env) hash = env[COOKIE_HASH_KEY] ||= {} string = env[HTTP_HEADER] return hash if string == env[COOKIE_STRING_KEY] # TODO Next Rack 1.7.x ?? version will have ::Rack::Utils.parse_cookies # We can then replace the following lines. hash.clear # According to RFC 2109: # If multiple cookies satisfy the criteria above, they are ordered in # the Cookie header such that those with more specific Path attributes # precede those with less specific. Ordering with respect to other # attributes (e.g., Domain) is unspecified. cookies = ::Rack::Utils.parse_query(string, COOKIE_SEPARATOR) { |s| ::Rack::Utils.unescape(s) rescue s } cookies.each { |k,v| hash[k] = Array === v ? v.first : v } env[COOKIE_STRING_KEY] = string hash end # Set a cookie in the headers # # @since 0.1.0 # @api private def set_cookie(key, value) ::Rack::Utils.set_cookie_header!(@_headers, key, value) end # Remove a cookie from the headers # # @since 0.1.0 # @api private def delete_cookie(key) ::Rack::Utils.delete_cookie_header!(@_headers, key, {}) end end end end