[1.4.x] Fixed #24158 -- Allowed GZipMiddleware to work with streaming responses

Backport of django.utils.text.compress_sequence and fix for
django.middleware.gzip.GZipMiddleware when using iterators as
response.content.
This commit is contained in:
Benjamin Richter 2015-01-25 23:22:46 +01:00 committed by Tim Graham
parent 9435474068
commit 1e39d0f628
5 changed files with 80 additions and 8 deletions

View File

@ -1,6 +1,6 @@
import re import re
from django.utils.text import compress_string from django.utils.text import compress_string, compress_sequence
from django.utils.cache import patch_vary_headers from django.utils.cache import patch_vary_headers
re_accepts_gzip = re.compile(r'\bgzip\b') re_accepts_gzip = re.compile(r'\bgzip\b')
@ -12,8 +12,9 @@ class GZipMiddleware(object):
on the Accept-Encoding header. on the Accept-Encoding header.
""" """
def process_response(self, request, response): def process_response(self, request, response):
# The response object can tell us whether content is a string or an iterable
# It's not worth attempting to compress really short responses. # It's not worth attempting to compress really short responses.
if len(response.content) < 200: if not response._base_content_is_iter and len(response.content) < 200:
return response return response
patch_vary_headers(response, ('Accept-Encoding',)) patch_vary_headers(response, ('Accept-Encoding',))
@ -32,15 +33,23 @@ class GZipMiddleware(object):
if not re_accepts_gzip.search(ae): if not re_accepts_gzip.search(ae):
return response return response
# Return the compressed content only if it's actually shorter. # The response object can tell us whether content is a string or an iterable
compressed_content = compress_string(response.content) if response._base_content_is_iter:
if len(compressed_content) >= len(response.content): # If the response content is iterable we don't know the length, so delete the header.
return response del response['Content-Length']
# Wrap the response content in a streaming gzip iterator (direct access to inner response._container)
response.content = compress_sequence(response._container)
else:
# Return the compressed content only if it's actually shorter.
compressed_content = compress_string(response.content)
if len(compressed_content) >= len(response.content):
return response
response.content = compressed_content
response['Content-Length'] = str(len(response.content))
if response.has_header('ETag'): if response.has_header('ETag'):
response['ETag'] = re.sub('"$', ';gzip"', response['ETag']) response['ETag'] = re.sub('"$', ';gzip"', response['ETag'])
response.content = compressed_content
response['Content-Encoding'] = 'gzip' response['Content-Encoding'] = 'gzip'
response['Content-Length'] = str(len(response.content))
return response return response

View File

@ -286,6 +286,39 @@ def compress_string(s):
ustring_re = re.compile(u"([\u0080-\uffff])") ustring_re = re.compile(u"([\u0080-\uffff])")
# Backported from django 1.5
class StreamingBuffer(object):
def __init__(self):
self.vals = []
def write(self, val):
self.vals.append(val)
def read(self):
ret = ''.join(self.vals)
self.vals = []
return ret
def flush(self):
return
def close(self):
return
# Backported from django 1.5
# Like compress_string, but for iterators of strings.
def compress_sequence(sequence):
buf = StreamingBuffer()
zfile = GzipFile(mode='wb', compresslevel=6, fileobj=buf)
# Output headers...
yield buf.read()
for item in sequence:
zfile.write(item)
zfile.flush()
yield buf.read()
zfile.close()
yield buf.read()
def javascript_quote(s, quote_double_quotes=False): def javascript_quote(s, quote_double_quotes=False):
def fix(match): def fix(match):

16
docs/releases/1.4.19.txt Normal file
View File

@ -0,0 +1,16 @@
===========================
Django 1.4.19 release notes
===========================
*Under development*
Django 1.4.19 fixes a regression in the 1.4.18 security release.
Bugfixes
========
* ``GZipMiddleware`` now supports streaming responses. As part of the 1.4.18
security release, the ``django.views.static.serve()`` function was altered
to stream the files it serves. Unfortunately, the ``GZipMiddleware`` consumed
the stream prematurely and prevented files from being served properly
(`#24158 <http://code.djangoproject.com/ticket/24158>`_).

View File

@ -19,6 +19,7 @@ Final releases
.. toctree:: .. toctree::
:maxdepth: 1 :maxdepth: 1
1.4.19
1.4.18 1.4.18
1.4.17 1.4.17
1.4.16 1.4.16

View File

@ -514,6 +514,7 @@ class GZipMiddlewareTest(TestCase):
short_string = "This string is too short to be worth compressing." short_string = "This string is too short to be worth compressing."
compressible_string = 'a' * 500 compressible_string = 'a' * 500
uncompressible_string = ''.join(chr(random.randint(0, 255)) for _ in xrange(500)) uncompressible_string = ''.join(chr(random.randint(0, 255)) for _ in xrange(500))
iterator_as_content = iter(compressible_string)
def setUp(self): def setUp(self):
self.req = HttpRequest() self.req = HttpRequest()
@ -589,6 +590,18 @@ class GZipMiddlewareTest(TestCase):
self.assertEqual(r.content, self.uncompressible_string) self.assertEqual(r.content, self.uncompressible_string)
self.assertEqual(r.get('Content-Encoding'), None) self.assertEqual(r.get('Content-Encoding'), None)
def test_streaming_compression(self):
"""
Tests that iterators as response content return a compressed stream without consuming
the whole response.content while doing so.
See #24158.
"""
self.resp.content = self.iterator_as_content
r = GZipMiddleware().process_response(self.req, self.resp)
self.assertEqual(self.decompress(''.join(r.content)), self.compressible_string)
self.assertEqual(r.get('Content-Encoding'), 'gzip')
self.assertEqual(r.get('Content-Length'), None)
class ETagGZipMiddlewareTest(TestCase): class ETagGZipMiddlewareTest(TestCase):
""" """