Coverage for /Users/davegaeddert/Development/dropseed/plain/plain/plain/assets/views.py: 19%

154 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-16 22:04 -0500

1import functools 

2import mimetypes 

3import os 

4from email.utils import formatdate, parsedate 

5from io import BytesIO 

6 

7from plain.http import ( 

8 FileResponse, 

9 Http404, 

10 Response, 

11 ResponseNotModified, 

12 ResponseRedirect, 

13 StreamingResponse, 

14) 

15from plain.runtime import settings 

16from plain.urls import reverse 

17from plain.views import View 

18 

19from .compile import FINGERPRINT_LENGTH, get_compiled_path 

20from .finders import find_assets 

21from .fingerprints import get_fingerprinted_url_path 

22 

23 

24class AssetView(View): 

25 """ 

26 Serve an asset file directly. 

27 

28 This class could be subclassed to further tweak the responses or behavior. 

29 """ 

30 

31 def get(self): 

32 url_path = self.url_kwargs["path"] 

33 

34 # Make a trailing slash work, but we don't expect it 

35 url_path = url_path.rstrip("/") 

36 

37 if settings.DEBUG: 

38 absolute_path = self.get_debug_asset_path(url_path) 

39 else: 

40 absolute_path = self.get_asset_path(url_path) 

41 

42 if settings.ASSETS_REDIRECT_ORIGINAL: 

43 if redirect_response := self.get_redirect_response(url_path): 

44 return redirect_response 

45 

46 self.check_asset_path(absolute_path) 

47 

48 if encoded_path := self.get_encoded_path(absolute_path): 

49 absolute_path = encoded_path 

50 

51 if range_response := self.get_range_response(absolute_path): 

52 return range_response 

53 

54 if not_modified_response := self.get_conditional_response(absolute_path): 

55 return not_modified_response 

56 

57 content_type, _ = mimetypes.guess_type(absolute_path) 

58 

59 response = FileResponse( 

60 open(absolute_path, "rb"), 

61 filename=os.path.basename(absolute_path), 

62 content_type=content_type, 

63 ) 

64 response.headers = self.update_headers(response.headers, absolute_path) 

65 return response 

66 

67 def get_asset_path(self, path): 

68 """Get the path to the compiled asset""" 

69 compiled_path = os.path.abspath(get_compiled_path()) 

70 asset_path = os.path.join(compiled_path, path) 

71 

72 # Make sure we don't try to escape the compiled assests path 

73 if not os.path.commonpath([compiled_path, asset_path]) == compiled_path: 

74 raise Http404("Asset not found") 

75 

76 return asset_path 

77 

78 def get_debug_asset_path(self, path): 

79 """Make a "live" check to find the uncompiled asset in the filesystem""" 

80 if asset := find_assets().get(path): 

81 return asset.absolute_path 

82 

83 def check_asset_path(self, path): 

84 if not path: 

85 raise Http404("Asset not found") 

86 

87 if not os.path.exists(path): 

88 raise Http404("Asset not found") 

89 

90 if os.path.isdir(path): 

91 raise Http404("Asset is a directory") 

92 

93 @functools.cache 

94 def get_last_modified(self, path): 

95 try: 

96 mtime = os.path.getmtime(path) 

97 except OSError: 

98 mtime = None 

99 

100 if mtime: 

101 return formatdate(mtime, usegmt=True) 

102 

103 @functools.cache 

104 def get_etag(self, path): 

105 try: 

106 mtime = os.path.getmtime(path) 

107 except OSError: 

108 mtime = None 

109 

110 timestamp = int(mtime) 

111 size = self.get_size(path) 

112 return f'"{timestamp:x}-{size:x}"' 

113 

114 @functools.cache 

115 def get_size(self, path): 

116 return os.path.getsize(path) 

117 

118 def update_headers(self, headers, path): 

119 headers.setdefault("Access-Control-Allow-Origin", "*") 

120 

121 # Always vary on Accept-Encoding 

122 vary = headers.get("Vary") 

123 if not vary: 

124 headers["Vary"] = "Accept-Encoding" 

125 elif vary == "*": 

126 pass 

127 elif "Accept-Encoding" not in vary: 

128 headers["Vary"] = vary + ", Accept-Encoding" 

129 

130 # If the file is compressed, tell the browser 

131 if encoding := mimetypes.guess_type(path)[1]: 

132 headers.setdefault("Content-Encoding", encoding) 

133 

134 is_immutable = self.is_immutable(path) 

135 

136 if is_immutable: 

137 max_age = 10 * 365 * 24 * 60 * 60 # 10 years 

138 headers.setdefault("Cache-Control", f"max-age={max_age}, immutable") 

139 elif settings.DEBUG: 

140 # In development, cache for 1 second to avoid re-fetching the same file 

