From 2968e3551b64fba983e13847253c3c30567efb9e Mon Sep 17 00:00:00 2001 From: Mushtaq Ali <mushtaque@edx.org> Date: Tue, 29 Nov 2016 16:48:30 +0500 Subject: [PATCH] Prevent non-video file formats - TNL-5956 --- cms/djangoapps/contentstore/views/tests/test_videos.py | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- cms/djangoapps/contentstore/views/videos.py | 11 +++++++++++ cms/static/js/factories/videos_index.js | 4 +++- cms/static/js/spec/views/active_video_upload_list_spec.js | 32 +++++++++++++++++++++++++++++++- cms/static/js/views/active_video_upload_list.js | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------ cms/templates/videos_index.html | 37 +++++++++++++++++++++++++++---------- 6 files changed, 255 insertions(+), 56 deletions(-) diff --git a/cms/djangoapps/contentstore/views/tests/test_videos.py b/cms/djangoapps/contentstore/views/tests/test_videos.py index 3083f45..6db155f 100644 --- a/cms/djangoapps/contentstore/views/tests/test_videos.py +++ b/cms/djangoapps/contentstore/views/tests/test_videos.py @@ -3,6 +3,7 @@ Unit tests for video-related REST APIs. """ import csv +import ddt import json import dateutil.parser import re @@ -158,6 +159,7 @@ class VideoUploadTestMixin(object): self.assertEqual(self.client.get(self.url).status_code, 404) +@ddt.ddt @patch.dict("django.conf.settings.FEATURES", {"ENABLE_VIDEO_UPLOAD_PIPELINE": True}) @override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"}) class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): @@ -223,6 +225,70 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): @override_settings(AWS_ACCESS_KEY_ID="test_key_id", AWS_SECRET_ACCESS_KEY="test_secret") @patch("boto.s3.key.Key") @patch("boto.s3.connection.S3Connection") + @ddt.data( + ( + [ + { + "file_name": "supported-1.mp4", + "content_type": "video/mp4", + }, + { + "file_name": "supported-2.mov", + "content_type": "video/quicktime", + }, + ], + 200 + ), + ( + [ + { + "file_name": "unsupported-1.txt", + "content_type": "text/plain", + }, + { + "file_name": "unsupported-2.png", + "content_type": "image/png", + }, + ], + 400 + ) + ) + @ddt.unpack + def test_video_supported_file_formats(self, files, expected_status, mock_conn, mock_key): + """ + Test that video upload works correctly against supported and unsupported file formats. + """ + bucket = Mock() + mock_conn.return_value = Mock(get_bucket=Mock(return_value=bucket)) + mock_key_instances = [ + Mock( + generate_url=Mock( + return_value="http://example.com/url_{}".format(file_info["file_name"]) + ) + ) + for file_info in files + ] + # If extra calls are made, return a dummy + mock_key.side_effect = mock_key_instances + [Mock()] + + # Check supported formats + response = self.client.post( + self.url, + json.dumps({"files": files}), + content_type="application/json" + ) + self.assertEqual(response.status_code, expected_status) + response = json.loads(response.content) + + if expected_status == 200: + self.assertNotIn('error', response) + else: + self.assertIn('error', response) + self.assertEqual(response['error'], "Request 'files' entry contain unsupported content_type") + + @override_settings(AWS_ACCESS_KEY_ID="test_key_id", AWS_SECRET_ACCESS_KEY="test_secret") + @patch("boto.s3.key.Key") + @patch("boto.s3.connection.S3Connection") def test_post_success(self, mock_conn, mock_key): files = [ { @@ -230,8 +296,8 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): "content_type": "video/mp4", }, { - "file_name": "second.webm", - "content_type": "video/webm", + "file_name": "second.mp4", + "content_type": "video/mp4", }, { "file_name": "third.mov", diff --git a/cms/djangoapps/contentstore/views/videos.py b/cms/djangoapps/contentstore/views/videos.py index 3ef38bb..ada8081 100644 --- a/cms/djangoapps/contentstore/views/videos.py +++ b/cms/djangoapps/contentstore/views/videos.py @@ -29,6 +29,11 @@ __all__ = ["videos_handler", "video_encodings_download"] # Default expiration, in seconds, of one-time URLs used for uploading videos. KEY_EXPIRATION_IN_SECONDS = 86400 +VIDEO_SUPPORTED_FILE_FORMATS = { + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', +} + class StatusDisplayStrings(object): """ @@ -257,6 +262,7 @@ def videos_index_html(course): "encodings_download_url": reverse_course_url("video_encodings_download", unicode(course.id)), "previous_uploads": _get_index_videos(course), "concurrent_upload_limit": settings.VIDEO_UPLOAD_PIPELINE.get("CONCURRENT_UPLOAD_LIMIT", 0), + "video_supported_file_formats": VIDEO_SUPPORTED_FILE_FORMATS.keys() } ) @@ -305,6 +311,11 @@ def videos_post(course, request): for file in request.json["files"] ): error = "Request 'files' entry does not contain 'file_name' and 'content_type'" + elif any( + file['content_type'] not in VIDEO_SUPPORTED_FILE_FORMATS.values() + for file in request.json["files"] + ): + error = "Request 'files' entry contain unsupported content_type" if error: return JsonResponse({"error": error}, status=400) diff --git a/cms/static/js/factories/videos_index.js b/cms/static/js/factories/videos_index.js index 23749dd..47cd356 100644 --- a/cms/static/js/factories/videos_index.js +++ b/cms/static/js/factories/videos_index.js @@ -9,12 +9,14 @@ define([ encodingsDownloadUrl, concurrentUploadLimit, uploadButton, - previousUploads + previousUploads, + videoSupportedFileFormats ) { var activeView = new ActiveVideoUploadListView({ postUrl: videoHandlerUrl, concurrentUploadLimit: concurrentUploadLimit, uploadButton: uploadButton, + videoSupportedFileFormats: videoSupportedFileFormats, onFileUploadDone: function(activeVideos) { $.ajax({ url: videoHandlerUrl, diff --git a/cms/static/js/spec/views/active_video_upload_list_spec.js b/cms/static/js/spec/views/active_video_upload_list_spec.js index fb08b88..8e1ab82 100644 --- a/cms/static/js/spec/views/active_video_upload_list_spec.js +++ b/cms/static/js/spec/views/active_video_upload_list_spec.js @@ -17,10 +17,12 @@ define( TemplateHelpers.installTemplate('active-video-upload-list'); this.postUrl = '/test/post/url'; this.uploadButton = $('<button>'); + this.videoSupportedFileFormats = ['.mp4', '.mov']; this.view = new ActiveVideoUploadListView({ concurrentUploadLimit: concurrentUploadLimit, postUrl: this.postUrl, - uploadButton: this.uploadButton + uploadButton: this.uploadButton, + videoSupportedFileFormats: this.videoSupportedFileFormats }); this.view.render(); jasmine.Ajax.install(); @@ -59,6 +61,34 @@ define( }); }; + describe('supported file formats', function() { + it('should not show unsupported file format notification for supported files', function() { + var supportedFiles = { + files: [ + {name: 'test-1.mp4', size: 0}, + {name: 'test-1.mov', size: 0} + ] + }; + this.view.$uploadForm.fileupload('add', supportedFiles); + expect(this.view.fileErrorMsg).toBeNull(); + }); + it('should show invalid file format notification for unspoorted files', function() { + var unSupportedFiles = { + files: [ + {name: 'test-3.txt', size: 0}, + {name: 'test-4.png', size: 0} + ] + }; + this.view.$uploadForm.fileupload('add', unSupportedFiles); + expect(this.view.fileErrorMsg).toBeDefined(); + expect(this.view.fileErrorMsg.options.title).toEqual('Your file could not be uploaded'); + expect(this.view.fileErrorMsg.options.message).toEqual( + 'test-3.txt is not in a supported file format. Supported file formats are ' + + this.videoSupportedFileFormats.join(' and ') + '.' + ); + }); + }); + _.each( [ {desc: 'a single file', numFiles: 1}, diff --git a/cms/static/js/views/active_video_upload_list.js b/cms/static/js/views/active_video_upload_list.js index 827021f..dcdea70 100644 --- a/cms/static/js/views/active_video_upload_list.js +++ b/cms/static/js/views/active_video_upload_list.js @@ -1,8 +1,18 @@ -define( - ['jquery', 'underscore', 'backbone', 'js/models/active_video_upload', 'js/views/baseview', 'js/views/active_video_upload', 'jquery.fileupload'], - function($, _, Backbone, ActiveVideoUpload, BaseView, ActiveVideoUploadView) { +define([ + 'jquery', + 'underscore', + 'backbone', + 'js/models/active_video_upload', + 'js/views/baseview', + 'js/views/active_video_upload', + 'common/js/components/views/feedback_notification', + 'edx-ui-toolkit/js/utils/html-utils', + 'text!templates/active-video-upload-list.underscore', + 'jquery.fileupload' +], + function($, _, Backbone, ActiveVideoUpload, BaseView, ActiveVideoUploadView, NotificationView, HtmlUtils, + activeVideoUploadListTemplate) { 'use strict'; - var ActiveVideoUploadListView = BaseView.extend({ tagName: 'div', events: { @@ -12,20 +22,26 @@ define( }, initialize: function(options) { - this.template = this.loadTemplate('active-video-upload-list'); + this.template = HtmlUtils.template(activeVideoUploadListTemplate)({}); this.collection = new Backbone.Collection(); this.itemViews = []; this.listenTo(this.collection, 'add', this.addUpload); this.concurrentUploadLimit = options.concurrentUploadLimit || 0; this.postUrl = options.postUrl; + this.videoSupportedFileFormats = options.videoSupportedFileFormats; this.onFileUploadDone = options.onFileUploadDone; if (options.uploadButton) { options.uploadButton.click(this.chooseFile.bind(this)); } + // error message modal for file uploads + this.fileErrorMsg = null; }, render: function() { - this.$el.html(this.template()); + HtmlUtils.setHtml( + this.$el, + this.template + ); _.each(this.itemViews, this.renderUploadView.bind(this)); this.$uploadForm = this.$('.file-upload-form'); this.$dropZone = this.$uploadForm.find('.file-drop-area'); @@ -80,6 +96,8 @@ define( chooseFile: function(event) { event.preventDefault(); + // hide error message if any present. + this.hideErrorMessage(); this.$uploadForm.find('.js-file-input').click(); }, @@ -101,41 +119,52 @@ define( // indicate that the correct upload url has already been retrieved fileUploadAdd: function(event, uploadData) { var view = this, - model; - if (uploadData.redirected) { - model = new ActiveVideoUpload({fileName: uploadData.files[0].name, videoId: uploadData.videoId}); - this.collection.add(model); - uploadData.cid = model.cid; - uploadData.submit(); + model, + errorMsg; + + // Validate file + errorMsg = view.validateFile(uploadData); + if (errorMsg) { + view.showErrorMessage(errorMsg); } else { - $.ajax({ - url: this.postUrl, - contentType: 'application/json', - data: JSON.stringify({ - files: _.map( - uploadData.files, - function(file) { - return {'file_name': file.name, 'content_type': file.type}; + if (uploadData.redirected) { + model = new ActiveVideoUpload({ + fileName: uploadData.files[0].name, + videoId: uploadData.videoId + }); + this.collection.add(model); + uploadData.cid = model.cid; // eslint-disable-line no-param-reassign + uploadData.submit(); + } else { + $.ajax({ + url: this.postUrl, + contentType: 'application/json', + data: JSON.stringify({ + files: _.map( + uploadData.files, + function(file) { + return {file_name: file.name, content_type: file.type}; + } + ) + }), + dataType: 'json', + type: 'POST' + }).done(function(responseData) { + _.each( + responseData.files, + function(file, index) { + view.$uploadForm.fileupload('add', { + files: [uploadData.files[index]], + url: file.upload_url, + videoId: file.edx_video_id, + multipart: false, + global: false, // Do not trigger global AJAX error handler + redirected: true + }); } - ) - }), - dataType: 'json', - type: 'POST' - }).done(function(responseData) { - _.each( - responseData['files'], - function(file, index) { - view.$uploadForm.fileupload('add', { - files: [uploadData.files[index]], - url: file['upload_url'], - videoId: file.edx_video_id, - multipart: false, - global: false, // Do not trigger global AJAX error handler - redirected: true - }); - } - ); - }); + ); + }); + } } }, @@ -169,6 +198,52 @@ define( this.setStatus(data.cid, ActiveVideoUpload.STATUS_FAILED); }, + hideErrorMessage: function() { + if (this.fileErrorMsg) { + this.fileErrorMsg.hide(); + this.fileErrorMsg = null; + } + }, + + readMessages: function(messages) { + if ($(window).prop('SR') !== undefined) { + $(window).prop('SR').readTexts(messages); + } + }, + + showErrorMessage: function(errorMsg) { + var titleMsg = gettext('Your file could not be uploaded'); + this.fileErrorMsg = new NotificationView.Error({ + title: titleMsg, + message: errorMsg + }); + this.fileErrorMsg.show(); + this.readMessages([titleMsg, errorMsg]); + }, + + validateFile: function(data) { + var self = this, + error = '', + fileName, + fileType; + + $.each(data.files, function(index, file) { // eslint-disable-line consistent-return + fileName = file.name; + fileType = fileName.substr(fileName.lastIndexOf('.')); + // validate file type + if (!_.contains(self.videoSupportedFileFormats, fileType)) { + error = gettext( + '{filename} is not in a supported file format. ' + + 'Supported file formats are {supportedFileFormats}.' + ) + .replace('{filename}', fileName) + .replace('{supportedFileFormats}', self.videoSupportedFileFormats.join(' and ')); + return false; + } + }); + return error; + }, + removeViewAt: function(index) { this.itemViews.splice(index); this.$('.active-video-upload-list li').eq(index).remove(); @@ -197,9 +272,7 @@ define( // Alert screen readers that the uploads were successful if (completedMessages.length) { completedMessages.push(gettext('Previous Uploads table has been updated.')); - if ($(window).prop('SR') !== undefined) { - $(window).prop('SR').readTexts(completedMessages); - } + this.readMessages(completedMessages); } } }); diff --git a/cms/templates/videos_index.html b/cms/templates/videos_index.html index 053b869..8e1103e 100644 --- a/cms/templates/videos_index.html +++ b/cms/templates/videos_index.html @@ -1,9 +1,14 @@ +<%page expression_filter="h"/> <%inherit file="base.html" /> <%def name="online_help_token()"><% return "video" %></%def> <%! import json from django.core.serializers.json import DjangoJSONEncoder from django.utils.translation import ugettext as _ + from openedx.core.djangolib.js_utils import ( + dump_js_escaped_json, js_escaped_string + ) + from openedx.core.djangolib.markup import HTML, Text %> <%block name="title">${_("Video Uploads")}</%block> <%block name="bodyclass">is-signedin course view-video-uploads</%block> @@ -11,7 +16,7 @@ <%namespace name='static' file='static_content.html'/> <%block name="header_extras"> -% for template_name in ["active-video-upload-list", "active-video-upload", "previous-video-upload-list"]: +% for template_name in ["active-video-upload", "previous-video-upload-list"]: <script type="text/template" id="${template_name}-tpl"> <%static:include path="js/${template_name}.underscore" /> </script> @@ -24,11 +29,12 @@ var $contentWrapper = $(".content-primary"); VideosIndexFactory( $contentWrapper, - "${video_handler_url}", - "${encodings_download_url}", - ${concurrent_upload_limit}, + "${video_handler_url | n, js_escaped_string}", + "${encodings_download_url | n, js_escaped_string}", + ${concurrent_upload_limit | n, dump_js_escaped_json}, $(".nav-actions .upload-button"), - $contentWrapper.data("previous-uploads") + $contentWrapper.data("previous-uploads"), + ${video_supported_file_formats | n, dump_js_escaped_json} ); }); </%block> @@ -55,21 +61,32 @@ <div class="wrapper-content wrapper"> <section class="content"> - <article class="content-primary" role="main" data-previous-uploads="${json.dumps(previous_uploads, cls=DjangoJSONEncoder) | h}"></article> + <article class="content-primary" role="main" data-previous-uploads="${json.dumps(previous_uploads, cls=DjangoJSONEncoder)}"></article> <aside class="content-supplementary" role="complementary"> <div class="bit"> <h3 class="title-3">${_("Why upload video files?")}</h3> - <p>${_("For a video to play on different devices, it needs to be available in multiple formats. After you upload an original video file in .mp4 or .mov format on this page, an automated process creates those additional formats and stores them for you.")}</p> + <p>${_("For a video to play on different devices, it needs to be available in multiple formats. After you upload an original video file in {file_formats} format on this page, an automated process creates those additional formats and stores them for you.").format( + file_formats=' or '.join(video_supported_file_formats) + )}</p> <h3 class="title-3">${_("Maximum Video File Size")}</h3> <p>${_("The maximum size for each video file that you upload is 5 GB. The upload process fails for larger files.")}</p> <h3 class="title-3">${_("Monitoring files as they upload")}</h3> <p>${_("Each video file that you upload needs to reach the video processing servers successfully before additional work can begin. You can monitor the progress of files as they upload, and try again if the upload fails.")}</p> <h3 class="title-3">${_("Managing uploaded files")}</h3> - <p>${_("After a file uploads successfully, automated processing begins. The file is then listed under Previous Uploads as {em_start}In Progress{em_end}. You can add the video to your course as soon as it has a unique video ID and the status is {em_start}Ready{em_end}. Allow 24 hours for file processing at the external video hosting sites to complete.").format(em_start='<strong>', em_end="</strong>")}</p> - <p>${_("If something goes wrong, the {em_start}Failed{em_end} status message appears. Check for problems in your original file and upload a replacement.").format(em_start='<strong>', em_end="</strong>")}</p> + <p>${Text(_("After a file uploads successfully, automated processing begins. The file is then listed under Previous Uploads as {em_start}In Progress{em_end}. You can add the video to your course as soon as it has a unique video ID and the status is {em_start}Ready{em_end}. Allow 24 hours for file processing at the external video hosting sites to complete.")).format( + em_start=HTML('<strong>'), + em_end=HTML('</strong>') + )}</p> + <p>${Text(_("If something goes wrong, the {em_start}Failed{em_end} status message appears. Check for problems in your original file and upload a replacement.")).format( + em_start=HTML('<strong>'), + em_end=HTML('</strong>') + )}</p> <h3 class="title-3">${_("How do I get the videos into my course?")}</h3> - <p>${_("When status for a file is {em_start}Ready{em_end}, you can add that video to a component in your course. Copy the unique video ID. In another browser window, on the Course Outline page, create or locate a video component to play this video. Edit the video component to paste the ID into the Advanced {em_start}Video ID{em_end} field. The video can play in the LMS as soon as its status is {em_start}Ready{em_end}, although processing may not be complete for all encodings and all video hosting sites.").format(em_start='<strong>', em_end="</strong>")}</p> + <p>${Text(_("When the status for a file is {em_start}Ready{em_end}, you can add that video to a component in your course. Copy the unique video ID. In another browser window, on the Course Outline page, create or locate a video component to play this video. Edit the video component to paste the ID into the Advanced {em_start}Video ID{em_end} field. The video can play in the LMS as soon as its status is {em_start}Ready{em_end}, although processing may not be complete for all encodings and all video hosting sites.")).format( + em_start=HTML('<strong>'), + em_end=HTML('</strong>') + )}</p> </div> </aside> </section> -- libgit2 0.26.0