From a19a982b1c09a790df8afa131f64a9e44d488fbe Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Mon, 14 Dec 2020 16:00:48 +0200 Subject: [PATCH] Sendfile: add a sendfile module based on django-sendfile2 --- django_etebase/sendfile/LICENSE | 28 ++++++ django_etebase/sendfile/README.md | 3 + django_etebase/sendfile/__init__.py | 1 + django_etebase/sendfile/backends/__init__.py | 0 .../sendfile/backends/development.py | 17 ++++ django_etebase/sendfile/backends/mod_wsgi.py | 17 ++++ django_etebase/sendfile/backends/nginx.py | 12 +++ django_etebase/sendfile/backends/simple.py | 60 +++++++++++++ django_etebase/sendfile/backends/xsendfile.py | 9 ++ django_etebase/sendfile/utils.py | 85 +++++++++++++++++++ 10 files changed, 232 insertions(+) create mode 100644 django_etebase/sendfile/LICENSE create mode 100644 django_etebase/sendfile/README.md create mode 100644 django_etebase/sendfile/__init__.py create mode 100644 django_etebase/sendfile/backends/__init__.py create mode 100644 django_etebase/sendfile/backends/development.py create mode 100644 django_etebase/sendfile/backends/mod_wsgi.py create mode 100644 django_etebase/sendfile/backends/nginx.py create mode 100644 django_etebase/sendfile/backends/simple.py create mode 100644 django_etebase/sendfile/backends/xsendfile.py create mode 100644 django_etebase/sendfile/utils.py diff --git a/django_etebase/sendfile/LICENSE b/django_etebase/sendfile/LICENSE new file mode 100644 index 0000000..4b733c8 --- /dev/null +++ b/django_etebase/sendfile/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2011, Sensible Development. +Copyright (c) 2019, Matt Molyneaux +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. Neither the name of Django Send File nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/django_etebase/sendfile/README.md b/django_etebase/sendfile/README.md new file mode 100644 index 0000000..aab5091 --- /dev/null +++ b/django_etebase/sendfile/README.md @@ -0,0 +1,3 @@ +Heavily inspired + code borrowed from: https://github.com/moggers87/django-sendfile2/ + +We just simplified and inlined it because we don't want another external dependency for distribution packagers to package, as well as need a much simpler version. diff --git a/django_etebase/sendfile/__init__.py b/django_etebase/sendfile/__init__.py new file mode 100644 index 0000000..4949aa5 --- /dev/null +++ b/django_etebase/sendfile/__init__.py @@ -0,0 +1 @@ +from .utils import sendfile # noqa diff --git a/django_etebase/sendfile/backends/__init__.py b/django_etebase/sendfile/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_etebase/sendfile/backends/development.py b/django_etebase/sendfile/backends/development.py new file mode 100644 index 0000000..d321932 --- /dev/null +++ b/django_etebase/sendfile/backends/development.py @@ -0,0 +1,17 @@ +import os.path + +from django.views.static import serve + + +def sendfile(request, filename, **kwargs): + """ + Send file using Django dev static file server. + + .. warning:: + + Do not use in production. This is only to be used when developing and + is provided for convenience only + """ + dirname = os.path.dirname(filename) + basename = os.path.basename(filename) + return serve(request, basename, dirname) diff --git a/django_etebase/sendfile/backends/mod_wsgi.py b/django_etebase/sendfile/backends/mod_wsgi.py new file mode 100644 index 0000000..07ba3f1 --- /dev/null +++ b/django_etebase/sendfile/backends/mod_wsgi.py @@ -0,0 +1,17 @@ +from __future__ import absolute_import + +from django.http import HttpResponse + +from ..utils import _convert_file_to_url + + +def sendfile(request, filename, **kwargs): + response = HttpResponse() + response['Location'] = _convert_file_to_url(filename) + # need to destroy get_host() to stop django + # rewriting our location to include http, so that + # mod_wsgi is able to do the internal redirect + request.get_host = lambda: '' + request.build_absolute_uri = lambda location: location + + return response diff --git a/django_etebase/sendfile/backends/nginx.py b/django_etebase/sendfile/backends/nginx.py new file mode 100644 index 0000000..8764309 --- /dev/null +++ b/django_etebase/sendfile/backends/nginx.py @@ -0,0 +1,12 @@ +from __future__ import absolute_import + +from django.http import HttpResponse + +from ..utils import _convert_file_to_url + + +def sendfile(request, filename, **kwargs): + response = HttpResponse() + response['X-Accel-Redirect'] = _convert_file_to_url(filename) + + return response diff --git a/django_etebase/sendfile/backends/simple.py b/django_etebase/sendfile/backends/simple.py new file mode 100644 index 0000000..0549b20 --- /dev/null +++ b/django_etebase/sendfile/backends/simple.py @@ -0,0 +1,60 @@ +from email.utils import mktime_tz, parsedate_tz +import re + +from django.core.files.base import File +from django.http import HttpResponse, HttpResponseNotModified +from django.utils.http import http_date + + +def sendfile(request, filepath, **kwargs): + '''Use the SENDFILE_ROOT value composed with the path arrived as argument + to build an absolute path with which resolve and return the file contents. + + If the path points to a file out of the root directory (should cover both + situations with '..' and symlinks) then a 404 is raised. + ''' + statobj = filepath.stat() + + # Respect the If-Modified-Since header. + if not was_modified_since(request.META.get('HTTP_IF_MODIFIED_SINCE'), + statobj.st_mtime, statobj.st_size): + return HttpResponseNotModified() + + with File(filepath.open('rb')) as f: + response = HttpResponse(f.chunks()) + + response["Last-Modified"] = http_date(statobj.st_mtime) + return response + + +def was_modified_since(header=None, mtime=0, size=0): + """ + Was something modified since the user last downloaded it? + + header + This is the value of the If-Modified-Since header. If this is None, + I'll just return True. + + mtime + This is the modification time of the item we're talking about. + + size + This is the size of the item we're talking about. + """ + try: + if header is None: + raise ValueError + matches = re.match(r"^([^;]+)(; length=([0-9]+))?$", header, + re.IGNORECASE) + header_date = parsedate_tz(matches.group(1)) + if header_date is None: + raise ValueError + header_mtime = mktime_tz(header_date) + header_len = matches.group(3) + if header_len and int(header_len) != size: + raise ValueError + if mtime > header_mtime: + raise ValueError + except (AttributeError, ValueError, OverflowError): + return True + return False diff --git a/django_etebase/sendfile/backends/xsendfile.py b/django_etebase/sendfile/backends/xsendfile.py new file mode 100644 index 0000000..74993ee --- /dev/null +++ b/django_etebase/sendfile/backends/xsendfile.py @@ -0,0 +1,9 @@ +from django.http import HttpResponse + + +def sendfile(request, filename, **kwargs): + filename = str(filename) + response = HttpResponse() + response['X-Sendfile'] = filename + + return response diff --git a/django_etebase/sendfile/utils.py b/django_etebase/sendfile/utils.py new file mode 100644 index 0000000..97c06d7 --- /dev/null +++ b/django_etebase/sendfile/utils.py @@ -0,0 +1,85 @@ +from functools import lru_cache +from importlib import import_module +from pathlib import Path, PurePath +from urllib.parse import quote +import logging + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.http import Http404 + +logger = logging.getLogger(__name__) + + +@lru_cache(maxsize=None) +def _get_sendfile(): + backend = getattr(settings, "SENDFILE_BACKEND", None) + if not backend: + raise ImproperlyConfigured("You must specify a value for SENDFILE_BACKEND") + module = import_module(backend) + return module.sendfile + + +def _convert_file_to_url(path): + try: + url_root = PurePath(getattr(settings, "SENDFILE_URL", None)) + except TypeError: + return path + + path_root = PurePath(settings.SENDFILE_ROOT) + path_obj = PurePath(path) + + relpath = path_obj.relative_to(path_root) + # Python 3.5: Path.resolve() has no `strict` kwarg, so use pathmod from an + # already instantiated Path object + url = relpath._flavour.pathmod.normpath(str(url_root / relpath)) + + return quote(str(url)) + + +def _sanitize_path(filepath): + try: + path_root = Path(getattr(settings, "SENDFILE_ROOT", None)) + except TypeError: + raise ImproperlyConfigured("You must specify a value for SENDFILE_ROOT") + + filepath_obj = Path(filepath) + + # get absolute path + # Python 3.5: Path.resolve() has no `strict` kwarg, so use pathmod from an + # already instantiated Path object + filepath_abs = Path(filepath_obj._flavour.pathmod.normpath(str(path_root / filepath_obj))) + + # if filepath_abs is not relative to path_root, relative_to throws an error + try: + filepath_abs.relative_to(path_root) + except ValueError: + raise Http404("{} wrt {} is impossible".format(filepath_abs, path_root)) + + return filepath_abs + + +def sendfile(request, filename, mimetype="application/octet-stream", encoding=None): + """ + Create a response to send file using backend configured in ``SENDFILE_BACKEND`` + + ``filename`` is the absolute path to the file to send. + """ + filepath_obj = _sanitize_path(filename) + logger.debug( + "filename '%s' requested \"\ + \"-> filepath '%s' obtained", + filename, + filepath_obj, + ) + _sendfile = _get_sendfile() + + if not filepath_obj.exists(): + raise Http404('"%s" does not exist' % filepath_obj) + + response = _sendfile(request, filepath_obj, mimetype=mimetype) + + response["Content-length"] = filepath_obj.stat().st_size + response["Content-Type"] = mimetype + + return response