141 headers.setdefault("Cache-Control", "max-age=0") 

142 else: 

143 # Tell the browser to cache the file for 60 seconds if nothing else 

144 headers.setdefault("Cache-Control", "max-age=60") 

145 

146 if not is_immutable: 

147 if last_modified := self.get_last_modified(path): 

148 headers.setdefault("Last-Modified", last_modified) 

149 if etag := self.get_etag(path): 

150 headers.setdefault("ETag", etag) 

151 

152 if "Content-Disposition" in headers: 

153 # This header messes up Safari... 

154 # https://github.com/evansd/whitenoise/commit/93657cf88e14b919cb726864814617a6a639e507 

155 # At some point, should probably look at not using FileResponse at all? 

156 del headers["Content-Disposition"] 

157 

158 return headers 

159 

160 def is_immutable(self, path): 

161 """ 

162 Determine whether an asset looks like it is immutable. 

163 

164 Pattern matching based on fingerprinted filenames: 

165 - main.{fingerprint}.css 

166 - main.{fingerprint}.css.gz 

167 """ 

168 base = os.path.basename(path) 

169 extension = None 

170 while extension != "": 

171 base, extension = os.path.splitext(base) 

172 if len(extension) == FINGERPRINT_LENGTH + 1 and extension[1:].isalnum(): 

173 return True 

174 

175 return False 

176 

177 def get_encoded_path(self, path): 

178 """ 

179 If the client supports compression, return the path to the compressed file. 

180 Otherwise, return the original path. 

181 """ 

182 accept_encoding = self.request.headers.get("Accept-Encoding") 

183 if not accept_encoding: 

184 return 

185 

186 if "br" in accept_encoding: 

187 br_path = path + ".br" 

188 if os.path.exists(br_path): 

189 return br_path 

190 

191 if "gzip" in accept_encoding: 

192 gzip_path = path + ".gz" 

193 if os.path.exists(gzip_path): 

194 return gzip_path 

195 

196 def get_redirect_response(self, path): 

197 """If the asset is not found, try to redirect to the fingerprinted path""" 

198 fingerprinted_url_path = get_fingerprinted_url_path(path) 

199 

200 if not fingerprinted_url_path or fingerprinted_url_path == path: 

201 # Don't need to redirect if there is no fingerprinted path, 

202 # or we're already looking at it. 

203 return 

204 

205 from .urls import default_namespace 

206 

207 return ResponseRedirect( 

208 redirect_to=reverse( 

209 f"{default_namespace}:asset", args=[fingerprinted_url_path] 

210 ), 

211 headers={ 

212 "Cache-Control": "max-age=60", # Can cache this for a short time, but the fingerprinted path can change 

213 }, 

214 ) 

215 

216 def get_conditional_response(self, path): 

217 """ 

218 Support conditional requests (HTTP 304 response) based on ETag and Last-Modified headers. 

219 """ 

220 if self.request.headers.get("If-None-Match") == self.get_etag(path): 

221 response = ResponseNotModified() 

222 response.headers = self.update_headers(response.headers, path) 

223 return response 

224 

225 if "If-Modified-Since" in self.request.headers: 

226 if_modified_since = parsedate(self.request.headers["If-Modified-Since"]) 

227 last_modified = parsedate(self.get_last_modified(path)) 

228 if ( 

229 if_modified_since 

230 and last_modified 

231 and if_modified_since >= last_modified 

232 ): 

233 response = ResponseNotModified() 

234 response.headers = self.update_headers(response.headers, path) 

235 return response 

236 

237 def get_range_response(self, path): 

238 """ 

239 Support range requests (HTTP 206 response). 

240 """ 

241 range_header = self.request.headers.get("HTTP_RANGE") 

242 if not range_header: 

243 return None 

244 

245 file_size = self.get_size(path) 

246 

247 if not range_header.startswith("bytes="): 

248 return Response( 

249 status=416, headers=[("Content-Range", f"bytes */{file_size}")] 

250 ) 

251 

252 range_values = range_header.split("=")[1].split("-") 

253 start = int(range_values[0]) if range_values[0] else 0 

254 end = int(range_values[1]) if range_values[1] else float("inf") 

255 

256 if start >= file_size: 

257 return Response( 

258 status=416, headers=[("Content-Range", f"bytes */{file_size}")] 

259 ) 

260 

261 end = min(end, file_size - 1) 

262 

263 with open(path, "rb") as f: 

264 f.seek(start) 

265 content = f.read(end - start + 1) 

266 

267 response = StreamingResponse(BytesIO(content), status=206) 

268 response.headers = self.update_headers(response.headers, path) 

269 response.headers["Content-Range"] = f"bytes {start}-{end}/{file_size}" 

270 response.headers["Content-Length"] = str(end - start + 1) 

271 return response