Coverage for /Users/davegaeddert/Developer/dropseed/plain/plain/plain/utils/cache.py: 23%

136 statements  

« prev     ^ index     » next       coverage.py v7.6.9, created at 2024-12-23 11:16 -0600

1""" 

2This module contains helper functions for controlling caching. It does so by 

3managing the "Vary" header of responses. It includes functions to patch the 

4header of response objects directly and decorators that change functions to do 

5that header-patching themselves. 

6 

7For information on the Vary header, see RFC 9110 Section 12.5.5. 

8 

9Essentially, the "Vary" HTTP header defines which headers a cache should take 

10into account when building its cache key. Requests with the same path but 

11different header content for headers named in "Vary" need to get different 

12cache keys to prevent delivery of wrong content. 

13 

14An example: i18n middleware would need to distinguish caches by the 

15"Accept-language" header. 

16""" 

17 

18import time 

19from collections import defaultdict 

20from hashlib import md5 

21 

22from plain.http import Response, ResponseNotModified 

23from plain.logs import log_response 

24from plain.runtime import settings 

25from plain.utils.http import http_date, parse_etags, parse_http_date_safe, quote_etag 

26from plain.utils.regex_helper import _lazy_re_compile 

27 

28cc_delim_re = _lazy_re_compile(r"\s*,\s*") 

29 

30 

31def patch_cache_control(response, **kwargs): 

32 """ 

33 Patch the Cache-Control header by adding all keyword arguments to it. 

34 The transformation is as follows: 

35 

36 * All keyword parameter names are turned to lowercase, and underscores 

37 are converted to hyphens. 

38 * If the value of a parameter is True (exactly True, not just a 

39 true value), only the parameter name is added to the header. 

40 * All other parameters are added with their value, after applying 

41 str() to it. 

42 """ 

43 

44 def dictitem(s): 

45 t = s.split("=", 1) 

46 if len(t) > 1: 

47 return (t[0].lower(), t[1]) 

48 else: 

49 return (t[0].lower(), True) 

50 

51 def dictvalue(*t): 

52 if t[1] is True: 

53 return t[0] 

54 else: 

55 return f"{t[0]}={t[1]}" 

56 

57 cc = defaultdict(set) 

58 if response.get("Cache-Control"): 

59 for field in cc_delim_re.split(response.headers["Cache-Control"]): 

60 directive, value = dictitem(field) 

61 if directive == "no-cache": 

62 # no-cache supports multiple field names. 

63 cc[directive].add(value) 

64 else: 

65 cc[directive] = value 

66 

67 # If there's already a max-age header but we're being asked to set a new 

68 # max-age, use the minimum of the two ages. In practice this happens when 

69 # a decorator and a piece of middleware both operate on a given view. 

70 if "max-age" in cc and "max_age" in kwargs: 

71 kwargs["max_age"] = min(int(cc["max-age"]), kwargs["max_age"]) 

72 

73 # Allow overriding private caching and vice versa 

74 if "private" in cc and "public" in kwargs: 

75 del cc["private"] 

76 elif "public" in cc and "private" in kwargs: 

77 del cc["public"] 

78 

79 for k, v in kwargs.items(): 

80 directive = k.replace("_", "-") 

81 if directive == "no-cache": 

82 # no-cache supports multiple field names. 

83 cc[directive].add(v) 

84 else: 

85 cc[directive] = v 

86 

87 directives = [] 

88 for directive, values in cc.items(): 

89 if isinstance(values, set): 

90 if True in values: 

91 # True takes precedence. 

92 values = {True} 

93 directives.extend([dictvalue(directive, value) for value in values]) 

94 else: 

95 directives.append(dictvalue(directive, values)) 

96 cc = ", ".join(directives) 

97 response.headers["Cache-Control"] = cc 

98 

99 

100def get_max_age(response): 

101 """ 

102 Return the max-age from the response Cache-Control header as an integer, 

103 or None if it wasn't found or wasn't an integer. 

104 """ 

105 if not response.has_header("Cache-Control"): 

106 return 

107 cc = dict( 

108 _to_tuple(el) for el in cc_delim_re.split(response.headers["Cache-Control"]) 

109 ) 

