Ticket #5791: 5791.5.diff

File 5791.5.diff, 14.0 KB (added by isagalaev, 7 years ago)

Patch with proper etag parsing and quoting

  • django/views/decorators/http.py

    === modified file 'django/views/decorators/http.py'
     
    77except ImportError:
    88    from django.utils.functional import wraps  # Python 2.3, 2.4 fallback.
    99
     10import re
     11from calendar import timegm
     12from datetime import timedelta
     13from email.Utils import formatdate
    1014from django.utils.decorators import decorator_from_middleware
    1115from django.middleware.http import ConditionalGetMiddleware
    12 from django.http import HttpResponseNotAllowed
     16from django.http import HttpResponseNotAllowed, HttpResponseNotModified
    1317
    1418conditional_page = decorator_from_middleware(ConditionalGetMiddleware)
    1519
     
    3640require_GET.__doc__ = "Decorator to require that a view only accept the GET method."
    3741
    3842require_POST = require_http_methods(["POST"])
    39 require_POST.__doc__ = "Decorator to require that a view only accept the POST method."
    40  No newline at end of file
     43require_POST.__doc__ = "Decorator to require that a view only accept the POST method."
     44
     45def _parse_etags(etag_str):
     46    """
     47    Parses a string with one or several etags passed in If-None-Match and
     48    If-Match headers by the rules in RFC 2616. Returns a list of etags
     49    without surrounding double quotes (") and unescaped from \<CHAR>.
     50    """
     51    etags = re.findall(r'(?:W/)?"((?:\\.|[^"])*)"', etag_str)
     52    if not etags:
     53        # etag_str has wrong format, treat it as an opaque string then
     54        return [etag_str]
     55    etags = [e.decode('string_escape') for e in etags]
     56    return etags
     57
     58def _quote_etag(etag):
     59    '''
     60    Wraps a string in double quotes escaping contents as necesary.
     61    '''
     62    return '"%s"' % etag.replace('\\', '\\\\').replace('"', '\\"')
     63
     64def condition(etag=None, last_modified=None):
     65    """
     66    Decorator to support conditional get for a view.  It takes as parameters
     67    user-defined functions that calculate etag and/or last modified time.
     68
     69    Both functions are passed the same parameters as the view itself. "last_modified"
     70    should return a standard datetime value and "etag" should return a string.
     71
     72    Example usage with last_modified::
     73
     74        @condition(last_modified=lambda r, obj_id: MyObject.objects.get(pk=obj_id).update_time)
     75        def my_object_view(request, obj_id):
     76            # ...
     77
     78    You can pass last_modified, etag or both of them (if you really need it).
     79    """
     80    def decorator(func):
     81        def inner(request, *args, **kwargs):
     82            if request.method not in ('GET', 'HEAD'):
     83                return func(request, *args, **kwargs)
     84
     85            # Get HTTP request headers
     86            if_modified_since = request.META.get('HTTP_IF_MODIFIED_SINCE', None)
     87            if_none_match = request.META.get('HTTP_IF_NONE_MATCH', None)
     88            if if_none_match:
     89                if_none_match = _parse_etags(if_none_match)
     90
     91            # Get and convert user-defined values
     92            if last_modified is not None:
     93                dt = last_modified(request, *args, **kwargs)
     94                last_modified_value = dt and (formatdate(timegm(dt.utctimetuple()))[:26] + 'GMT')
     95            else:
     96                last_modified_value = None
     97
     98            if etag is not None:
     99                etag_value = etag(request, *args, **kwargs)
     100            else:
     101                etag_value = None
     102
     103            # Calculate "not modified" condition
     104            not_modified = (if_modified_since or if_none_match) and \
     105                           (not if_modified_since or last_modified_value == if_modified_since) and \
     106                           (not if_none_match or etag_value in if_none_match)
     107
     108            # Create appropriate response
     109            if not_modified:
     110                response = HttpResponseNotModified()
     111            else:
     112                response = func(request, *args, **kwargs)
     113
     114            # Set relevant headers for response
     115            if last_modified_value and not response.has_header('Last-Modified'):
     116                response['Last-Modified'] = last_modified_value
     117            if etag_value and not response.has_header('ETag'):
     118                response['ETag'] = _quote_etag(etag_value)
     119
     120            return response
     121        return inner
     122    return decorator
     123
     124# Shortcut decorators for common cases based on ETag or Last-Modified only
     125def etag(callable):
     126    return condition(etag=callable)
     127
     128def last_modified(callable):
     129    return condition(last_modified=callable)
  • docs/index.txt

    === modified file 'docs/index.txt'
     
    8181    * :ref:`Admin site <ref-contrib-admin>`
    8282    * :ref:`Authentication <topics-auth>`
    8383    * :ref:`Cache system <topics-cache>`
     84    * :ref:`Conditional get <topics-conditional-get>`
    8485    * :ref:`Comments <ref-contrib-comments-index>`
    8586    * :ref:`Content types <ref-contrib-contenttypes>`
    8687    * :ref:`Cross Site Request Forgery protection <ref-contrib-csrf>`
  • docs/topics/conditional-get.txt

    === added file 'docs/topics/conditional-get.txt'
     
     1========================
     2Per-view conditional get
     3========================
     4
     5`Conditional get`_ is a feature of HTTP that allows you to omit sending a response
     6if the client signals in a request that it has a cached copy of reponse. To control
     7whether the cached copy is expired the client may use time of its last modification
     8or an "entity tag" - a short value that changes whenever the response itself changes
     9(typically it's simply a hash over the response).
     10
     11.. _`Conditional get`: http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3
     12
     13Django allows simple usage of this feature with ``ConditionalGetMiddleware`` and
     14``CommonMiddleware`` (see `middleware documentation`_ for their usage). But, while
     15simple, they both have limitations:
     16
     17* they are applied gloablly to all views in your project
     18* they don't spare you from generating the response itself, which may be expensive
     19
     20.. _`middleware documentation`: ../middleware/
     21
     22Decorators
     23==========
     24
     25When you need more fine-grained control you may use per-view conditional get
     26decorators.
     27
     28Decorators ``etag`` and ``last_modified`` accept a user-defined function that
     29takes the same parameters as the view itself. ``last_modified`` should return a
     30standard datetime value and ``etag`` should return a string (you don't need to
     31wrap it in double quotes, Django will escape the string for you).
     32
     33Example usage of ``last_modified``::
     34
     35    @last_modified(lambda r, obj_id: MyObject.objects.get(pk=obj_id).update_time)
     36    def my_object_view(request, obj_id):
     37        # Expensive generation of response with MyObject instance
     38
     39In this example we're taking advantage of the fact that MyObject instance stores
     40the time when it was last updated in a field.
     41
     42Usage of ``etag`` is similar.
     43
     44HTTP allows to use both "ETag" and "Last-Modified" headers in your response. Then
     45a response is considered not modified only if the client sends both headers back and
     46they're both equal to the response headers. This means that you can't just chain
     47decorators on your view:
     48
     49    @etag(etag_func)
     50    @last_modified(last_modified_func)
     51    def my_view(request):
     52        # ...
     53
     54Because the first decorator doesn't know anything about the second and can answer
     55that the response is not modified even if the second decorators would think otherwise.
     56In this case you should use a more general decorator - ``condition`` that accepts
     57two functions at once:
     58
     59    @condition(etag_func, last_modified_func)
     60    def my_view(request):
     61        # ...
  • tests/regressiontests/conditional_get/__init__.py

    === added directory 'tests/regressiontests/conditional_get'
    === added file 'tests/regressiontests/conditional_get/__init__.py'
     
     1# -*- coding:utf-8 -*-
  • tests/regressiontests/conditional_get/models.py

    === added file 'tests/regressiontests/conditional_get/models.py'
     
     1# -*- coding:utf-8 -*-
     2from datetime import datetime, timedelta
     3from calendar import timegm
     4from django.test import TestCase
     5
     6from django.views.decorators.http import _parse_etags, _quote_etag
     7
     8FULL_RESPONSE = 'Test conditional get response'
     9LAST_MODIFIED = datetime(2007, 10, 21, 23, 21, 47)
     10LAST_MODIFIED_STR = 'Sun, 21 Oct 2007 23:21:47 GMT'
     11EXPIRED_LAST_MODIFIED_STR = 'Sat, 20 Oct 2007 23:21:47 GMT'
     12ETAG = 'b4246ffc4f62314ca13147c9d4f76974'
     13EXPIRED_ETAG = '7fae4cd4b0f81e7d2914700043aa8ed6'
     14
     15class ConditionalGet(TestCase):
     16    def assertFullResponse(self, response, check_last_modified=True, check_etag=True):
     17        self.assertEquals(response.status_code, 200)
     18        self.assertEquals(response.content, FULL_RESPONSE)
     19        if check_last_modified:
     20            self.assertEquals(response['Last-Modified'], LAST_MODIFIED_STR)
     21        if check_etag:
     22            self.assertEquals(response['ETag'], '"%s"' % ETAG)
     23
     24    def assertNotModified(self, response):
     25        self.assertEquals(response.status_code, 304)
     26        self.assertEquals(response.content, '')
     27
     28    def testWithoutConditions(self):
     29        response = self.client.get('/condition/')
     30        self.assertFullResponse(response)
     31
     32    def testIfModifiedSince(self):
     33        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
     34        response = self.client.get('/condition/')
     35        self.assertNotModified(response)
     36        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
     37        response = self.client.get('/condition/')
     38        self.assertFullResponse(response)
     39
     40    def testIfNoneMatch(self):
     41        self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % ETAG
     42        response = self.client.get('/condition/')
     43        self.assertNotModified(response)
     44        self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % EXPIRED_ETAG
     45        response = self.client.get('/condition/')
     46        self.assertFullResponse(response)
     47
     48        # Several etags in If-None-Match is a bit exotic but why not?
     49        self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s", "%s"' % (ETAG, EXPIRED_ETAG)
     50        response = self.client.get('/condition/')
     51        self.assertNotModified(response)
     52
     53    def testBothHeaders(self):
     54        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
     55        self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % ETAG
     56        response = self.client.get('/condition/')
     57        self.assertNotModified(response)
     58        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
     59        self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % ETAG
     60        response = self.client.get('/condition/')
     61        self.assertFullResponse(response)
     62        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
     63        self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % EXPIRED_ETAG
     64        response = self.client.get('/condition/')
     65        self.assertFullResponse(response)
     66
     67    def testSingleConditions(self):
     68        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
     69        response = self.client.get('/condition/last_modified/')
     70        self.assertNotModified(response)
     71        response = self.client.get('/condition/etag/')
     72        self.assertFullResponse(response, check_last_modified=False)
     73
     74        del self.client.defaults['HTTP_IF_MODIFIED_SINCE']
     75        self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % ETAG
     76        response = self.client.get('/condition/etag/')
     77        self.assertNotModified(response)
     78        response = self.client.get('/condition/last_modified/')
     79        self.assertFullResponse(response, check_etag=False)
     80
     81        self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
     82        self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % EXPIRED_ETAG
     83        response = self.client.get('/condition/last_modified/')
     84        self.assertFullResponse(response, check_etag=False)
     85        response = self.client.get('/condition/etag/')
     86        self.assertFullResponse(response, check_last_modified=False)
     87
     88
     89class ETagProcesing(TestCase):
     90    def testParsing(self):
     91        etags = _parse_etags(r'"", "etag", "e\"t\"ag", "e\\tag", W/"weak"')
     92        self.assertEquals(etags, ['', 'etag', 'e"t"ag', r'e\tag', 'weak'])
     93
     94    def testQuoting(self):
     95        quoted_etag = _quote_etag(r'e\t"ag')
     96        self.assertEquals(quoted_etag, r'"e\\t\"ag"')
  • tests/regressiontests/conditional_get/urls.py

    === added file 'tests/regressiontests/conditional_get/urls.py'
     
     1from django.conf.urls.defaults import *
     2import views
     3
     4urlpatterns = patterns('',
     5    ('^$', views.index),
     6    ('^last_modified/$', views.last_modified),
     7    ('^etag/$', views.etag),
     8)
  • tests/regressiontests/conditional_get/views.py

    === added file 'tests/regressiontests/conditional_get/views.py'
     
     1# -*- coding:utf-8 -*-
     2from django.views.decorators.http import condition
     3from django.http import HttpResponse
     4
     5from models import FULL_RESPONSE, LAST_MODIFIED, ETAG
     6
     7@condition(lambda r: ETAG, lambda r: LAST_MODIFIED)
     8def index(request):
     9    return HttpResponse(FULL_RESPONSE)
     10
     11@condition(last_modified=lambda r: LAST_MODIFIED)
     12def last_modified(request):
     13    return HttpResponse(FULL_RESPONSE)
     14
     15@condition(etag=lambda r: ETAG)
     16def etag(request):
     17    return HttpResponse(FULL_RESPONSE)
     18 No newline at end of file
  • tests/urls.py

    === modified file 'tests/urls.py'
     
    2929
    3030    # test urlconf for syndication tests
    3131    (r'^syndication/', include('regressiontests.syndication.urls')),
     32
     33    # conditional get views
     34    (r'condition/', include('regressiontests.conditional_get.urls')),
    3235)
Back to Top