aboutsummaryrefslogtreecommitdiffstats
path: root/actionpack/lib/action_dispatch/http/cache.rb
blob: a7c7cfc1e5d795372e817516e6542307be0d4372 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# frozen_string_literal: true

module ActionDispatch
  module Http
    module Cache
      module Request
        HTTP_IF_MODIFIED_SINCE = "HTTP_IF_MODIFIED_SINCE".freeze
        HTTP_IF_NONE_MATCH     = "HTTP_IF_NONE_MATCH".freeze

        def if_modified_since
          if since = get_header(HTTP_IF_MODIFIED_SINCE)
            Time.rfc2822(since) rescue nil
          end
        end

        def if_none_match
          get_header HTTP_IF_NONE_MATCH
        end

        def if_none_match_etags
          if_none_match ? if_none_match.split(/\s*,\s*/) : []
        end

        def not_modified?(modified_at)
          if_modified_since && modified_at && if_modified_since >= modified_at
        end

        def etag_matches?(etag)
          if etag
            validators = if_none_match_etags
            validators.include?(etag) || validators.include?("*")
          end
        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)
          last_modified = if_modified_since
          etag          = if_none_match

          return false unless last_modified || etag

          success = true
          success &&= not_modified?(response.last_modified) if last_modified
          success &&= etag_matches?(response.etag) if etag
          success
        end
      end

      module Response
        attr_reader :cache_control

        def last_modified
          if last = get_header(LAST_MODIFIED)
            Time.httpdate(last)
          end
        end

        def last_modified?
          has_header? LAST_MODIFIED
        end

        def last_modified=(utc_time)
          set_header LAST_MODIFIED, utc_time.httpdate
        end

        def date
          if date_header = get_header(DATE)
            Time.httpdate(date_header)
          end
        end

        def date?
          has_header? DATE
        end

        def date=(utc_time)
          set_header DATE, utc_time.httpdate
        end

        # This method sets a weak ETag validator on the response so browsers
        # and proxies may cache the response, keyed on the ETag. On subsequent
        # requests, the If-None-Match header is set to the cached ETag. If it
        # matches the current ETag, we can return a 304 Not Modified response
        # with no body, letting the browser or proxy know that their cache is
        # current. Big savings in request time and network bandwidth.
        #
        # Weak ETags are considered to be semantically equivalent but not
        # byte-for-byte identical. This is perfect for browser caching of HTML
        # pages where we don't care about exact equality, just what the user
        # is viewing.
        #
        # Strong ETags are considered byte-for-byte identical. They allow a
        # browser or proxy cache to support Range requests, useful for paging
        # through a PDF file or scrubbing through a video. Some CDNs only
        # support strong ETags and will ignore weak ETags entirely.
        #
        # Weak ETags are what we almost always need, so they're the default.
        # Check out #strong_etag= to provide a strong ETag validator.
        def etag=(weak_validators)
          self.weak_etag = weak_validators
        end

        def weak_etag=(weak_validators)
          set_header "ETag", generate_weak_etag(weak_validators)
        end

        def strong_etag=(strong_validators)
          set_header "ETag", generate_strong_etag(strong_validators)
        end

        def etag?; etag; end

        # True if an ETag is set and it's a weak validator (preceded with W/)
        def weak_etag?
          etag? && etag.starts_with?('W/"')
        end

        # True if an ETag is set and it isn't a weak validator (not preceded with W/)
        def strong_etag?
          etag? && !weak_etag?
        end

      private

        DATE          = "Date".freeze
        LAST_MODIFIED = "Last-Modified".freeze
        SPECIAL_KEYS  = Set.new(%w[extras no-cache max-age public private must-revalidate])

        def generate_weak_etag(validators)
          "W/#{generate_strong_etag(validators)}"
        end

        def generate_strong_etag(validators)
          %("#{ActiveSupport::Digest.hexdigest(ActiveSupport::Cache.expand_cache_key(validators))}")
        end

        def cache_control_segments
          if cache_control = _cache_control
            cache_control.delete(" ").split(",")
          else
            []
          end
        end

        def cache_control_headers
          cache_control = {}

          cache_control_segments.each do |segment|
            directive, argument = segment.split("=", 2)

            if SPECIAL_KEYS.include? directive
              key = directive.tr("-", "_")
              cache_control[key.to_sym] = argument || true
            else
              cache_control[:extras] ||= []
              cache_control[:extras] << segment
            end
          end

          cache_control
        end

        def prepare_cache_control!
          @cache_control = cache_control_headers
        end

        DEFAULT_CACHE_CONTROL = "max-age=0, private, must-revalidate".freeze
        NO_CACHE              = "no-cache".freeze
        PUBLIC                = "public".freeze
        PRIVATE               = "private".freeze
        MUST_REVALIDATE       = "must-revalidate".freeze

        def handle_conditional_get!
          # Normally default cache control setting is handled by ETag
          # middleware. But, if an etag is already set, the middleware
          # defaults to `no-cache` unless a default `Cache-Control` value is
          # previously set. So, set a default one here.
          if (etag? || last_modified?) && !self._cache_control
            self._cache_control = DEFAULT_CACHE_CONTROL
          end
        end

        def merge_and_normalize_cache_control!(cache_control)
          control = {}
          cc_headers = cache_control_headers
          if extras = cc_headers.delete(:extras)
            cache_control[:extras] ||= []
            cache_control[:extras] += extras
            cache_control[:extras].uniq!
          end

          control.merge! cc_headers
          control.merge! cache_control

          if control.empty?
            # Let middleware handle default behavior
          elsif control[:no_cache]
            self._cache_control = NO_CACHE
            if control[:extras]
              self._cache_control = _cache_control + ", #{control[:extras].join(', ')}"
            end
          else
            extras = control[:extras]
            max_age = control[:max_age]
            stale_while_revalidate = control[:stale_while_revalidate]
            stale_if_error = control[:stale_if_error]

            options = []
            options << "max-age=#{max_age.to_i}" if max_age
            options << (control[:public] ? PUBLIC : PRIVATE)
            options << MUST_REVALIDATE if control[:must_revalidate]
            options << "stale-while-revalidate=#{stale_while_revalidate.to_i}" if stale_while_revalidate
            options << "stale-if-error=#{stale_if_error.to_i}" if stale_if_error
            options.concat(extras) if extras

            self._cache_control = options.join(", ")
          end
        end
      end
    end
  end
end