Commit 2968e355 by Mushtaq Ali

Prevent non-video file formats - TNL-5956

parent 2e00fd58
......@@ -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",
......
......@@ -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)
......
......@@ -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,
......
......@@ -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},
......
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);
}
}
});
......
<%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>
......
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