lib/roda/plugins/caching.rb



# frozen-string-literal: true

require 'time'

#
class Roda
  module RodaPlugins
    # The caching plugin adds methods related to HTTP caching.
    #
    # For proper caching, you should use either the +last_modified+ or
    # +etag+ request methods.  
    #
    #   r.get 'albums', Integer do |album_id|
    #     @album = Album[album_id]
    #     r.last_modified @album.updated_at
    #     view('album')
    #   end
    #
    #   # or
    #
    #   r.get 'albums', Integer do |album_id|
    #     @album = Album[album_id]
    #     r.etag @album.sha1
    #     view('album')
    #   end
    #
    # Both +last_modified+ or +etag+ will immediately halt processing
    # if there have been no modifications since the last time the
    # client requested the resource, assuming the client uses the
    # appropriate HTTP 1.1 request headers.
    #
    # This plugin also includes the +cache_control+ and +expires+
    # response methods.  The +cache_control+ method sets the
    # Cache-Control header using the given hash:
    #
    #   response.cache_control public: true, max_age: 60
    #   # Cache-Control: public, max-age=60
    # 
    # The +expires+ method is similar, but in addition
    # to setting the HTTP 1.1 Cache-Control header, it also sets
    # the HTTP 1.0 Expires header:
    #
    #   response.expires 60, public: true
    #   # Cache-Control: public, max-age=60
    #   # Expires: Mon, 29 Sep 2014 21:25:47 GMT
    #
    # The implementation was originally taken from Sinatra,
    # which is also released under the MIT License:
    #
    # Copyright (c) 2007, 2008, 2009 Blake Mizerany
    # Copyright (c) 2010, 2011, 2012, 2013, 2014 Konstantin Haase
    # 
    # Permission is hereby granted, free of charge, to any person
    # obtaining a copy of this software and associated documentation
    # files (the "Software"), to deal in the Software without
    # restriction, including without limitation the rights to use,
    # copy, modify, merge, publish, distribute, sublicense, and/or sell
    # copies of the Software, and to permit persons to whom the
    # Software is furnished to do so, subject to the following
    # conditions:
    # 
    # The above copyright notice and this permission notice shall be
    # included in all copies or substantial portions of the Software.
    # 
    # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
    # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
    # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
    # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
    # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
    # OTHER DEALINGS IN THE SOFTWARE.
    module Caching
      module RequestMethods
        # Set the last modified time of the resource using the Last-Modified header.
        # The +time+ argument should be a Time instance.
        #
        # If the current request includes an If-Modified-Since header that is
        # equal or later than the time specified, immediately returns a response
        # with a 304 status.
        #
        # If the current request includes an If-Unmodified-Since header that is
        # before than the time specified, immediately returns a response
        # with a 412 status.
        def last_modified(time)
          return unless time
          res = response
          e = env
          res[RodaResponseHeaders::LAST_MODIFIED] = time.httpdate
          return if e['HTTP_IF_NONE_MATCH']
          status = res.status

          if (!status || status == 200) && (ims = time_from_header(e['HTTP_IF_MODIFIED_SINCE'])) && ims >= time.to_i
            res.status = 304
            halt
          end

          if (!status || (status >= 200 && status < 300) || status == 412) && (ius = time_from_header(e['HTTP_IF_UNMODIFIED_SINCE'])) && ius < time.to_i
            res.status = 412
            halt
          end
        end

        # Set the response entity tag using the ETag header.
        #
        # The +value+ argument is an identifier that uniquely
        # identifies the current version of the resource.
        # Options:
        # :weak :: Use a weak cache validator (a strong cache validator is the default)
        # :new_resource :: Whether this etag should match an etag of * (true for POST, false otherwise)
        #
        # When the current request includes an If-None-Match header with a
        # matching etag, immediately returns a response with a 304 or 412 status,
        # depending on the request method.
        #
        # When the current request includes an If-Match header with a
        # etag that doesn't match, immediately returns a response with a 412 status.
        def etag(value, opts=OPTS)
          # Before touching this code, please double check RFC 2616 14.24 and 14.26.
          weak = opts[:weak]
          new_resource = opts.fetch(:new_resource){post?}

          res = response
          e = env
          res[RodaResponseHeaders::ETAG] = etag = "#{'W/' if weak}\"#{value}\""
          status = res.status

          if (!status || (status >= 200 && status < 300) || status == 304)
            if etag_matches?(e['HTTP_IF_NONE_MATCH'], etag, new_resource)
              res.status = (request_method =~ /\AGET|HEAD|OPTIONS|TRACE\z/i ? 304 : 412)
              halt
            end

            if ifm = e['HTTP_IF_MATCH']
              unless etag_matches?(ifm, etag, new_resource)
                res.status = 412
                halt
              end
            end
          end
        end

        private

        # Helper method checking if a ETag value list includes the current ETag.
        def etag_matches?(list, etag, new_resource)
          return unless list
          return !new_resource if list == '*'
          list.to_s.split(/\s*,\s*/).include?(etag)
        end

        # Helper method parsing a time value from an HTTP header, returning the
        # time as an integer.
        def time_from_header(t)
          Time.httpdate(t).to_i if t
        rescue ArgumentError
        end
      end

      module ResponseMethods
        # Specify response freshness policy for using the Cache-Control header.
        # Options can can any non-value directives (:public, :private, :no_cache,
        # :no_store, :must_revalidate, :proxy_revalidate), with true as the value.
        # Options can also contain value directives (:max_age, :s_maxage).
        #
        #   response.cache_control public: true, max_age: 60
        #   # => Cache-Control: public, max-age=60
        #
        # See RFC 2616 / 14.9 for more on standard cache control directives:
        # http://tools.ietf.org/html/rfc2616#section-14.9.1
        def cache_control(opts)
          values = []
          opts.each do |k, v|
            next unless v
            k = k.to_s.tr('_', '-')
            values << (v == true ? k : "#{k}=#{v}")
          end

          @headers[RodaResponseHeaders::CACHE_CONTROL] = values.join(', ') unless values.empty?
        end

        # Set Cache-Control header with the max_age given.  max_age should
        # be an integer number of seconds that the current request should be
        # cached for.  Also sets the Expires header, useful if you have
        # HTTP 1.0 clients (Cache-Control is an HTTP 1.1 header).
        def expires(max_age, opts=OPTS)
          cache_control(Hash[opts].merge!(:max_age=>max_age))
          @headers[RodaResponseHeaders::EXPIRES] = (Time.now + max_age).httpdate
        end

        # Remove Content-Type and Content-Length for 304 responses.
        def finish
          a = super
          if a[0] == 304
            h = a[1]
            h.delete(RodaResponseHeaders::CONTENT_TYPE)
            h.delete(RodaResponseHeaders::CONTENT_LENGTH)
          end
          a
        end
      end
    end

    register_plugin(:caching, Caching)
  end
end