110 try: 

111 return int(cc["max-age"]) 

112 except (ValueError, TypeError, KeyError): 

113 pass 

114 

115 

116def set_response_etag(response): 

117 if not response.streaming and response.content: 

118 response.headers["ETag"] = quote_etag( 

119 md5(response.content, usedforsecurity=False).hexdigest(), 

120 ) 

121 return response 

122 

123 

124def _precondition_failed(request): 

125 response = Response(status=412) 

126 log_response( 

127 "Precondition Failed: %s", 

128 request.path, 

129 response=response, 

130 request=request, 

131 ) 

132 return response 

133 

134 

135def _not_modified(request, response=None): 

136 new_response = ResponseNotModified() 

137 if response: 

138 # Preserve the headers required by RFC 9110 Section 15.4.5, as well as 

139 # Last-Modified. 

140 for header in ( 

141 "Cache-Control", 

142 "Content-Location", 

143 "Date", 

144 "ETag", 

145 "Expires", 

146 "Last-Modified", 

147 "Vary", 

148 ): 

149 if header in response: 

150 new_response.headers[header] = response.headers[header] 

151 

152 # Preserve cookies as per the cookie specification: "If a proxy server 

153 # receives a response which contains a Set-cookie header, it should 

154 # propagate the Set-cookie header to the client, regardless of whether 

155 # the response was 304 (Not Modified) or 200 (OK). 

156 # https://curl.haxx.se/rfc/cookie_spec.html 

157 new_response.cookies = response.cookies 

158 return new_response 

159 

160 

161def get_conditional_response(request, etag=None, last_modified=None, response=None): 

162 # Only return conditional responses on successful requests. 

163 if response and not (200 <= response.status_code < 300): 

164 return response 

165 

166 # Get HTTP request headers. 

167 if_match_etags = parse_etags(request.META.get("HTTP_IF_MATCH", "")) 

168 if_unmodified_since = request.META.get("HTTP_IF_UNMODIFIED_SINCE") 

169 if_unmodified_since = if_unmodified_since and parse_http_date_safe( 

170 if_unmodified_since 

171 ) 

172 if_none_match_etags = parse_etags(request.META.get("HTTP_IF_NONE_MATCH", "")) 

173 if_modified_since = request.META.get("HTTP_IF_MODIFIED_SINCE") 

174 if_modified_since = if_modified_since and parse_http_date_safe(if_modified_since) 

175 

176 # Evaluation of request preconditions below follows RFC 9110 Section 

177 # 13.2.2. 

178 # Step 1: Test the If-Match precondition. 

179 if if_match_etags and not _if_match_passes(etag, if_match_etags): 

180 return _precondition_failed(request) 

181 

182 # Step 2: Test the If-Unmodified-Since precondition. 

183 if ( 

184 not if_match_etags 

185 and if_unmodified_since 

186 and not _if_unmodified_since_passes(last_modified, if_unmodified_since) 

187 ): 

188 return _precondition_failed(request) 

189 

190 # Step 3: Test the If-None-Match precondition. 

191 if if_none_match_etags and not _if_none_match_passes(etag, if_none_match_etags): 

192 if request.method in ("GET", "HEAD"): 

193 return _not_modified(request, response) 

194 else: 

195 return _precondition_failed(request) 

196 

197 # Step 4: Test the If-Modified-Since precondition. 

198 if ( 

199 not if_none_match_etags 

200 and if_modified_since 

201 and not _if_modified_since_passes(last_modified, if_modified_since) 

202 and request.method in ("GET", "HEAD") 

203 ): 

204 return _not_modified(request, response) 

205 

206 # Step 5: Test the If-Range precondition (not supported). 

207 # Step 6: Return original response since there isn't a conditional response. 

208 return response 

209 

210 

211def _if_match_passes(target_etag, etags): 

212 """ 

213 Test the If-Match comparison as defined in RFC 9110 Section 13.1.1. 

214 """ 

215 if not target_etag: 

216 # If there isn't an ETag, then there can't be a match. 

217 return False 

218 elif etags == ["*"]: 

