Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,4 @@ Clinton Blackburn <cblackburn@edx.org>
Dennis Jen <djen@edx.org>
Filippo Valsorda <hi@filippo.io>
Ivica Ceraj <ceraj@mit.edu>
Marceau Cnudde <marceau.cnudde@gmail.com>
43 changes: 42 additions & 1 deletion common/djangoapps/contentserver/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,48 @@ def process_request(self, request):
if if_modified_since == last_modified_at_str:
return HttpResponseNotModified()

response = HttpResponse(content.stream_data(), content_type=content.content_type)
# *** File streaming within a byte range ***
# If a Range is provided, parse Range attribute of the request
# Add Content-Range in the response if Range is structurally correct
# Request -> Range attribute structure: "Range: bytes=first-[last]"
# Response -> Content-Range attribute structure: "Content-Range: bytes first-last/totalLength"
response = None
if request.META.get('HTTP_RANGE'):
range_header = request.META['HTTP_RANGE']
if '=' in range_header:
unit, range = range_header.split('=')
if unit == 'bytes' and '-' in range:
first, last = range.split('-')
try:
first = int(first)
except ValueError:
first = 0
try:
last = int(last)
except ValueError:
last = content.length - 1

if 0 <= first <= last < content.length:
# Valid Range attribute
response = HttpResponse(content.stream_data_in_range(first, last))
response['Content-Range'] = 'bytes {first}-{last}/{length}'.format(
first=first, last=last, length=content.length
)
response['Content-Length'] = str(last - first + 1)
response.status_code = 206 # HTTP_206_PARTIAL_CONTENT
if not response:
# Malformed Range attribute
response = HttpResponse()
response.status_code = 400 # HTTP_400_BAD_REQUEST
return response

else:
# No Range attribute
response = HttpResponse(content.stream_data())
response['Content-Length'] = content.length

response['Accept-Ranges'] = 'bytes'
response['Content-Type'] = content.content_type
response['Last-Modified'] = last_modified_at_str

return response
75 changes: 73 additions & 2 deletions common/djangoapps/contentserver/tests/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import copy
import logging
from uuid import uuid4
from mock import patch

from django.conf import settings
from django.test.client import Client
Expand Down Expand Up @@ -48,12 +49,12 @@ def setUp(self):
# A locked asset
self.locked_asset = self.course_key.make_asset_key('asset', 'sample_static.txt')
self.url_locked = self.locked_asset.to_deprecated_string()
self.contentstore.set_attr(self.locked_asset, 'locked', True)

# An unlocked asset
self.unlocked_asset = self.course_key.make_asset_key('asset', 'another_static.txt')
self.url_unlocked = self.unlocked_asset.to_deprecated_string()

self.contentstore.set_attr(self.locked_asset, 'locked', True)
self.length_unlocked = self.contentstore.get_attr(self.unlocked_asset, 'length')

def test_unlocked_asset(self):
"""
Expand Down Expand Up @@ -101,3 +102,73 @@ def test_locked_asset_staff(self):
resp = self.client.get(self.url_locked)
self.assertEqual(resp.status_code, 200) # pylint: disable=E1103

@patch("common.djangoapps.contentserver.middleware.contentstore")
def test_range_request_full_file(self, mock_contentstore):
"""
Test that a range request from byte 0 to last outputs partial content status code and valid Content-Range.
"""
mock_content = mock_contentstore.return_value.find.return_value
mock_content.length = 5

resp = self.client.get(self.url_unlocked, HTTP_RANGE='bytes=0-')
self.assertEqual(resp.status_code, 206) # HTTP_206_PARTIAL_CONTENT
self.assertEqual(resp['Content-Range'], 'bytes {first}-{last}/{length}'.format(
first=0, last=self.length_unlocked-1, length=self.length_unlocked
)
)

def test_range_request_partial_file(self):
"""
Test that a range request for a partial file outputs partial content status code and valid Content-Range.
firstByte and lastByte are chosen to be simple but non trivial values.
"""
firstByte = self.length_unlocked / 4
lastByte = self.length_unlocked / 2
resp = self.client.get(self.url_unlocked, HTTP_RANGE='bytes={first}-{last}'.format(
first=firstByte, last=lastByte
)
)
self.assertEqual(resp.status_code, 206) # HTTP_206_PARTIAL_CONTENT
self.assertEqual(resp['Content-Range'], 'bytes {first}-{last}/{length}'.format(
first=firstByte, last=lastByte, length=lastByte - firstByte + 1
)
)

def test_range_request_malformed_missing_equal(self):
"""
Test that a range request with malformed Range (missing '=') outputs status 400.
"""
resp = self.client.get(self.url_unlocked, HTTP_RANGE='bytes 0-')
self.assertEqual(resp.status_code, 400) # HTTP_400_BAD_REQUEST


def test_range_request_malformed_missing_minus(self):
"""
Test that a range request with malformed Range (missing '-') outputs status 400.
"""
resp = self.client.get(self.url_unlocked, HTTP_RANGE='bytes=0')
self.assertEqual(resp.status_code, 400) # HTTP_400_BAD_REQUEST

def test_range_request_malformed_invalid_range(self):
"""
Test that a range request with malformed Range (firstByte > lastByte) outputs status 400.
"""
firstByte = self.length_unlocked / 2
lastByte = self.length_unlocked / 4
resp = self.client.get(self.url_unlocked, HTTP_RANGE='bytes={first}-{last}'.format(
first=firstByte, last=lastByte
)
)
self.assertEqual(resp.status_code, 400) # HTTP_400_BAD_REQUEST

def test_range_request_malformed_out_of_bounds(self):
"""
Test that a range request with malformed Range (lastByte == totalLength, offset by 1 error) outputs status 400.
"""
lastByte = self.length_unlocked
resp = self.client.get(self.url_unlocked, HTTP_RANGE='bytes=0-{last}'.format(
last=lastByte
)
)
self.assertEqual(resp.status_code, 400) # HTTP_400_BAD_REQUEST

19 changes: 18 additions & 1 deletion common/lib/xmodule/xmodule/contentstore/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

XASSET_THUMBNAIL_TAIL_NAME = '.jpg'

STREAM_DATA_CHUNK_SIZE = 1024

import os
import logging
import StringIO
Expand Down Expand Up @@ -162,11 +164,26 @@ def __init__(self, loc, name, content_type, stream, last_modified_at=None, thumb

def stream_data(self):
while True:
chunk = self._stream.read(1024)
chunk = self._stream.read(STREAM_DATA_CHUNK_SIZE)
if len(chunk) == 0:
break
yield chunk

def stream_data_in_range(self, first_byte, last_byte):
"""
Stream the data between first_byte and last_byte (included)
"""
self._stream.seek(first_byte)
position = first_byte
while True:
if last_byte < position + STREAM_DATA_CHUNK_SIZE - 1:
chunk = self._stream.read(last_byte - position + 1)
yield chunk
break
chunk = self._stream.read(STREAM_DATA_CHUNK_SIZE)
position += STREAM_DATA_CHUNK_SIZE
yield chunk

def close(self):
self._stream.close()

Expand Down