diff --git a/CHANGELOG b/CHANGELOG index fea7872..42bfdef 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -10,6 +10,7 @@ future releases, check `milestones`_ and :doc:`/about/vision`. X-Sendfile support. +- Feature #2 - Introduced support of Lighttpd's x-Sendfile. - Feature #36 - Introduced support of Apache's mod_xsendfile. diff --git a/demo/demoproject/lighttpd/__init__.py b/demo/demoproject/lighttpd/__init__.py new file mode 100644 index 0000000..a0977ec --- /dev/null +++ b/demo/demoproject/lighttpd/__init__.py @@ -0,0 +1 @@ +"""Lighttpd optimizations.""" diff --git a/demo/demoproject/lighttpd/models.py b/demo/demoproject/lighttpd/models.py new file mode 100644 index 0000000..35f7cd9 --- /dev/null +++ b/demo/demoproject/lighttpd/models.py @@ -0,0 +1 @@ +"""Required to make a Django application.""" diff --git a/demo/demoproject/lighttpd/tests.py b/demo/demoproject/lighttpd/tests.py new file mode 100644 index 0000000..4918d15 --- /dev/null +++ b/demo/demoproject/lighttpd/tests.py @@ -0,0 +1,43 @@ +import os + +from django.core.files.base import ContentFile +from django.core.urlresolvers import reverse +import django.test + +from django_downloadview.lighttpd import assert_x_sendfile + +from demoproject.lighttpd.views import storage, storage_dir + + +def setup_file(): + if not os.path.exists(storage_dir): + os.makedirs(storage_dir) + storage.save('hello-world.txt', ContentFile(u'Hello world!\n')) + + +class OptimizedByMiddlewareTestCase(django.test.TestCase): + def test_response(self): + """'lighttpd:optimized_by_middleware' returns X-Sendfile response.""" + setup_file() + url = reverse('lighttpd:optimized_by_middleware') + response = self.client.get(url) + assert_x_sendfile( + self, + response, + content_type="text/plain; charset=utf-8", + basename="hello-world.txt", + file_path="/lighttpd-optimized-by-middleware/hello-world.txt") + + +class OptimizedByDecoratorTestCase(django.test.TestCase): + def test_response(self): + """'lighttpd:optimized_by_decorator' returns X-Sendfile response.""" + setup_file() + url = reverse('lighttpd:optimized_by_decorator') + response = self.client.get(url) + assert_x_sendfile( + self, + response, + content_type="text/plain; charset=utf-8", + basename="hello-world.txt", + file_path="/lighttpd-optimized-by-decorator/hello-world.txt") diff --git a/demo/demoproject/lighttpd/urls.py b/demo/demoproject/lighttpd/urls.py new file mode 100644 index 0000000..9cb6b97 --- /dev/null +++ b/demo/demoproject/lighttpd/urls.py @@ -0,0 +1,13 @@ +"""URL mapping.""" +from django.conf.urls import patterns, url + + +urlpatterns = patterns( + 'demoproject.lighttpd.views', + url(r'^optimized-by-middleware/$', + 'optimized_by_middleware', + name='optimized_by_middleware'), + url(r'^optimized-by-decorator/$', + 'optimized_by_decorator', + name='optimized_by_decorator'), +) diff --git a/demo/demoproject/lighttpd/views.py b/demo/demoproject/lighttpd/views.py new file mode 100644 index 0000000..d9b4e1b --- /dev/null +++ b/demo/demoproject/lighttpd/views.py @@ -0,0 +1,22 @@ +import os + +from django.conf import settings +from django.core.files.storage import FileSystemStorage + +from django_downloadview import StorageDownloadView +from django_downloadview.lighttpd import x_sendfile + + +storage_dir = os.path.join(settings.MEDIA_ROOT, 'lighttpd') +storage = FileSystemStorage(location=storage_dir, + base_url=''.join([settings.MEDIA_URL, 'lighttpd/'])) + + +optimized_by_middleware = StorageDownloadView.as_view(storage=storage, + path='hello-world.txt') + + +optimized_by_decorator = x_sendfile( + StorageDownloadView.as_view(storage=storage, path='hello-world.txt'), + source_url=storage.base_url, + destination_dir='/lighttpd-optimized-by-decorator/') diff --git a/demo/demoproject/settings.py b/demo/demoproject/settings.py index 4066284..29bafb9 100755 --- a/demo/demoproject/settings.py +++ b/demo/demoproject/settings.py @@ -53,6 +53,7 @@ INSTALLED_APPS = ( 'demoproject.virtual', # Demo around VirtualDownloadView 'demoproject.nginx', # Sample optimizations for Nginx X-Accel. 'demoproject.apache', # Sample optimizations for Apache X-Sendfile. + 'demoproject.lighttpd', # Sample optimizations for Lighttpd X-Sendfile. # For test purposes. The demo project is part of django-downloadview # test suite. 'django_nose', @@ -74,6 +75,7 @@ MIDDLEWARE_CLASSES = [ DOWNLOADVIEW_BACKEND = 'django_downloadview.nginx.XAccelRedirectMiddleware' """Could also be: DOWNLOADVIEW_BACKEND = 'django_downloadview.apache.XSendfileMiddleware' +DOWNLOADVIEW_BACKEND = 'django_downloadview.lighttpd.XSendfileMiddleware' """ DOWNLOADVIEW_RULES = [ { @@ -89,6 +91,15 @@ DOWNLOADVIEW_RULES = [ # demonstrate usage of several backends. 'backend': 'django_downloadview.apache.XSendfileMiddleware', }, + { + 'source_url': '/media/lighttpd/', + 'destination_dir': '/lighttpd-optimized-by-middleware/', + # Bypass global default backend with additional argument "backend". + # Notice that in general use case, ``DOWNLOADVIEW_BACKEND`` should be + # enough. Here, the django_downloadview demo project needs to + # demonstrate usage of several backends. + 'backend': 'django_downloadview.lighttpd.XSendfileMiddleware', + }, ] diff --git a/demo/demoproject/urls.py b/demo/demoproject/urls.py index 154b9ff..78a48c8 100755 --- a/demo/demoproject/urls.py +++ b/demo/demoproject/urls.py @@ -35,6 +35,10 @@ urlpatterns = patterns( url(r'^apache/', include('demoproject.apache.urls', app_name='apache', namespace='apache')), + # Lighttpd optimizations. + url(r'^lighttpd/', include('demoproject.lighttpd.urls', + app_name='lighttpd', + namespace='lighttpd')), # An informative homepage. url(r'$', home, name='home') ) diff --git a/django_downloadview/lighttpd/__init__.py b/django_downloadview/lighttpd/__init__.py new file mode 100644 index 0000000..8328a20 --- /dev/null +++ b/django_downloadview/lighttpd/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +"""Optimizations for Lighttpd. + +See also `documentation of X-Sendfile for Lighttpd +`_ and +:doc:`narrative documentation about Lighttpd optimizations +`. + +""" +# API shortcuts. +from django_downloadview.lighttpd.decorators import x_sendfile # NoQA +from django_downloadview.lighttpd.response import XSendfileResponse # NoQA +from django_downloadview.lighttpd.tests import assert_x_sendfile # NoQA +from django_downloadview.lighttpd.middlewares import XSendfileMiddleware # NoQA diff --git a/django_downloadview/lighttpd/decorators.py b/django_downloadview/lighttpd/decorators.py new file mode 100644 index 0000000..25999ea --- /dev/null +++ b/django_downloadview/lighttpd/decorators.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +"""Decorators to apply Lighttpd X-Sendfile on a specific view.""" +from django_downloadview.decorators import DownloadDecorator +from django_downloadview.lighttpd.middlewares import XSendfileMiddleware + + +def x_sendfile(view_func, *args, **kwargs): + """Apply + :class:`~django_downloadview.lighttpd.middlewares.XSendfileMiddleware` to + ``view_func``. + + Proxies (``*args``, ``**kwargs``) to middleware constructor. + + """ + decorator = DownloadDecorator(XSendfileMiddleware) + return decorator(view_func, *args, **kwargs) diff --git a/django_downloadview/lighttpd/middlewares.py b/django_downloadview/lighttpd/middlewares.py new file mode 100644 index 0000000..c19e158 --- /dev/null +++ b/django_downloadview/lighttpd/middlewares.py @@ -0,0 +1,30 @@ +from django_downloadview.lighttpd.response import XSendfileResponse +from django_downloadview.middlewares import (ProxiedDownloadMiddleware, + NoRedirectionMatch) + + +class XSendfileMiddleware(ProxiedDownloadMiddleware): + """Configurable middleware, for use in decorators or in global middlewares. + + Standard Django middlewares are configured globally via settings. Instances + of this class are to be configured individually. It makes it possible to + use this class as the factory in + :py:class:`django_downloadview.decorators.DownloadDecorator`. + + """ + def __init__(self, source_dir=None, source_url=None, destination_dir=None): + """Constructor.""" + super(XSendfileMiddleware, self).__init__(source_dir, + source_url, + destination_dir) + + def process_download_response(self, request, response): + """Replace DownloadResponse instances by XSendfileResponse ones.""" + try: + redirect_url = self.get_redirect_url(response) + except NoRedirectionMatch: + return response + return XSendfileResponse(file_path=redirect_url, + content_type=response['Content-Type'], + basename=response.basename, + attachment=response.attachment) diff --git a/django_downloadview/lighttpd/response.py b/django_downloadview/lighttpd/response.py new file mode 100644 index 0000000..cfa64e1 --- /dev/null +++ b/django_downloadview/lighttpd/response.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +"""Lighttpd's specific responses.""" +import os.path + +from django_downloadview.response import (ProxiedDownloadResponse, + content_disposition) + + +class XSendfileResponse(ProxiedDownloadResponse): + "Delegates serving file to Lighttpd via X-Sendfile header." + def __init__(self, file_path, content_type, basename=None, + attachment=True): + """Return a HttpResponse with headers for Lighttpd X-Sendfile.""" + super(XSendfileResponse, self).__init__(content_type=content_type) + if attachment: + self.basename = basename or os.path.basename(file_path) + self['Content-Disposition'] = content_disposition(self.basename) + self['X-Sendfile'] = file_path diff --git a/django_downloadview/lighttpd/tests.py b/django_downloadview/lighttpd/tests.py new file mode 100644 index 0000000..751a2dc --- /dev/null +++ b/django_downloadview/lighttpd/tests.py @@ -0,0 +1,28 @@ +import django_downloadview.apache.tests +from django_downloadview.lighttpd.response import XSendfileResponse + + +class XSendfileValidator(django_downloadview.apache.tests.XSendfileValidator): + """Utility class to validate XSendfileResponse instances. + + See also :py:func:`assert_x_sendfile` shortcut function. + + """ + def assert_x_sendfile_response(self, test_case, response): + test_case.assertTrue(isinstance(response, XSendfileResponse)) + + +def assert_x_sendfile(test_case, response, **assertions): + """Make ``test_case`` assert that ``response`` is a XSendfileResponse. + + Optional ``assertions`` dictionary can be used to check additional items: + + * ``basename``: the basename of the file in the response. + + * ``content_type``: the value of "Content-Type" header. + + * ``file_path``: the value of "X-Sendfile" header. + + """ + validator = XSendfileValidator() + return validator(test_case, response, **assertions) diff --git a/django_downloadview/tests/api.py b/django_downloadview/tests/api.py index 1d146b7..4113043 100644 --- a/django_downloadview/tests/api.py +++ b/django_downloadview/tests/api.py @@ -70,6 +70,24 @@ class APITestCase(unittest.TestCase): 'assert_x_accel_redirect'] self.assert_module_attributes('django_downloadview.nginx', api) + def test_apache_attributes(self): + """Apache-related API is exposed in django_downloadview.apache.""" + api = [ + 'XSendfileResponse', + 'XSendfileMiddleware', + 'x_sendfile', + 'assert_x_sendfile'] + self.assert_module_attributes('django_downloadview.apache', api) + + def test_lighttpd_attributes(self): + """Lighttpd-related API is exposed in django_downloadview.lighttpd.""" + api = [ + 'XSendfileResponse', + 'XSendfileMiddleware', + 'x_sendfile', + 'assert_x_sendfile'] + self.assert_module_attributes('django_downloadview.lighttpd', api) + class DeprecatedAPITestCase(django.test.SimpleTestCase): """Make sure using deprecated items raise DeprecationWarning.""" diff --git a/docs/optimizations/apache.txt b/docs/optimizations/apache.txt index 77676bd..376787f 100644 --- a/docs/optimizations/apache.txt +++ b/docs/optimizations/apache.txt @@ -51,20 +51,20 @@ Example: .. literalinclude:: /../demo/demoproject/settings.py :language: python - :lines: 63-70 + :lines: 64-71 Then set ``django_downloadview.apache.XSendfileMiddleware`` as ``DOWNLOADVIEW_BACKEND``: .. literalinclude:: /../demo/demoproject/settings.py :language: python - :lines: 76 + :lines: 77 Then register as many ``DOWNLOADVIEW_RULES`` as you wish: .. literalinclude:: /../demo/demoproject/settings.py :language: python - :lines: 78, 79-82, 92 + :lines: 80, 85-87, 93, 103 Each item in ``DOWNLOADVIEW_RULES`` is a dictionary of keyword arguments passed to the middleware factory. In the example above, we capture responses by diff --git a/docs/optimizations/index.txt b/docs/optimizations/index.txt index 29e52fb..cd5e068 100644 --- a/docs/optimizations/index.txt +++ b/docs/optimizations/index.txt @@ -19,19 +19,19 @@ Supported features grid Supported features depend on backend. Given the file you want to stream, the backend may or may not be able to handle it: -+-----------------------+-------------------------+-------------------------+ -| View / File | :doc:`nginx` | :doc:`apache` | -+=======================+=========================+=========================+ -| :doc:`/views/path` | Yes, local filesystem. | Yes, local filesystem. | -+-----------------------+-------------------------+-------------------------+ -| :doc:`/views/storage` | Yes, local and remote. | Yes, local filesystem. | -+-----------------------+-------------------------+-------------------------+ -| :doc:`/views/object` | Yes, local and remote. | Yes, local filesystem. | -+-----------------------+-------------------------+-------------------------+ -| :doc:`/views/http` | Yes. | No. | -+-----------------------+-------------------------+-------------------------+ -| :doc:`/views/virtual` | No. | No. | -+-----------------------+-------------------------+-------------------------+ ++-----------------------+-------------------------+-------------------------+-------------------------+ +| View / File | :doc:`nginx` | :doc:`apache` | :doc:`lighttpd` | ++=======================+=========================+=========================+=========================+ +| :doc:`/views/path` | Yes, local filesystem. | Yes, local filesystem. | Yes, local filesystem. | ++-----------------------+-------------------------+-------------------------+-------------------------+ +| :doc:`/views/storage` | Yes, local and remote. | Yes, local filesystem. | Yes, local filesystem. | ++-----------------------+-------------------------+-------------------------+-------------------------+ +| :doc:`/views/object` | Yes, local and remote. | Yes, local filesystem. | Yes, local filesystem. | ++-----------------------+-------------------------+-------------------------+-------------------------+ +| :doc:`/views/http` | Yes. | No. | No. | ++-----------------------+-------------------------+-------------------------+-------------------------+ +| :doc:`/views/virtual` | No. | No. | No. | ++-----------------------+-------------------------+-------------------------+-------------------------+ As an example, :doc:`Nginx X-Accel ` handles URL for internal redirects, so it can manage @@ -77,6 +77,7 @@ Here are optimizations builtin `django_downloadview`: nginx apache + lighttpd .. note:: If you need support for additional optimizations, `tell us`_! diff --git a/docs/optimizations/lighttpd.txt b/docs/optimizations/lighttpd.txt new file mode 100644 index 0000000..f3d34f9 --- /dev/null +++ b/docs/optimizations/lighttpd.txt @@ -0,0 +1,140 @@ +######## +Lighttpd +######## + +If you serve Django behind `Lighttpd`, then you can delegate the file streaming +to `Lighttpd` and get increased performance: + +* lower resources used by Python/Django workers ; +* faster download. + +See `Lighttpd X-Sendfile documentation`_ for details. + +.. note:: + + Currently, `django_downloadview` supports ``X-Sendfile``, but not + ``X-Sendfile2``. If you need ``X-Sendfile2`` or know how to handle it, + check `X-Sendfile2 feature request on django_downloadview's bugtracker`_. + + +***************** +Known limitations +***************** + +* Lighttpd needs access to the resource by path on local filesystem. +* Thus only files that live on local filesystem can be streamed by Lighttpd. + + +************ +Given a view +************ + +Let's consider the following view: + +.. literalinclude:: /../demo/demoproject/lighttpd/views.py + :language: python + :lines: 1-6, 8-16 + +What is important here is that the files will have an ``url`` property +implemented by storage. Let's setup an optimization rule based on that URL. + +.. note:: + + It is generally easier to setup rules based on URL rather than based on + name in filesystem. This is because path is generally relative to storage, + whereas URL usually contains some storage identifier, i.e. it is easier to + target a specific location by URL rather than by filesystem name. + + +*************************** +Setup XSendfile middlewares +*************************** + +Make sure ``django_downloadview.SmartDownloadMiddleware`` is in +``MIDDLEWARE_CLASSES`` of your `Django` settings. + +Example: + +.. literalinclude:: /../demo/demoproject/settings.py + :language: python + :lines: 64-71 + +Then set ``django_downloadview.lighttpd.XSendfileMiddleware`` as +``DOWNLOADVIEW_BACKEND``: + +.. literalinclude:: /../demo/demoproject/settings.py + :language: python + :lines: 78 + +Then register as many ``DOWNLOADVIEW_RULES`` as you wish: + +.. literalinclude:: /../demo/demoproject/settings.py + :language: python + :lines: 80, 94-96, 102, 103 + +Each item in ``DOWNLOADVIEW_RULES`` is a dictionary of keyword arguments passed +to the middleware factory. In the example above, we capture responses by +``source_url`` and convert them to internal redirects to ``destination_dir``. + +.. autoclass:: django_downloadview.lighttpd.middlewares.XSendfileMiddleware + :members: + :inherited-members: + :undoc-members: + :show-inheritance: + :member-order: bysource + + +**************************************** +Per-view setup with x_sendfile decorator +**************************************** + +Middlewares should be enough for most use cases, but you may want per-view +configuration. For `Lighttpd`, there is ``x_sendfile``: + +.. autofunction:: django_downloadview.lighttpd.decorators.x_sendfile + +As an example: + +.. literalinclude:: /../demo/demoproject/lighttpd/views.py + :language: python + :lines: 1-7, 17- + + +************************************* +Test responses with assert_x_sendfile +************************************* + +Use :func:`~django_downloadview.lighttpd.decorators.assert_x_sendfile` +function as a shortcut in your tests. + +.. literalinclude:: /../demo/demoproject/lighttpd/tests.py + :language: python + +.. autofunction:: django_downloadview.lighttpd.tests.assert_x_sendfile + +The tests above assert the `Django` part is OK. Now let's configure `Lighttpd`. + + +************** +Setup Lighttpd +************** + +See `Lighttpd X-Sendfile documentation`_ for details. + + +********************************************* +Assert everything goes fine with healthchecks +********************************************* + +:doc:`Healthchecks ` are the best way to check the complete +setup. + + +.. rubric:: References + +.. target-notes:: + +.. _`Lighttpd X-Sendfile documentation`: + http://redmine.lighttpd.net/projects/lighttpd/wiki/X-LIGHTTPD-send-file +.. _`X-Sendfile2 feature request on django_downloadview's bugtracker`: + https://github.com/benoitbryon/django-downloadview/issues/67 diff --git a/docs/optimizations/nginx.txt b/docs/optimizations/nginx.txt index a704093..05243fc 100644 --- a/docs/optimizations/nginx.txt +++ b/docs/optimizations/nginx.txt @@ -52,20 +52,20 @@ Example: .. literalinclude:: /../demo/demoproject/settings.py :language: python - :lines: 63-70 + :lines: 64-71 Then set ``django_downloadview.nginx.XAccelRedirectMiddleware`` as ``DOWNLOADVIEW_BACKEND``: .. literalinclude:: /../demo/demoproject/settings.py :language: python - :lines: 74 + :lines: 75 Then register as many ``DOWNLOADVIEW_RULES`` as you wish: .. literalinclude:: /../demo/demoproject/settings.py :language: python - :lines: 78, 79-82, 92 + :lines: 80, 81-84, 103 Each item in ``DOWNLOADVIEW_RULES`` is a dictionary of keyword arguments passed to the middleware factory. In the example above, we capture responses by diff --git a/docs/settings.txt b/docs/settings.txt index 2c06c21..e7d5b88 100644 --- a/docs/settings.txt +++ b/docs/settings.txt @@ -26,7 +26,7 @@ Example: .. literalinclude:: /../demo/demoproject/settings.py :language: python - :lines: 63-70 + :lines: 64-71 ******************** @@ -43,7 +43,7 @@ Example: .. literalinclude:: /../demo/demoproject/settings.py :language: python - :lines: 74 + :lines: 75 See :doc:`/optimizations/index` for a list of available backends (middlewares). @@ -69,7 +69,7 @@ Here is an example containing one rule using keyword arguments: .. literalinclude:: /../demo/demoproject/settings.py :language: python - :lines: 78, 79-82, 92 + :lines: 80, 81-84, 103 See :doc:`/optimizations/index` for details about builtin backends (middlewares) and their options.