219 # The existence of an ETag means that there is "a current 

220 # representation for the target resource", even if the ETag is weak, 

221 # so there is a match to '*'. 

222 return True 

223 elif target_etag.startswith("W/"): 

224 # A weak ETag can never strongly match another ETag. 

225 return False 

226 else: 

227 # Since the ETag is strong, this will only return True if there's a 

228 # strong match. 

229 return target_etag in etags 

230 

231 

232def _if_unmodified_since_passes(last_modified, if_unmodified_since): 

233 """ 

234 Test the If-Unmodified-Since comparison as defined in RFC 9110 Section 

235 13.1.4. 

236 """ 

237 return last_modified and last_modified <= if_unmodified_since 

238 

239 

240def _if_none_match_passes(target_etag, etags): 

241 """ 

242 Test the If-None-Match comparison as defined in RFC 9110 Section 13.1.2. 

243 """ 

244 if not target_etag: 

245 # If there isn't an ETag, then there isn't a match. 

246 return True 

247 elif etags == ["*"]: 

248 # The existence of an ETag means that there is "a current 

249 # representation for the target resource", so there is a match to '*'. 

250 return False 

251 else: 

252 # The comparison should be weak, so look for a match after stripping 

253 # off any weak indicators. 

254 target_etag = target_etag.strip("W/") 

255 etags = (etag.strip("W/") for etag in etags) 

256 return target_etag not in etags 

257 

258 

259def _if_modified_since_passes(last_modified, if_modified_since): 

260 """ 

261 Test the If-Modified-Since comparison as defined in RFC 9110 Section 

262 13.1.3. 

263 """ 

264 return not last_modified or last_modified > if_modified_since 

265 

266 

267def patch_response_headers(response, cache_timeout=None): 

268 """ 

269 Add HTTP caching headers to the given Response: Expires and 

270 Cache-Control. 

271 

272 Each header is only added if it isn't already set. 

273 

274 cache_timeout is in seconds. The CACHE_MIDDLEWARE_SECONDS setting is used 

275 by default. 

276 """ 

277 if cache_timeout is None: 

278 cache_timeout = settings.CACHE_MIDDLEWARE_SECONDS 

279 if cache_timeout < 0: 

280 cache_timeout = 0 # Can't have max-age negative 

281 if not response.has_header("Expires"): 

282 response.headers["Expires"] = http_date(time.time() + cache_timeout) 

283 patch_cache_control(response, max_age=cache_timeout) 

284 

285 

286def add_never_cache_headers(response): 

287 """ 

288 Add headers to a response to indicate that a page should never be cached. 

289 """ 

290 patch_response_headers(response, cache_timeout=-1) 

291 patch_cache_control( 

292 response, no_cache=True, no_store=True, must_revalidate=True, private=True 

293 ) 

294 

295 

296def patch_vary_headers(response, newheaders): 

297 """ 

298 Add (or update) the "Vary" header in the given Response object. 

299 newheaders is a list of header names that should be in "Vary". If headers 

300 contains an asterisk, then "Vary" header will consist of a single asterisk 

301 '*'. Otherwise, existing headers in "Vary" aren't removed. 

302 """ 

303 # Note that we need to keep the original order intact, because cache 

304 # implementations may rely on the order of the Vary contents in, say, 

305 # computing an MD5 hash. 

306 if response.has_header("Vary"): 

307 vary_headers = cc_delim_re.split(response.headers["Vary"]) 

308 else: 

309 vary_headers = [] 

310 # Use .lower() here so we treat headers as case-insensitive. 

311 existing_headers = {header.lower() for header in vary_headers} 

312 additional_headers = [ 

313 newheader 

314 for newheader in newheaders 

315 if newheader.lower() not in existing_headers 

316 ] 

317 vary_headers += additional_headers 

318 if "*" in vary_headers: 

319 response.headers["Vary"] = "*" 

320 else: 

321 response.headers["Vary"] = ", ".join(vary_headers) 

322 

323 

324def _to_tuple(s): 

325 t = s.split("=", 1) 

326 if len(t) == 2: 

327 return t[0].lower(), t[1] 

328 return t[0].lower(), True