[REF] core: HTTPocalypse (3) serve static files
This commit is the 3rd commit of a comprehensive refactor of our HTTP framework. See odoo/odoo#78857 for complete historic, discussions and rationnals. Static files were historically served thanks to a werkzeug middleware, the usage of that middleware prevented easy modification of the response headers. Using `http.send_file` and `request._serve_static` we achieve the same result as the middleware with less code and less frames in the callstack. The historic `http.send_file` have been splitted in two. One to send a file via its path on the filesystem (prefered function) and one to send a file already open. It is preferred to use `http.send_filepath`. During development we tried to remove the fallback from the `_serve_static` function into the more capables (and resource hungry) db-compatible router. The purpose was to quickly respond to all requests requesting static files at the cost of reduced customization. We have been prooved wrong, e.g. the website themes all depends on the fallback mechanism to deliver static files. PR: odoo#78857 Task: 2571224
This commit is contained in:
+124
-4
@@ -183,12 +183,101 @@ babel.core.LOCALE_ALIASES['nb'] = 'nb_NO'
|
||||
|
||||
...
|
||||
|
||||
|
||||
# The cache duration for static content from the filesystem, one week.
|
||||
STATIC_CACHE = 60 * 60 * 24 * 7
|
||||
|
||||
# The cache duration for content where the url uniquely identifies the
|
||||
# content (usually using a hash), one year.
|
||||
STATIC_CACHE_LONG = 60 * 60 * 24 * 365
|
||||
|
||||
# =========================================================
|
||||
# Helpers
|
||||
# =========================================================
|
||||
|
||||
...
|
||||
|
||||
def send_file(filepath_or_fp, filename=None, mimetype=None, mtime=None,
|
||||
as_attachment=False, cache_timeout=STATIC_CACHE):
|
||||
"""
|
||||
Fle streaming utility with mime and cache handling, it takes a
|
||||
file-object or immediately the content as bytes/str.
|
||||
|
||||
Sends the content of a file to the client. This will use the most
|
||||
efficient method available and configured. By default it will try to
|
||||
use the WSGI server's file_wrapper support.
|
||||
|
||||
If filename of file.name is provided it will try to guess the
|
||||
mimetype for you, but you can also explicitly provide one.
|
||||
|
||||
For extra security you probably want to send certain files as
|
||||
attachment (e.g. HTML).
|
||||
|
||||
:param Union[os.PathLike,io.FileIO] filepath_or_fp: the filename of
|
||||
the file to send. Alternatively a file object might be provided
|
||||
in which case `X-Sendfile` might not work and fall back to the
|
||||
traditional method. Make sure that the file pointer is position-
|
||||
ed at the start of data to send before calling :func:`send_file`
|
||||
:param str filename: optional if file has a 'name' attribute, used
|
||||
for attachment name and mimetype guess.
|
||||
:param str mimetype: the mimetype of the file if provided, otherwise
|
||||
auto detection happens based on the name.
|
||||
:param datetime mtime: optional if file has a 'name' attribute, last
|
||||
modification time used for conditional response.
|
||||
:param bool as_attachment: set to `True` if you want to send this
|
||||
file with a ``Content-Disposition: attachment`` header.
|
||||
:param int cache_timeout: set to `False` to disable etags and
|
||||
conditional response handling (last modified and etags)
|
||||
:returns: the HTTP response that streams the file.
|
||||
"""
|
||||
if isinstance(filepath_or_fp, str):
|
||||
if not filename:
|
||||
filename = os.path.basename(filepath_or_fp)
|
||||
file = open(filepath_or_fp, 'rb')
|
||||
else:
|
||||
file = filepath_or_fp
|
||||
if not filename:
|
||||
filename = getattr(file, 'name', None)
|
||||
|
||||
# Only used when filename or mtime argument is not provided
|
||||
path = getattr(file, 'name', 'file.bin')
|
||||
|
||||
if not filename:
|
||||
filename = os.path.basename(path)
|
||||
|
||||
if not mimetype:
|
||||
mimetype = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
|
||||
|
||||
file.seek(0, 2)
|
||||
size = file.tell()
|
||||
file.seek(0)
|
||||
|
||||
data = werkzeug.wsgi.wrap_file(request.httprequest.environ, file)
|
||||
|
||||
res = werkzeug.wrappers.Response(data, mimetype=mimetype, direct_passthrough=True)
|
||||
res.content_length = size
|
||||
|
||||
if as_attachment:
|
||||
res.headers.add('Content-Disposition', 'attachment', filename=filename)
|
||||
|
||||
if cache_timeout:
|
||||
if not mtime:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
mtime = datetime.fromtimestamp(os.path.getmtime(path))
|
||||
if mtime:
|
||||
res.last_modified = mtime
|
||||
crc = zlib.adler32(filename.encode('utf-8') if isinstance(filename, str) else filename) & 0xffffffff
|
||||
etag = f'odoo-{mtime}-{size}-{crc}'
|
||||
if not werkzeug.http.is_resource_modified(request.httprequest.environ, etag, last_modified=mtime):
|
||||
res = werkzeug.wrappers.Response(status=304)
|
||||
else:
|
||||
res.cache_control.public = True
|
||||
res.cache_control.max_age = cache_timeout
|
||||
res.set_etag(etag)
|
||||
return res
|
||||
|
||||
...
|
||||
|
||||
|
||||
# =========================================================
|
||||
# Controller and routes
|
||||
@@ -259,7 +348,7 @@ class FutureResponse:
|
||||
|
||||
class Request:
|
||||
"""
|
||||
Wrapper around the incomming HTTP request with deserialized requ@est
|
||||
Wrapper around the incomming HTTP request with deserialized request
|
||||
parameters, session utilities and request dispatching logic.
|
||||
"""
|
||||
|
||||
@@ -283,7 +372,16 @@ class Request:
|
||||
# Routing
|
||||
# =====================================================
|
||||
def _serve_static(self):
|
||||
...
|
||||
""" Serve a static file from the file system. """
|
||||
module, _, path = self.httprequest.path[1:].partition('/static/')
|
||||
try:
|
||||
directory = root.statics[module]
|
||||
filepath = werkzeug.security.safe_join(directory, path)
|
||||
return send_file(filepath)
|
||||
except KeyError:
|
||||
raise NotFound(f'Module "{module}" not found.\n')
|
||||
except OSError: # cover both missing file and invalid permissions
|
||||
raise NotFound(f'File "{path}" not found in module {module}.\n')
|
||||
|
||||
def _serve_nodb(self):
|
||||
...
|
||||
@@ -327,6 +425,23 @@ class Application:
|
||||
""" Odoo WSGI application """
|
||||
# See also: https://www.python.org/dev/peps/pep-3333
|
||||
|
||||
@lazy_property
|
||||
def statics(self):
|
||||
"""
|
||||
Map module names to their absolute ``static`` path on the file
|
||||
system.
|
||||
"""
|
||||
mod2path = {}
|
||||
for addons_path in odoo.addons.__path__:
|
||||
for module in os.listdir(addons_path):
|
||||
manifest = get_manifest(module)
|
||||
static_path = opj(addons_path, module, 'static')
|
||||
if (manifest
|
||||
and (manifest['installable'] or manifest['assets'])
|
||||
and os.path.isdir(static_path)):
|
||||
mod2path[module] = static_path
|
||||
return mod2path
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
"""
|
||||
WSGI application entry point.
|
||||
@@ -347,8 +462,13 @@ class Application:
|
||||
...
|
||||
|
||||
try:
|
||||
response = ...
|
||||
return response(environ, start_response)
|
||||
segments = httprequest.path.split('/')
|
||||
if len(segments) >= 4 and segments[2] == 'static':
|
||||
with contextlib.suppress(NotFound):
|
||||
response = request._serve_static()
|
||||
return response(environ, start_response)
|
||||
|
||||
...
|
||||
|
||||
except Exception as exc:
|
||||
...
|
||||
|
||||
Reference in New Issue
Block a user