Coverage for /Users/davegaeddert/Developer/dropseed/plain/plain/plain/assets/views.py: 19%
154 statements
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-23 11:16 -0600
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-23 11:16 -0600
1import functools
2import mimetypes
3import os
4from email.utils import formatdate, parsedate
5from io import BytesIO
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
19from .compile import FINGERPRINT_LENGTH, get_compiled_path
20from .finders import find_assets
21from .fingerprints import get_fingerprinted_url_path
24class AssetView(View):
25 """
26 Serve an asset file directly.
28 This class could be subclassed to further tweak the responses or behavior.
29 """
31 def get(self):
32 url_path = self.url_kwargs["path"]
34 # Make a trailing slash work, but we don't expect it
35 url_path = url_path.rstrip("/")
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)
42 if settings.ASSETS_REDIRECT_ORIGINAL:
43 if redirect_response := self.get_redirect_response(url_path):
44 return redirect_response
46 self.check_asset_path(absolute_path)
48 if encoded_path := self.get_encoded_path(absolute_path):
49 absolute_path = encoded_path
51 if range_response := self.get_range_response(absolute_path):
52 return range_response
54 if not_modified_response := self.get_conditional_response(absolute_path):
55 return not_modified_response
57 content_type, _ = mimetypes.guess_type(absolute_path)
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
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)
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")
76 return asset_path
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
83 def check_asset_path(self, path):
84 if not path:
85 raise Http404("Asset not found")
87 if not os.path.exists(path):
88 raise Http404("Asset not found")
90 if os.path.isdir(path):
91 raise Http404("Asset is a directory")
93 @functools.cache
94 def get_last_modified(self, path):
95 try:
96 mtime = os.path.getmtime(path)
97 except OSError:
98 mtime = None
100 if mtime:
101 return formatdate(mtime, usegmt=True)
103 @functools.cache
104 def get_etag(self, path):
105 try:
106 mtime = os.path.getmtime(path)
107 except OSError:
108 mtime = None
110 timestamp = int(mtime)
111 size = self.get_size(path)
112 return f'"{timestamp:x}-{size:x}"'
114 @functools.cache
115 def get_size(self, path):
116 return os.path.getsize(path)
118 def update_headers(self, headers, path):
119 headers.setdefault("Access-Control-Allow-Origin", "*")
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"
130 # If the file is compressed, tell the browser
131 if encoding := mimetypes.guess_type(path)[1]:
132 headers.setdefault("Content-Encoding", encoding)
134 is_immutable = self.is_immutable(path)
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")
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)
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"]
158 return headers
160 def is_immutable(self, path):
161 """
162 Determine whether an asset looks like it is immutable.
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
175 return False
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
186 if "br" in accept_encoding:
187 br_path = path + ".br"
188 if os.path.exists(br_path):
189 return br_path
191 if "gzip" in accept_encoding:
192 gzip_path = path + ".gz"
193 if os.path.exists(gzip_path):
194 return gzip_path
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)
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
205 from .urls import default_namespace
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 )
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
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
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
245 file_size = self.get_size(path)
247 if not range_header.startswith("bytes="):
248 return Response(
249 status=416, headers=[("Content-Range", f"bytes */{file_size}")]
250 )
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")
256 if start >= file_size:
257 return Response(
258 status=416, headers=[("Content-Range", f"bytes */{file_size}")]
259 )
261 end = min(end, file_size - 1)
263 with open(path, "rb") as f:
264 f.seek(start)
265 content = f.read(end - start + 1)
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