# frozen_string_literal: true require "active_support/core_ext/object/try" require "active_support/core_ext/integer/time" module ActionController module ConditionalGet extend ActiveSupport::Concern include Head included do class_attribute :etaggers, default: [] end module ClassMethods # Allows you to consider additional controller-wide information when generating an ETag. # For example, if you serve pages tailored depending on who's logged in at the moment, you # may want to add the current user id to be part of the ETag to prevent unauthorized displaying # of cached pages. # # class InvoicesController < ApplicationController # etag { current_user&.id } # # def show # # Etag will differ even for the same invoice when it's viewed by a different current_user # @invoice = Invoice.find(params[:id]) # fresh_when etag: @invoice # end # end def etag(&etagger) self.etaggers += [etagger] end end # Sets the +etag+, +last_modified+, or both on the response, and renders a # 304 Not Modified response if the request is already fresh. # # ==== Options # # [+:etag+] # Sets a "weak" ETag validator on the response. See the +:weak_etag+ option. # [+:weak_etag+] # Sets a "weak" ETag validator on the response. Requests that specify an # +If-None-Match+ header may receive a 304 Not Modified response # if the ETag matches exactly. # # A weak ETag indicates semantic equivalence, not byte-for-byte equality, # so they're good for caching HTML pages in browser caches. They can't be # used for responses that must be byte-identical, like serving +Range+ # requests within a PDF file. # [+:strong_etag+] # Sets a "strong" ETag validator on the response. Requests that specify an # +If-None-Match+ header may receive a 304 Not Modified response # if the ETag matches exactly. # # A strong ETag implies exact equality -- the response must match byte for # byte. This is necessary for serving +Range+ requests within a large # video or PDF file, for example, or for compatibility with some CDNs that # don't support weak ETags. # [+:last_modified+] # Sets a "weak" last-update validator on the response. Subsequent requests # that specify an +If-Modified-Since+ header may receive a 304 Not Modified # response if +last_modified+ <= +If-Modified-Since+. # [+:public+] # By default the +Cache-Control+ header is private. Set this option to # +true+ if you want your application to be cacheable by other devices, # such as proxy caches. # [+:cache_control+] # When given, will overwrite an existing +Cache-Control+ header. For a # list of +Cache-Control+ directives, see the {article on # MDN}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control]. # [+:template+] # By default, the template digest for the current controller/action is # included in ETags. If the action renders a different template, you can # include its digest instead. If the action doesn't render a template at # all, you can pass template: false to skip any attempt to check # for a template digest. # # ==== Examples # # def show # @article = Article.find(params[:id]) # fresh_when(etag: @article, last_modified: @article.updated_at, public: true) # end # # This will send a 304 Not Modified response if the request # specifies a matching ETag and +If-Modified-Since+ header. Otherwise, it # will render the +show+ template. # # You can also just pass a record: # # def show # @article = Article.find(params[:id]) # fresh_when(@article) # end # # +etag+ will be set to the record, and +last_modified+ will be set to the # record's +updated_at+. # # You can also pass an object that responds to +maximum+, such as a # collection of records: # # def index # @articles = Article.all # fresh_when(@articles) # end # # In this case, +etag+ will be set to the collection, and +last_modified+ # will be set to maximum(:updated_at) (the timestamp of the most # recently updated record). # # When passing a record or a collection, you can still specify other # options, such as +:public+ and +:cache_control+: # # def show # @article = Article.find(params[:id]) # fresh_when(@article, public: true, cache_control: { no_cache: true }) # end # # The above will set Cache-Control: public, no-cache in the response. # # When rendering a different template than the controller/action's default # template, you can indicate which digest to include in the ETag: # # before_action { fresh_when @article, template: "widgets/show" } # def fresh_when(object = nil, etag: nil, weak_etag: nil, strong_etag: nil, last_modified: nil, public: false, cache_control: {}, template: nil) response.cache_control.delete(:no_store) weak_etag ||= etag || object unless strong_etag last_modified ||= object.try(:updated_at) || object.try(:maximum, :updated_at) if strong_etag response.strong_etag = combine_etags strong_etag, last_modified: last_modified, public: public, template: template elsif weak_etag || template response.weak_etag = combine_etags weak_etag, last_modified: last_modified, public: public, template: template end response.last_modified = last_modified if last_modified response.cache_control[:public] = true if public response.cache_control.merge!(cache_control) head :not_modified if request.fresh?(response) end # Sets the +etag+ and/or +last_modified+ on the response and checks them # against the request. If the request doesn't match the provided options, it # is considered stale, and the response should be rendered from scratch. # Otherwise, it is fresh, and a 304 Not Modified is sent. # # ==== Options # # See #fresh_when for supported options. # # ==== Examples # # def show # @article = Article.find(params[:id]) # # if stale?(etag: @article, last_modified: @article.updated_at) # @statistics = @article.really_expensive_call # respond_to do |format| # # all the supported formats # end # end # end # # You can also just pass a record: # # def show # @article = Article.find(params[:id]) # # if stale?(@article) # @statistics = @article.really_expensive_call # respond_to do |format| # # all the supported formats # end # end # end # # +etag+ will be set to the record, and +last_modified+ will be set to the # record's +updated_at+. # # You can also pass an object that responds to +maximum+, such as a # collection of records: # # def index # @articles = Article.all # # if stale?(@articles) # @statistics = @articles.really_expensive_call # respond_to do |format| # # all the supported formats # end # end # end # # In this case, +etag+ will be set to the collection, and +last_modified+ # will be set to maximum(:updated_at) (the timestamp of the most # recently updated record). # # When passing a record or a collection, you can still specify other # options, such as +:public+ and +:cache_control+: # # def show # @article = Article.find(params[:id]) # # if stale?(@article, public: true, cache_control: { no_cache: true }) # @statistics = @articles.really_expensive_call # respond_to do |format| # # all the supported formats # end # end # end # # The above will set Cache-Control: public, no-cache in the response. # # When rendering a different template than the controller/action's default # template, you can indicate which digest to include in the ETag: # # def show # super if stale?(@article, template: "widgets/show") # end # def stale?(object = nil, **freshness_kwargs) fresh_when(object, **freshness_kwargs) !request.fresh?(response) end # Sets the +Cache-Control+ header, overwriting existing directives. This # method will also ensure an HTTP +Date+ header for client compatibility. # # Defaults to issuing the +private+ directive, so that intermediate caches # must not cache the response. # # ==== Options # # [+:public+] # If true, replaces the default +private+ directive with the +public+ # directive. # [+:must_revalidate+] # If true, adds the +must-revalidate+ directive. # [+:stale_while_revalidate+] # Sets the value of the +stale-while-revalidate+ directive. # [+:stale_if_error+] # Sets the value of the +stale-if-error+ directive. # # Any additional key-value pairs are concatenated as directives. For a list # of supported +Cache-Control+ directives, see the {article on # MDN}[https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control]. # # ==== Examples # # expires_in 10.minutes # # => Cache-Control: max-age=600, private # # expires_in 10.minutes, public: true # # => Cache-Control: max-age=600, public # # expires_in 10.minutes, public: true, must_revalidate: true # # => Cache-Control: max-age=600, public, must-revalidate # # expires_in 1.hour, stale_while_revalidate: 60.seconds # # => Cache-Control: max-age=3600, private, stale-while-revalidate=60 # # expires_in 1.hour, stale_if_error: 5.minutes # # => Cache-Control: max-age=3600, private, stale-if-error=300 # # expires_in 1.hour, public: true, "s-maxage": 3.hours, "no-transform": true # # => Cache-Control: max-age=3600, public, s-maxage=10800, no-transform=true # def expires_in(seconds, options = {}) response.cache_control.delete(:no_store) response.cache_control.merge!( max_age: seconds, public: options.delete(:public), must_revalidate: options.delete(:must_revalidate), stale_while_revalidate: options.delete(:stale_while_revalidate), stale_if_error: options.delete(:stale_if_error), ) options.delete(:private) response.cache_control[:extras] = options.map { |k, v| "#{k}=#{v}" } response.date = Time.now unless response.date? end # Sets an HTTP 1.1 +Cache-Control+ header of no-cache. This means the # resource will be marked as stale, so clients must always revalidate. # Intermediate/browser caches may still store the asset. def expires_now response.cache_control.replace(no_cache: true) end # Cache or yield the block. The cache is supposed to never expire. # # You can use this method when you have an HTTP response that never changes, # and the browser and proxies should cache it indefinitely. # # * +public+: By default, HTTP responses are private, cached only on the # user's web browser. To allow proxies to cache the response, set +true+ to # indicate that they can serve the cached response to all users. def http_cache_forever(public: false) expires_in 100.years, public: public yield if stale?(etag: request.fullpath, last_modified: Time.new(2011, 1, 1).utc, public: public) end # Sets an HTTP 1.1 +Cache-Control+ header of no-store. This means the # resource may not be stored in any cache. def no_store response.cache_control.replace(no_store: true) end private def combine_etags(validator, options) [validator, *etaggers.map { |etagger| instance_exec(options, &etagger) }].compact end end end