diff --git a/setup.py b/setup.py index a44d564e3b..7fd2b15cba 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,7 @@ install_requires = [ "django-modelcluster>=0.6", "django-taggit>=0.13.0", "django-treebeard==3.0", + "django-sendfile==0.3.7", "Pillow>=2.6.1", "beautifulsoup4>=4.3.2", "html5lib==0.999", diff --git a/tox.ini b/tox.ini index d0d1078962..53993ef7db 100644 --- a/tox.ini +++ b/tox.ini @@ -23,6 +23,7 @@ deps = django-modelcluster>=0.6 django-taggit==0.13.0 django-treebeard==3.0 + django-sendfile==0.3.6 Pillow>=2.3.0 beautifulsoup4>=4.3.2 html5lib==0.999 diff --git a/wagtail/tests/settings.py b/wagtail/tests/settings.py index 6bbe14e561..c5e06592a8 100644 --- a/wagtail/tests/settings.py +++ b/wagtail/tests/settings.py @@ -83,6 +83,7 @@ INSTALLED_APPS = ( 'taggit', 'compressor', + 'sendfile', 'wagtail.wagtailcore', 'wagtail.wagtailadmin', diff --git a/wagtail/utils/sendfile.py b/wagtail/utils/sendfile.py new file mode 100644 index 0000000000..a808065b91 --- /dev/null +++ b/wagtail/utils/sendfile.py @@ -0,0 +1,96 @@ +# Copied from django-sendfile 0.3.6 and tweaked to allow a backend to be passed +# to sendfile() +# See: https://github.com/johnsensible/django-sendfile/pull/33 +import os.path +from mimetypes import guess_type + +VERSION = (0, 3, 6) +__version__ = '.'.join(map(str, VERSION)) + + +def _lazy_load(fn): + _cached = [] + + def _decorated(): + if not _cached: + _cached.append(fn()) + return _cached[0] + + def clear(): + while _cached: + _cached.pop() + _decorated.clear = clear + return _decorated + + +@_lazy_load +def _get_sendfile(): + from django.utils.importlib import import_module + from django.conf import settings + from django.core.exceptions import ImproperlyConfigured + + 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 sendfile(request, filename, attachment=False, attachment_filename=None, mimetype=None, encoding=None, backend=None): + ''' + create a response to send file using backend configured in SENDFILE_BACKEND + + If attachment is True the content-disposition header will be set. + This will typically prompt the user to download the file, rather + than view it. The content-disposition filename depends on the + value of attachment_filename: + + None (default): Same as filename + False: No content-disposition filename + String: Value used as filename + + If no mimetype or encoding are specified, then they will be guessed via the + filename (using the standard python mimetypes module) + ''' + _sendfile = backend or _get_sendfile() + + if not os.path.exists(filename): + from django.http import Http404 + raise Http404('"%s" does not exist' % filename) + + guessed_mimetype, guessed_encoding = guess_type(filename) + if mimetype is None: + if guessed_mimetype: + mimetype = guessed_mimetype + else: + mimetype = 'application/octet-stream' + + response = _sendfile(request, filename, mimetype=mimetype) + if attachment: + if attachment_filename is None: + attachment_filename = os.path.basename(filename) + parts = ['attachment'] + if attachment_filename: + from unidecode import unidecode + try: + from django.utils.encoding import force_text + except ImportError: + # Django 1.3 + from django.utils.encoding import force_unicode as force_text + attachment_filename = force_text(attachment_filename) + ascii_filename = unidecode(attachment_filename) + parts.append('filename="%s"' % ascii_filename) + if ascii_filename != attachment_filename: + from django.utils.http import urlquote + quoted_filename = urlquote(attachment_filename) + parts.append('filename*=UTF-8\'\'%s' % quoted_filename) + response['Content-Disposition'] = '; '.join(parts) + + response['Content-length'] = os.path.getsize(filename) + response['Content-Type'] = mimetype + if not encoding: + encoding = guessed_encoding + if encoding: + response['Content-Encoding'] = encoding + + return response diff --git a/wagtail/utils/sendfile_streaming_backend.py b/wagtail/utils/sendfile_streaming_backend.py new file mode 100644 index 0000000000..c42f06eb0f --- /dev/null +++ b/wagtail/utils/sendfile_streaming_backend.py @@ -0,0 +1,61 @@ +# Sendfile "streaming" backend +# This is based on sendfiles builtin "simple" backend but uses a StreamingHttpResponse + +import os +import stat +import re +try: + from email.utils import parsedate_tz, mktime_tz +except ImportError: + from email.Utils import parsedate_tz, mktime_tz +from wsgiref.util import FileWrapper + +from django.http import StreamingHttpResponse, HttpResponseNotModified +from django.utils.http import http_date + + +def sendfile(request, filename, **kwargs): + # Respect the If-Modified-Since header. + statobj = os.stat(filename) + + if not was_modified_since(request.META.get('HTTP_IF_MODIFIED_SINCE'), + statobj[stat.ST_MTIME], statobj[stat.ST_SIZE]): + return HttpResponseNotModified() + + response = StreamingHttpResponse(FileWrapper(open(filename, 'rb'))) + + response["Last-Modified"] = http_date(statobj[stat.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/wagtail/wagtaildocs/tests.py b/wagtail/wagtaildocs/tests.py index ecde7fa096..d9fca748f0 100644 --- a/wagtail/wagtaildocs/tests.py +++ b/wagtail/wagtaildocs/tests.py @@ -4,6 +4,7 @@ from six import b import unittest import mock from bs4 import BeautifulSoup +import os.path from django.test import TestCase from django.contrib.auth import get_user_model @@ -11,6 +12,7 @@ from django.contrib.auth.models import Group, Permission from django.core.urlresolvers import reverse from django.core.files.base import ContentFile from django.test.utils import override_settings +from django.conf import settings from wagtail.tests.utils import WagtailTestUtils from wagtail.wagtailcore.models import Page @@ -556,6 +558,9 @@ class TestServeView(TestCase): def test_content_length_header(self): self.assertEqual(self.get()['Content-Length'], '25') + def test_content_type_header(self): + self.assertEqual(self.get()['Content-Type'], 'application/msword') + def test_is_streaming_response(self): self.assertTrue(self.get().streaming) @@ -581,6 +586,34 @@ class TestServeView(TestCase): response = self.client.get(reverse('wagtaildocs_serve', args=(self.document.id, 'incorrectfilename'))) self.assertEqual(response.status_code, 404) + def clear_sendfile_cache(self): + from wagtail.utils.sendfile import _get_sendfile + _get_sendfile.clear() + + @override_settings(SENDFILE_BACKEND='sendfile.backends.xsendfile') + def test_sendfile_xsendfile_backend(self): + self.clear_sendfile_cache() + response = self.get() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response['X-Sendfile'], os.path.join(settings.MEDIA_ROOT, self.document.file.name)) + + @override_settings(SENDFILE_BACKEND='sendfile.backends.mod_wsgi', SENDFILE_ROOT=settings.MEDIA_ROOT, SENDFILE_URL=settings.MEDIA_URL[:-1]) + def test_sendfile_mod_wsgi_backend(self): + self.clear_sendfile_cache() + response = self.get() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Location'], os.path.join(settings.MEDIA_URL, self.document.file.name)) + + @override_settings(SENDFILE_BACKEND='sendfile.backends.nginx', SENDFILE_ROOT=settings.MEDIA_ROOT, SENDFILE_URL=settings.MEDIA_URL[:-1]) + def test_sendfile_nginx_backend(self): + self.clear_sendfile_cache() + response = self.get() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response['X-Accel-Redirect'], os.path.join(settings.MEDIA_URL, self.document.file.name)) + class TestServeWithUnicodeFilename(TestCase): def setUp(self): diff --git a/wagtail/wagtaildocs/views/serve.py b/wagtail/wagtaildocs/views/serve.py index 4bd8d45569..62bec36698 100644 --- a/wagtail/wagtaildocs/views/serve.py +++ b/wagtail/wagtaildocs/views/serve.py @@ -1,27 +1,20 @@ from django.shortcuts import get_object_or_404 -from wsgiref.util import FileWrapper -from django.http import StreamingHttpResponse, BadHeaderError +from django.conf import settings -from unidecode import unidecode +from wagtail.utils.sendfile import sendfile +from wagtail.utils import sendfile_streaming_backend from wagtail.wagtaildocs.models import Document, document_served def serve(request, document_id, document_filename): doc = get_object_or_404(Document, id=document_id) - wrapper = FileWrapper(doc.file) - response = StreamingHttpResponse(wrapper, content_type='application/octet-stream') - - try: - response['Content-Disposition'] = 'attachment; filename=%s' % doc.filename - except BadHeaderError: - # Unicode filenames can fail on Django <1.8, Python 2 due to - # https://code.djangoproject.com/ticket/20889 - try with an ASCIIfied version of the name - response['Content-Disposition'] = 'attachment; filename=%s' % unidecode(doc.filename) - - response['Content-Length'] = doc.file.size # Send document_served signal document_served.send(sender=Document, instance=doc, request=request) - return response + if hasattr(settings, 'SENDFILE_BACKEND'): + return sendfile(request, doc.file.path, attachment=True, attachment_filename=doc.filename) + else: + # Fallback to streaming backend if user hasn't specified SENDFILE_BACKEND + return sendfile(request, doc.file.path, attachment=True, attachment_filename=doc.filename, backend=sendfile_streaming_backend.sendfile)