Commit 257dcc54 by J. Cliff Dyer Committed by Nimisha Asthagiri

Accept raw image data as Content-type: image/*

MA-1416

Created a TypedFileUploadParser that validates the mimetype and then
takes the content and puts it on request.FILES['file'].  Subclasses the
existing FileUploadParser.

Use namedtuple in IMAGE_TYPES as per style guide:
https://github.com/edx/edx-platform/wiki/Python-Guidelines#classes-vs-dictionaries
parent 7fe813e6
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
Image file manipulation functions related to profile images. Image file manipulation functions related to profile images.
""" """
from cStringIO import StringIO from cStringIO import StringIO
from collections import namedtuple
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
...@@ -11,22 +12,24 @@ from PIL import Image ...@@ -11,22 +12,24 @@ from PIL import Image
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage
ImageType = namedtuple('ImageType', ('extensions', 'mimetypes', 'magic'))
IMAGE_TYPES = { IMAGE_TYPES = {
'jpeg': { 'jpeg': ImageType(
'extension': [".jpeg", ".jpg"], extensions=['.jpeg', '.jpg'],
'mimetypes': ['image/jpeg', 'image/pjpeg'], mimetypes=['image/jpeg', 'image/pjpeg'],
'magic': ["ffd8"] magic=['ffd8'],
}, ),
'png': { 'png': ImageType(
'extension': [".png"], extensions=[".png"],
'mimetypes': ['image/png'], mimetypes=['image/png'],
'magic': ["89504e470d0a1a0a"] magic=["89504e470d0a1a0a"],
}, ),
'gif': { 'gif': ImageType(
'extension': [".gif"], extensions=[".gif"],
'mimetypes': ['image/gif'], mimetypes=['image/gif'],
'magic': ["474946383961", "474946383761"] magic=["474946383961", "474946383761"],
} ),
} }
...@@ -52,7 +55,7 @@ def get_valid_file_types(): ...@@ -52,7 +55,7 @@ def get_valid_file_types():
""" """
Return comma separated string of valid file types. Return comma separated string of valid file types.
""" """
return ', '.join([', '.join(IMAGE_TYPES[ft]['extension']) for ft in IMAGE_TYPES.keys()]) return ', '.join([', '.join(IMAGE_TYPES[ft].extensions) for ft in IMAGE_TYPES.keys()])
FILE_UPLOAD_TOO_LARGE = _noop(u'The file must be smaller than {image_max_size} in size.'.format(image_max_size=user_friendly_size(settings.PROFILE_IMAGE_MAX_BYTES))) # pylint: disable=line-too-long FILE_UPLOAD_TOO_LARGE = _noop(u'The file must be smaller than {image_max_size} in size.'.format(image_max_size=user_friendly_size(settings.PROFILE_IMAGE_MAX_BYTES))) # pylint: disable=line-too-long
...@@ -93,17 +96,17 @@ def validate_uploaded_image(uploaded_file): ...@@ -93,17 +96,17 @@ def validate_uploaded_image(uploaded_file):
# check the file extension looks acceptable # check the file extension looks acceptable
filename = unicode(uploaded_file.name).lower() filename = unicode(uploaded_file.name).lower()
filetype = [ft for ft in IMAGE_TYPES if any(filename.endswith(ext) for ext in IMAGE_TYPES[ft]['extension'])] filetype = [ft for ft in IMAGE_TYPES if any(filename.endswith(ext) for ext in IMAGE_TYPES[ft].extensions)]
if not filetype: if not filetype:
raise ImageValidationError(FILE_UPLOAD_BAD_TYPE) raise ImageValidationError(FILE_UPLOAD_BAD_TYPE)
filetype = filetype[0] filetype = filetype[0]
# check mimetype matches expected file type # check mimetype matches expected file type
if uploaded_file.content_type not in IMAGE_TYPES[filetype]['mimetypes']: if uploaded_file.content_type not in IMAGE_TYPES[filetype].mimetypes:
raise ImageValidationError(FILE_UPLOAD_BAD_MIMETYPE) raise ImageValidationError(FILE_UPLOAD_BAD_MIMETYPE)
# check magic number matches expected file type # check magic number matches expected file type
headers = IMAGE_TYPES[filetype]['magic'] headers = IMAGE_TYPES[filetype].magic
if uploaded_file.read(len(headers[0]) / 2).encode('hex') not in headers: if uploaded_file.read(len(headers[0]) / 2).encode('hex') not in headers:
raise ImageValidationError(FILE_UPLOAD_BAD_EXT) raise ImageValidationError(FILE_UPLOAD_BAD_EXT)
# avoid unexpected errors from subsequent modules expecting the fp to be at 0 # avoid unexpected errors from subsequent modules expecting the fp to be at 0
......
...@@ -10,6 +10,7 @@ from django.conf import settings ...@@ -10,6 +10,7 @@ from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponse from django.http import HttpResponse
import ddt
import mock import mock
from mock import patch from mock import patch
from PIL import Image from PIL import Image
...@@ -17,12 +18,12 @@ from rest_framework.test import APITestCase, APIClient ...@@ -17,12 +18,12 @@ from rest_framework.test import APITestCase, APIClient
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from student.tests.tests import UserSettingsEventTestMixin from student.tests.tests import UserSettingsEventTestMixin
from openedx.core.djangoapps.user_api.accounts.image_helpers import (
from ...user_api.accounts.image_helpers import (
set_has_profile_image, set_has_profile_image,
get_profile_image_names, get_profile_image_names,
get_profile_image_storage, get_profile_image_storage,
) )
from ..images import create_profile_images, ImageValidationError from ..images import create_profile_images, ImageValidationError
from ..views import LOG_MESSAGE_CREATE, LOG_MESSAGE_DELETE from ..views import LOG_MESSAGE_CREATE, LOG_MESSAGE_DELETE
from .helpers import make_image_file from .helpers import make_image_file
...@@ -169,6 +170,7 @@ class ProfileImageViewGeneralTestCase(ProfileImageEndpointMixin, APITestCase): ...@@ -169,6 +170,7 @@ class ProfileImageViewGeneralTestCase(ProfileImageEndpointMixin, APITestCase):
self.assert_no_events_were_emitted() self.assert_no_events_were_emitted()
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Profile Image API is only supported in LMS') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Profile Image API is only supported in LMS')
@mock.patch('openedx.core.djangoapps.profile_images.views.log') @mock.patch('openedx.core.djangoapps.profile_images.views.log')
class ProfileImageViewPostTestCase(ProfileImageEndpointMixin, APITestCase): class ProfileImageViewPostTestCase(ProfileImageEndpointMixin, APITestCase):
...@@ -196,15 +198,16 @@ class ProfileImageViewPostTestCase(ProfileImageEndpointMixin, APITestCase): ...@@ -196,15 +198,16 @@ class ProfileImageViewPostTestCase(ProfileImageEndpointMixin, APITestCase):
self.check_anonymous_request_rejected('post') self.check_anonymous_request_rejected('post')
self.assertFalse(mock_log.info.called) self.assertFalse(mock_log.info.called)
@ddt.data('.jpg', '.jpeg', '.jpg', '.jpeg', '.png', '.gif', '.GIF')
@patch( @patch(
'openedx.core.djangoapps.profile_images.views._make_upload_dt', 'openedx.core.djangoapps.profile_images.views._make_upload_dt',
side_effect=[TEST_UPLOAD_DT, TEST_UPLOAD_DT2] side_effect=[TEST_UPLOAD_DT, TEST_UPLOAD_DT2],
) )
def test_upload_self(self, mock_make_image_version, mock_log): # pylint: disable=unused-argument def test_upload_self(self, extension, _mock_make_image_version, mock_log):
""" """
Test that an authenticated user can POST to their own upload endpoint. Test that an authenticated user can POST to their own upload endpoint.
""" """
with make_image_file() as image_file: with make_image_file(extension=extension) as image_file:
response = self.client.post(self.url, {'file': image_file}, format='multipart') response = self.client.post(self.url, {'file': image_file}, format='multipart')
self.check_response(response, 204) self.check_response(response, 204)
self.check_images() self.check_images()
...@@ -222,6 +225,57 @@ class ProfileImageViewPostTestCase(ProfileImageEndpointMixin, APITestCase): ...@@ -222,6 +225,57 @@ class ProfileImageViewPostTestCase(ProfileImageEndpointMixin, APITestCase):
self.check_upload_event_emitted(old=TEST_UPLOAD_DT, new=TEST_UPLOAD_DT2) self.check_upload_event_emitted(old=TEST_UPLOAD_DT, new=TEST_UPLOAD_DT2)
@ddt.data(
('image/jpeg', '.jpg'),
('image/jpeg', '.jpeg'),
('image/pjpeg', '.jpg'),
('image/pjpeg', '.jpeg'),
('image/png', '.png'),
('image/gif', '.gif'),
('image/gif', '.GIF'),
)
@ddt.unpack
@patch('openedx.core.djangoapps.profile_images.views._make_upload_dt', return_value=TEST_UPLOAD_DT)
def test_upload_by_mimetype(self, content_type, extension, _mock_make_image_version, mock_log):
"""
Test that a user can upload raw content with the appropriate mimetype
"""
with make_image_file(extension=extension) as image_file:
data = image_file.read()
response = self.client.post(
self.url,
data,
content_type=content_type,
HTTP_CONTENT_DISPOSITION='attachment;filename=filename{}'.format(extension),
)
self.check_response(response, 204)
self.check_images()
self.check_has_profile_image()
mock_log.info.assert_called_once_with(
LOG_MESSAGE_CREATE,
{'image_names': get_profile_image_names(self.user.username).values(), 'user_id': self.user.id}
)
self.check_upload_event_emitted()
def test_upload_unsupported_mimetype(self, mock_log):
"""
Test that uploading an unsupported image as raw content fails with an
HTTP 415 Error.
"""
with make_image_file() as image_file:
data = image_file.read()
response = self.client.post(
self.url,
data,
content_type='image/tiff',
HTTP_CONTENT_DISPOSITION='attachment;filename=filename.tiff',
)
self.check_response(response, 415)
self.check_images(False)
self.check_has_profile_image(False)
self.assertFalse(mock_log.info.called)
self.assert_no_events_were_emitted()
def test_upload_other(self, mock_log): def test_upload_other(self, mock_log):
""" """
Test that an authenticated user cannot POST to another user's upload Test that an authenticated user cannot POST to another user's upload
......
...@@ -3,6 +3,7 @@ This module implements the upload and remove endpoints of the profile image api. ...@@ -3,6 +3,7 @@ This module implements the upload and remove endpoints of the profile image api.
""" """
from contextlib import closing from contextlib import closing
import datetime import datetime
import itertools
import logging import logging
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
...@@ -17,9 +18,13 @@ from openedx.core.lib.api.authentication import ( ...@@ -17,9 +18,13 @@ from openedx.core.lib.api.authentication import (
OAuth2AuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser,
) )
from openedx.core.lib.api.parsers import TypedFileUploadParser
from openedx.core.lib.api.permissions import IsUserInUrl, IsUserInUrlOrStaff from openedx.core.lib.api.permissions import IsUserInUrl, IsUserInUrlOrStaff
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image
from .images import validate_uploaded_image, create_profile_images, remove_profile_images, ImageValidationError from .images import (
IMAGE_TYPES, validate_uploaded_image, create_profile_images, remove_profile_images, ImageValidationError
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -35,7 +40,7 @@ def _make_upload_dt(): ...@@ -35,7 +40,7 @@ def _make_upload_dt():
return datetime.datetime.utcnow().replace(tzinfo=utc) return datetime.datetime.utcnow().replace(tzinfo=utc)
class ProfileImageView(APIView): class ProfileImageView(DeveloperErrorViewMixin, APIView):
""" """
**Use Cases** **Use Cases**
...@@ -105,10 +110,12 @@ class ProfileImageView(APIView): ...@@ -105,10 +110,12 @@ class ProfileImageView(APIView):
the user exists or not. the user exists or not.
""" """
parser_classes = (MultiPartParser, FormParser) parser_classes = (MultiPartParser, FormParser, TypedFileUploadParser)
authentication_classes = (OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser) authentication_classes = (OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser)
permission_classes = (permissions.IsAuthenticated, IsUserInUrl) permission_classes = (permissions.IsAuthenticated, IsUserInUrl)
upload_media_types = set(itertools.chain(*(image_type.mimetypes for image_type in IMAGE_TYPES.values())))
def post(self, request, username): def post(self, request, username):
""" """
POST /api/user/v1/accounts/{username}/image POST /api/user/v1/accounts/{username}/image
......
""" """
Custom Django REST Framework request/response pipeline parsers Custom DRF request parsers. These can be used by views to handle different
content types, as specified by `<Parser>.media_type`.
To use these in an APIView, set `<View>.parser_classes` to a list including the
desired parsers. See http://www.django-rest-framework.org/api-guide/parsers/
for details.
""" """
from rest_framework import parsers
from rest_framework.exceptions import ParseError, UnsupportedMediaType
from rest_framework.parsers import FileUploadParser, JSONParser
class TypedFileUploadParser(FileUploadParser):
"""
Handles upload of files, ensuring that the media type is supported, and
that the uploaded filename matches the Content-type.
Requirements:
* The view must have an `upload_media_types` attribute which is a
set (or other container) enumerating the mimetypes of the supported
media formats
Example:
View.upload_media_types = {'audio/mp3', 'audio/ogg', 'audio/wav'}
* Content-type must be set to a supported type (as
defined in View.upload_media_types above).
Example:
Content-type: audio/ogg
* Content-disposition must include a filename with a valid extension
for the specified Content-type.
Example:
Content-disposition: attachment; filename="lecture-1.ogg"
"""
media_type = '*/*'
# Add more entries to this as needed. All extensions should be lowercase.
file_extensions = {
'image/gif': {'.gif'},
'image/jpeg': {'.jpeg', '.jpg'},
'image/pjpeg': {'.jpeg', '.jpg'},
'image/png': {'.png'},
'image/svg': {'.svg'},
}
def parse(self, stream, media_type=None, parser_context=None):
"""
Parse the request, returning a DataAndFiles object with the data dict
left empty, and the body of the request placed in files['file'].
"""
upload_media_types = getattr(parser_context['view'], 'upload_media_types', set())
if media_type not in upload_media_types:
raise UnsupportedMediaType(media_type)
filename = self.get_filename(stream, media_type, parser_context)
if media_type in self.file_extensions:
fileparts = filename.rsplit('.', 1)
if len(fileparts) < 2:
ext = ''
else:
ext = '.{}'.format(fileparts[1])
if ext.lower() not in self.file_extensions[media_type]:
errmsg = (
u'File extension does not match requested Content-type. '
u'Filename: "{filename}", Content-type: "{contenttype}"'
)
raise ParseError(errmsg.format(filename=filename, contenttype=media_type))
return super(TypedFileUploadParser, self).parse(stream, media_type, parser_context)
class MergePatchParser(parsers.JSONParser): class MergePatchParser(JSONParser):
""" """
Custom parser to be used with the "merge patch" implementation (https://tools.ietf.org/html/rfc7396). Custom parser to be used with the "merge patch" implementation (https://tools.ietf.org/html/rfc7396).
""" """
......
"""
TestCases verifying proper behavior of custom DRF request parsers.
"""
from collections import namedtuple
from io import BytesIO
from rest_framework import exceptions
from rest_framework.test import APITestCase, APIRequestFactory
from openedx.core.lib.api import parsers
class TestTypedFileUploadParser(APITestCase):
"""
Tests that verify the behavior of TypedFileUploadParser
"""
def setUp(self):
super(TestTypedFileUploadParser, self).setUp()
self.parser = parsers.TypedFileUploadParser()
self.request_factory = APIRequestFactory()
upload_media_types = {'image/png', 'image/jpeg', 'application/octet-stream'}
self.view = namedtuple('view', ('upload_media_types',))(upload_media_types)
def test_parse_supported_type(self):
"""
Test that TypedFileUploadParser returns empty data and content stored in
files['file'].
"""
request = self.request_factory.post(
'/',
content_type='image/png',
HTTP_CONTENT_DISPOSITION='attachment; filename="file.PNG"',
)
context = {'view': self.view, 'request': request}
result = self.parser.parse(stream=BytesIO('abcdefgh'), media_type='image/png', parser_context=context)
self.assertEqual(result.data, {})
self.assertIn('file', result.files)
self.assertEqual(result.files['file'].read(), 'abcdefgh')
def test_parse_unsupported_type(self):
"""
Test that TypedFileUploadParser raises an exception when parsing an
unsupported image format.
"""
request = self.request_factory.post(
'/',
content_type='image/tiff',
HTTP_CONTENT_DISPOSITION='attachment; filename="file.tiff"',
)
context = {'view': self.view, 'request': request}
with self.assertRaises(exceptions.UnsupportedMediaType):
self.parser.parse(stream=BytesIO('abcdefgh'), media_type='image/tiff', parser_context=context)
def test_parse_unconstrained_type(self):
"""
Test that TypedFileUploader allows any extension for mimetypes without
specified extensions
"""
request = self.request_factory.post(
'/',
content_type='application/octet-stream',
HTTP_CONTENT_DISPOSITION='attachment; filename="VIRUS.EXE',
)
context = {'view': self.view, 'request': request}
result = self.parser.parse(
stream=BytesIO('abcdefgh'), media_type='application/octet-stream', parser_context=context
)
self.assertEqual(result.data, {})
self.assertIn('file', result.files)
self.assertEqual(result.files['file'].read(), 'abcdefgh')
def test_parse_mismatched_filename_and_mimetype(self):
"""
Test that TypedFileUploadParser raises an exception when the specified
content-type doesn't match the filename extension in the
content-disposition header.
"""
request = self.request_factory.post(
'/',
content_type='image/png',
HTTP_CONTENT_DISPOSITION='attachment; filename="file.jpg"',
)
context = {'view': self.view, 'request': request}
with self.assertRaises(exceptions.ParseError) as err:
self.parser.parse(stream=BytesIO('abcdefgh'), media_type='image/png', parser_context=context)
self.assertIn('developer_message', err.detail)
self.assertNotIn('user_message', err.detail)
def test_no_acceptable_types(self):
"""
If the view doesn't specify supported types, the parser rejects
everything.
"""
view = object()
self.assertFalse(hasattr(view, 'upload_media_types'))
request = self.request_factory.post(
'/',
content_type='image/png',
HTTP_CONTENT_DISPOSITION='attachment; filename="file.png"',
)
context = {'view': view, 'request': request}
with self.assertRaises(exceptions.UnsupportedMediaType) as err:
self.parser.parse(stream=BytesIO('abcdefgh'), media_type='image/png', parser_context=context)
self.assertIn('developer_message', err.detail)
self.assertIn('user_message', err.detail)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment