Commit 2968e355 by Mushtaq Ali

Prevent non-video file formats - TNL-5956

parent 2e00fd58
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
Unit tests for video-related REST APIs. Unit tests for video-related REST APIs.
""" """
import csv import csv
import ddt
import json import json
import dateutil.parser import dateutil.parser
import re import re
...@@ -158,6 +159,7 @@ class VideoUploadTestMixin(object): ...@@ -158,6 +159,7 @@ class VideoUploadTestMixin(object):
self.assertEqual(self.client.get(self.url).status_code, 404) self.assertEqual(self.client.get(self.url).status_code, 404)
@ddt.ddt
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_VIDEO_UPLOAD_PIPELINE": True}) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_VIDEO_UPLOAD_PIPELINE": True})
@override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"}) @override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"})
class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
...@@ -223,6 +225,70 @@ 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") @override_settings(AWS_ACCESS_KEY_ID="test_key_id", AWS_SECRET_ACCESS_KEY="test_secret")
@patch("boto.s3.key.Key") @patch("boto.s3.key.Key")
@patch("boto.s3.connection.S3Connection") @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): def test_post_success(self, mock_conn, mock_key):
files = [ files = [
{ {
...@@ -230,8 +296,8 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase): ...@@ -230,8 +296,8 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
"content_type": "video/mp4", "content_type": "video/mp4",
}, },
{ {
"file_name": "second.webm", "file_name": "second.mp4",
"content_type": "video/webm", "content_type": "video/mp4",
}, },
{ {
"file_name": "third.mov", "file_name": "third.mov",
......
...@@ -29,6 +29,11 @@ __all__ = ["videos_handler", "video_encodings_download"] ...@@ -29,6 +29,11 @@ __all__ = ["videos_handler", "video_encodings_download"]
# Default expiration, in seconds, of one-time URLs used for uploading videos. # Default expiration, in seconds, of one-time URLs used for uploading videos.
KEY_EXPIRATION_IN_SECONDS = 86400 KEY_EXPIRATION_IN_SECONDS = 86400
VIDEO_SUPPORTED_FILE_FORMATS = {
'.mp4': 'video/mp4',
'.mov': 'video/quicktime',
}
class StatusDisplayStrings(object): class StatusDisplayStrings(object):
""" """
...@@ -257,6 +262,7 @@ def videos_index_html(course): ...@@ -257,6 +262,7 @@ def videos_index_html(course):
"encodings_download_url": reverse_course_url("video_encodings_download", unicode(course.id)), "encodings_download_url": reverse_course_url("video_encodings_download", unicode(course.id)),
"previous_uploads": _get_index_videos(course), "previous_uploads": _get_index_videos(course),
"concurrent_upload_limit": settings.VIDEO_UPLOAD_PIPELINE.get("CONCURRENT_UPLOAD_LIMIT", 0), "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): ...@@ -305,6 +311,11 @@ def videos_post(course, request):
for file in request.json["files"] for file in request.json["files"]
): ):
error = "Request 'files' entry does not contain 'file_name' and 'content_type'" 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: if error:
return JsonResponse({"error": error}, status=400) return JsonResponse({"error": error}, status=400)
......
...@@ -9,12 +9,14 @@ define([ ...@@ -9,12 +9,14 @@ define([
encodingsDownloadUrl, encodingsDownloadUrl,
concurrentUploadLimit, concurrentUploadLimit,
uploadButton, uploadButton,
previousUploads previousUploads,
videoSupportedFileFormats
) { ) {
var activeView = new ActiveVideoUploadListView({ var activeView = new ActiveVideoUploadListView({
postUrl: videoHandlerUrl, postUrl: videoHandlerUrl,
concurrentUploadLimit: concurrentUploadLimit, concurrentUploadLimit: concurrentUploadLimit,
uploadButton: uploadButton, uploadButton: uploadButton,
videoSupportedFileFormats: videoSupportedFileFormats,
onFileUploadDone: function(activeVideos) { onFileUploadDone: function(activeVideos) {
$.ajax({ $.ajax({
url: videoHandlerUrl, url: videoHandlerUrl,
......
...@@ -17,10 +17,12 @@ define( ...@@ -17,10 +17,12 @@ define(
TemplateHelpers.installTemplate('active-video-upload-list'); TemplateHelpers.installTemplate('active-video-upload-list');
this.postUrl = '/test/post/url'; this.postUrl = '/test/post/url';
this.uploadButton = $('<button>'); this.uploadButton = $('<button>');
this.videoSupportedFileFormats = ['.mp4', '.mov'];
this.view = new ActiveVideoUploadListView({ this.view = new ActiveVideoUploadListView({
concurrentUploadLimit: concurrentUploadLimit, concurrentUploadLimit: concurrentUploadLimit,
postUrl: this.postUrl, postUrl: this.postUrl,
uploadButton: this.uploadButton uploadButton: this.uploadButton,
videoSupportedFileFormats: this.videoSupportedFileFormats
}); });
this.view.render(); this.view.render();
jasmine.Ajax.install(); jasmine.Ajax.install();
...@@ -59,6 +61,34 @@ define( ...@@ -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( _.each(
[ [
{desc: 'a single file', numFiles: 1}, {desc: 'a single file', numFiles: 1},
......
define( define([
['jquery', 'underscore', 'backbone', 'js/models/active_video_upload', 'js/views/baseview', 'js/views/active_video_upload', 'jquery.fileupload'], 'jquery',
function($, _, Backbone, ActiveVideoUpload, BaseView, ActiveVideoUploadView) { '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'; 'use strict';
var ActiveVideoUploadListView = BaseView.extend({ var ActiveVideoUploadListView = BaseView.extend({
tagName: 'div', tagName: 'div',
events: { events: {
...@@ -12,20 +22,26 @@ define( ...@@ -12,20 +22,26 @@ define(
}, },
initialize: function(options) { initialize: function(options) {
this.template = this.loadTemplate('active-video-upload-list'); this.template = HtmlUtils.template(activeVideoUploadListTemplate)({});
this.collection = new Backbone.Collection(); this.collection = new Backbone.Collection();
this.itemViews = []; this.itemViews = [];
this.listenTo(this.collection, 'add', this.addUpload); this.listenTo(this.collection, 'add', this.addUpload);
this.concurrentUploadLimit = options.concurrentUploadLimit || 0; this.concurrentUploadLimit = options.concurrentUploadLimit || 0;
this.postUrl = options.postUrl; this.postUrl = options.postUrl;
this.videoSupportedFileFormats = options.videoSupportedFileFormats;
this.onFileUploadDone = options.onFileUploadDone; this.onFileUploadDone = options.onFileUploadDone;
if (options.uploadButton) { if (options.uploadButton) {
options.uploadButton.click(this.chooseFile.bind(this)); options.uploadButton.click(this.chooseFile.bind(this));
} }
// error message modal for file uploads
this.fileErrorMsg = null;
}, },
render: function() { render: function() {
this.$el.html(this.template()); HtmlUtils.setHtml(
this.$el,
this.template
);
_.each(this.itemViews, this.renderUploadView.bind(this)); _.each(this.itemViews, this.renderUploadView.bind(this));
this.$uploadForm = this.$('.file-upload-form'); this.$uploadForm = this.$('.file-upload-form');
this.$dropZone = this.$uploadForm.find('.file-drop-area'); this.$dropZone = this.$uploadForm.find('.file-drop-area');
...@@ -80,6 +96,8 @@ define( ...@@ -80,6 +96,8 @@ define(
chooseFile: function(event) { chooseFile: function(event) {
event.preventDefault(); event.preventDefault();
// hide error message if any present.
this.hideErrorMessage();
this.$uploadForm.find('.js-file-input').click(); this.$uploadForm.find('.js-file-input').click();
}, },
...@@ -101,41 +119,52 @@ define( ...@@ -101,41 +119,52 @@ define(
// indicate that the correct upload url has already been retrieved // indicate that the correct upload url has already been retrieved
fileUploadAdd: function(event, uploadData) { fileUploadAdd: function(event, uploadData) {
var view = this, var view = this,
model; model,
if (uploadData.redirected) { errorMsg;
model = new ActiveVideoUpload({fileName: uploadData.files[0].name, videoId: uploadData.videoId});
this.collection.add(model); // Validate file
uploadData.cid = model.cid; errorMsg = view.validateFile(uploadData);
uploadData.submit(); if (errorMsg) {
view.showErrorMessage(errorMsg);
} else { } else {
$.ajax({ if (uploadData.redirected) {
url: this.postUrl, model = new ActiveVideoUpload({
contentType: 'application/json', fileName: uploadData.files[0].name,
data: JSON.stringify({ videoId: uploadData.videoId
files: _.map( });
uploadData.files, this.collection.add(model);
function(file) { uploadData.cid = model.cid; // eslint-disable-line no-param-reassign
return {'file_name': file.name, 'content_type': file.type}; 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( ...@@ -169,6 +198,52 @@ define(
this.setStatus(data.cid, ActiveVideoUpload.STATUS_FAILED); 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) { removeViewAt: function(index) {
this.itemViews.splice(index); this.itemViews.splice(index);
this.$('.active-video-upload-list li').eq(index).remove(); this.$('.active-video-upload-list li').eq(index).remove();
...@@ -197,9 +272,7 @@ define( ...@@ -197,9 +272,7 @@ define(
// Alert screen readers that the uploads were successful // Alert screen readers that the uploads were successful
if (completedMessages.length) { if (completedMessages.length) {
completedMessages.push(gettext('Previous Uploads table has been updated.')); completedMessages.push(gettext('Previous Uploads table has been updated.'));
if ($(window).prop('SR') !== undefined) { this.readMessages(completedMessages);
$(window).prop('SR').readTexts(completedMessages);
}
} }
} }
}); });
......
<%page expression_filter="h"/>
<%inherit file="base.html" /> <%inherit file="base.html" />
<%def name="online_help_token()"><% return "video" %></%def> <%def name="online_help_token()"><% return "video" %></%def>
<%! <%!
import json import json
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.utils.translation import ugettext as _ 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="title">${_("Video Uploads")}</%block>
<%block name="bodyclass">is-signedin course view-video-uploads</%block> <%block name="bodyclass">is-signedin course view-video-uploads</%block>
...@@ -11,7 +16,7 @@ ...@@ -11,7 +16,7 @@
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<%block name="header_extras"> <%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"> <script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" /> <%static:include path="js/${template_name}.underscore" />
</script> </script>
...@@ -24,11 +29,12 @@ ...@@ -24,11 +29,12 @@
var $contentWrapper = $(".content-primary"); var $contentWrapper = $(".content-primary");
VideosIndexFactory( VideosIndexFactory(
$contentWrapper, $contentWrapper,
"${video_handler_url}", "${video_handler_url | n, js_escaped_string}",
"${encodings_download_url}", "${encodings_download_url | n, js_escaped_string}",
${concurrent_upload_limit}, ${concurrent_upload_limit | n, dump_js_escaped_json},
$(".nav-actions .upload-button"), $(".nav-actions .upload-button"),
$contentWrapper.data("previous-uploads") $contentWrapper.data("previous-uploads"),
${video_supported_file_formats | n, dump_js_escaped_json}
); );
}); });
</%block> </%block>
...@@ -55,21 +61,32 @@ ...@@ -55,21 +61,32 @@
<div class="wrapper-content wrapper"> <div class="wrapper-content wrapper">
<section class="content"> <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"> <aside class="content-supplementary" role="complementary">
<div class="bit"> <div class="bit">
<h3 class="title-3">${_("Why upload video files?")}</h3> <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> <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> <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> <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> <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> <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>${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(
<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> 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> <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> </div>
</aside> </aside>
</section> </section>
......
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