diff --git a/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs b/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs index b81d120295..26b776ddde 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs @@ -49,6 +49,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers; [ParameterSwapControllerActionSelector(nameof(GetChildren), "id", typeof(int), typeof(Guid), typeof(Udi))] public class MediaController : ContentControllerBase { + private static readonly Semaphore _postAddFileSemaphore = new(1, 1); private readonly AppCaches _appCaches; private readonly IAuthorizationService _authorizationService; private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; @@ -574,6 +575,7 @@ public class MediaController : ContentControllerBase public async Task PostAddFile([FromForm] string path, [FromForm] string currentFolder, [FromForm] string contentTypeAlias, List file) { + await _postAddFileSemaphore.WaitOneAsync(); var root = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads); //ensure it exists Directory.CreateDirectory(root); @@ -581,6 +583,7 @@ public class MediaController : ContentControllerBase //must have a file if (file.Count == 0) { + _postAddFileSemaphore.Release(); return NotFound(); } @@ -588,12 +591,14 @@ public class MediaController : ContentControllerBase ActionResult? parentIdResult = await GetParentIdAsIntAsync(currentFolder, true); if (!(parentIdResult?.Result is null)) { + _postAddFileSemaphore.Release(); return parentIdResult.Result; } var parentId = parentIdResult?.Value; if (!parentId.HasValue) { + _postAddFileSemaphore.Release(); return NotFound("The passed id doesn't exist"); } @@ -605,6 +610,7 @@ public class MediaController : ContentControllerBase if (!IsFolderCreationAllowedHere(parentId.Value)) { AddCancelMessage(tempFiles, _localizedTextService.Localize("speechBubbles", "folderUploadNotAllowed")); + _postAddFileSemaphore.Release(); return Ok(tempFiles); } @@ -638,6 +644,7 @@ public class MediaController : ContentControllerBase //if the media root is null, something went wrong, we'll abort if (mediaRoot == null) { + _postAddFileSemaphore.Release(); return Problem( "The folder: " + folderName + " could not be used for storing images, its ID: " + parentId + " returned null"); @@ -808,10 +815,12 @@ public class MediaController : ContentControllerBase KeyValuePair origin = HttpContext.Request.Query.First(x => x.Key == "origin"); if (origin.Value == "blueimp") { + _postAddFileSemaphore.Release(); return new JsonResult(tempFiles); //Don't output the angular xsrf stuff, blue imp doesn't like that } } + _postAddFileSemaphore.Release(); return Ok(tempFiles); } diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/upload/umbfiledropzone.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/upload/umbfiledropzone.directive.js index 98f02b7e06..1841548426 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/upload/umbfiledropzone.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/upload/umbfiledropzone.directive.js @@ -19,239 +19,267 @@ TODO */ angular.module("umbraco.directives") - .directive('umbFileDropzone', - function ($timeout, Upload, localizationService, umbRequestHelper, overlayService, mediaHelper, mediaTypeHelper) { - return { - restrict: 'E', - replace: true, - templateUrl: 'views/components/upload/umb-file-dropzone.html', - scope: { - parentId: '@', - contentTypeAlias: '@', - propertyAlias: '@', - accept: '@', - maxFileSize: '@', - - compact: '@', - hideDropzone: '@', - acceptedMediatypes: '=', + .directive('umbFileDropzone', + function ($timeout, Upload, localizationService, umbRequestHelper, overlayService, mediaHelper, mediaTypeHelper) { + return { + restrict: 'E', + replace: true, + templateUrl: 'views/components/upload/umb-file-dropzone.html', + scope: { + parentId: '@', + contentTypeAlias: '@', + propertyAlias: '@', + accept: '@', + maxFileSize: '@', - filesQueued: '=', - handleFile: '=', - filesUploaded: '=' - }, - link: function(scope, element, attrs) { - scope.queue = []; - scope.totalQueued = 0; - scope.currentFile = undefined; - scope.processed = []; - scope.totalMessages = 0; + compact: '@', + hideDropzone: '@', + acceptedMediatypes: '=', - function _filterFile(file) { - var ignoreFileNames = ['Thumbs.db']; - var ignoreFileTypes = ['directory']; + filesQueued: '=', + handleFile: '=', + filesUploaded: '=' + }, + link: function (scope, element, attrs) { + scope.queue = []; + scope.totalQueued = 0; + scope.processing = []; + scope.processed = []; + scope.totalMessages = 0; + // TODO - Make configurable in appsettings + scope.batchSize = 10; + scope.processingCount = 0; - // ignore files with names from the list - // ignore files with types from the list - // ignore files which starts with "." - if (ignoreFileNames.indexOf(file.name) === -1 && - ignoreFileTypes.indexOf(file.type) === -1 && - file.name.indexOf(".") !== 0) { - return true; - } else { - return false; - } - } + function _filterFile(file) { + var ignoreFileNames = ['Thumbs.db']; + var ignoreFileTypes = ['directory']; - function _filesQueued(files, event) { - //Push into the queue - Utilities.forEach(files, file => { - if (_filterFile(file) === true) { - file.messages = []; - scope.queue.push(file); - } - }); + // ignore files with names from the list + // ignore files with types from the list + // ignore files which starts with "." + if (ignoreFileNames.indexOf(file.name) === -1 && + ignoreFileTypes.indexOf(file.type) === -1 && + file.name.indexOf(".") !== 0) { + return true; + } else { + return false; + } + } - // Upload not allowed - if (!scope.acceptedMediatypes || !scope.acceptedMediatypes.length) { - files.map(file => { - file.messages.push({message: "File type is not allowed here", type: "Error"}); - }); - } + function _filesQueued(files, event) { + //Push into the queue + Utilities.forEach(files, file => { + if (_filterFile(file) === true) { + file.messages = []; + scope.queue.push(file); + } + }); - // If we have Accepted Media Types, we will ask to choose Media Type, if Choose Media Type returns false, it only had one choice and therefor no reason to - if (scope.acceptedMediatypes && _requestChooseMediaTypeDialog() === false) { - scope.contentTypeAlias = "umbracoAutoSelect"; - } + // Upload not allowed + if (!scope.acceptedMediatypes || !scope.acceptedMediatypes.length) { + files.map(file => { + file.messages.push({ message: "File type is not allowed here", type: "Error" }); + }); + } - // Add the processed length, as we might be uploading in stages - scope.totalQueued = scope.queue.length + scope.processed.length; + // If we have Accepted Media Types, we will ask to choose Media Type, if + // Choose Media Type returns false, it only had one choice and therefor no reason to + if (scope.acceptedMediatypes && _requestChooseMediaTypeDialog() === false) { + scope.contentTypeAlias = "umbracoAutoSelect"; + } - _processQueueItems(); - } + // Add all of the processing and processed files to account for uploading + // files in stages (dragging files X at a time into the dropzone). + scope.totalQueued = scope.queue.length + scope.processingCount + scope.processed.length; - function _processQueueItems() { - // if we have processed all files, either by successful - // upload, or attending to all messages, we deem the - // action complete, else continue processing files - scope.totalMessages = scope.processed.filter(e => e.messages.length > 0).length; - if (scope.totalQueued === scope.processed.length) { - if (scope.totalMessages === 0) { - if (scope.filesUploaded) { - //queue is empty, trigger the done action - scope.filesUploaded(scope.done); - } - //auto-clear the done queue after 3 secs - var currentLength = scope.processed.length; - $timeout(function() { - scope.processed.splice(0, currentLength); - }, 3000); - } - } else { - scope.currentFile = scope.queue.shift(); - _upload(scope.currentFile); - } - } + _processQueueItems(); + } - function _upload(file) { + function _processQueueItems() { - scope.propertyAlias = scope.propertyAlias ? scope.propertyAlias : "umbracoFile"; - scope.contentTypeAlias = scope.contentTypeAlias ? scope.contentTypeAlias : "Image"; + if (scope.processingCount === scope.batchSize) { + return; + } - Upload.upload({ - url: umbRequestHelper.getApiUrl("mediaApiBaseUrl", "PostAddFile"), - fields: { - 'currentFolder': scope.parentId, - 'contentTypeAlias': scope.contentTypeAlias, - 'propertyAlias': scope.propertyAlias, - 'path': file.path - }, - file: file - }) - .progress(function(evt) { - if (file.uploadStat !== "done" && file.uploadStat !== "error") { - // calculate progress in percentage - var progressPercentage = parseInt(100.0 * evt.loaded / evt.total, 10); - // set percentage property on file - file.uploadProgress = progressPercentage; - } - }) - .success(function (data, status, headers, config) { - // Set server messages - file.messages = data.notifications; - scope.processed.push(file); - //after processing, test if everything is done - scope.currentFile = undefined; - _processQueueItems(); - }) - .error(function(evt, status, headers, config) { - //if the service returns a detailed error - if (evt.InnerException) { - file.messages.push({ message: evt.InnerException.ExceptionMessage, type: "Error" }); - //Check if its the common "too large file" exception - if (evt.InnerException.StackTrace && - evt.InnerException.StackTrace.indexOf("ValidateRequestEntityLength") > 0) { - file.messages.push({ message: "File too large to upload", type: "Error" }); - } - } else if (evt.Message) { - file.messages.push({message: evt.Message, type: "Error"}); - } else if (evt && typeof evt === "string") { - file.messages.push({message: evt, type: "Error"}); - } - // If file not found, server will return a 404 and display this message - if (status === 404) { - file.messages.push({message: "File not found", type: "Error"}); - } - scope.currentFile = undefined; - _processQueueItems(); - }); - } + // if we have processed all files, either by successful + // upload, or attending to all messages, we deem the + // action complete, else continue processing files + scope.totalMessages = scope.processed.filter(e => e.messages.length > 0).length; - function _requestChooseMediaTypeDialog() { - - if (scope.queue.length === 0) { - // if queue has no items so there is nothing to choose a type for - return false; - } - - if (scope.acceptedMediatypes.length === 1) { - // if only one accepted type, then we wont ask to choose. - return false; - } - - var uploadFileExtensions = scope.queue.map(file => mediaHelper.getFileExtension(file.name)); - - var filteredMediaTypes = mediaTypeHelper.getTypeAcceptingFileExtensions(scope.acceptedMediatypes, uploadFileExtensions); - - var mediaTypesNotFile = filteredMediaTypes.filter(mediaType => mediaType.alias !== "File"); - - if (mediaTypesNotFile.length <= 1) { - // if only one or less accepted types when we have filtered type 'file' out, then we wont ask to choose. - return false; - } - - - localizationService.localizeMany(["defaultdialogs_selectMediaType", "mediaType_autoPickMediaType"]).then(function (translations) { - - filteredMediaTypes.push({ - alias: "umbracoAutoSelect", - name: translations[1], - icon: "icon-wand" - }); - - const dialog = { - view: "itempicker", - filter: filteredMediaTypes.length > 8, - availableItems: filteredMediaTypes, - submit: function (model) { - scope.contentTypeAlias = model.selectedItem.alias; - _processQueueItems(); - - overlayService.close(); - }, - close: function () { - - scope.queue.map(function (file) { - file.messages.push({message:"No files uploaded, no mediatype selected", type: "Error"}); - }); - scope.queue = []; - - overlayService.close(); - } - }; - - dialog.title = translations[0]; - overlayService.open(dialog); - }); - - return true; // yes, we did open the choose-media dialog, therefore we return true. - } - - scope.dismissMessages = function (file) { - file.messages = []; - _processQueueItems(); - } - - scope.dismissAllMessages = function () { - Utilities.forEach(scope.processed, file => { - file.messages = []; - }); - _processQueueItems(); - } - - scope.handleFiles = function(files, event, invalidFiles) { - const allFiles = [...files, ...invalidFiles]; - - // add unique key for each files to use in ng-repeats - Utilities.forEach(allFiles, file => { - file.key = String.CreateGuid(); - }); - - if (scope.filesQueued) { - scope.filesQueued(allFiles, event); - } - _filesQueued(allFiles, event); - }; + if (scope.totalQueued === scope.processed.length) { + if (scope.totalMessages === 0) { + if (scope.filesUploaded) { + //queue is empty, trigger the done action + scope.filesUploaded(scope.done); } - }; - }); + + //auto-clear the done queue after 3 secs + var currentLength = scope.processed.length; + $timeout(function () { + scope.processed.splice(0, currentLength); + }, 3000); + } + } else if (scope.queue.length) { + + var file = scope.queue.shift(); + scope.processing.push(file); + _upload(file); + + // If we still have items to process + // do so right away for parallel uploads + if (scope.queue.length > 0) { + _processQueueItems(); + } + } + } + + function _upload(file) { + + scope.propertyAlias = scope.propertyAlias ? scope.propertyAlias : "umbracoFile"; + scope.contentTypeAlias = scope.contentTypeAlias ? scope.contentTypeAlias : "Image"; + + scope.processingCount++; + + Upload.upload({ + url: umbRequestHelper.getApiUrl("mediaApiBaseUrl", "PostAddFile"), + fields: { + 'currentFolder': scope.parentId, + 'contentTypeAlias': scope.contentTypeAlias, + 'propertyAlias': scope.propertyAlias, + 'path': file.path + }, + file: file + }) + .progress(function (evt) { + if (file.uploadStat !== "done" && file.uploadStat !== "error") { + // calculate progress in percentage + var progressPercentage = parseInt(100.0 * evt.loaded / evt.total, 10); + // set percentage property on file + file.uploadProgress = progressPercentage; + } + }) + .success(function (data, status, headers, config) { + // Set server messages + file.messages = data.notifications; + file.done = true; + scope.processed.push(file); + scope.processingCount--; + _processQueueItems(); + }) + .error(function (evt, status, headers, config) { + //if the service returns a detailed error + if (evt.InnerException) { + file.messages.push({ message: evt.InnerException.ExceptionMessage, type: "Error" }); + //Check if its the common "too large file" exception + if (evt.InnerException.StackTrace && + evt.InnerException.StackTrace.indexOf("ValidateRequestEntityLength") > 0) { + file.messages.push({ message: "File too large to upload", type: "Error", header: "Error" }); + } + } else if (status === 413) { + file.messages.push({ message: "File too large to upload", type: "Error", header: "Error" }); + } else if (evt.Message) { + file.messages.push({ message: evt.Message, type: "Error", header: "Error" }); + } else if (evt && typeof evt === "string") { + file.messages.push({ message: evt, type: "Error", header: "Error" }); + } else if (status === 404) { + // If file not found, server will return a 404 and display this message + file.messages.push({ message: "File not found", type: "Error" }); + } else if (status !== 200) { + file.messages.push({ message: "An unknown error occurred", type: "Error", header: "Error" }); + } + + file.done = true; + scope.processed.push(file); + scope.processingCount--; + _processQueueItems(); + }); + } + + function _requestChooseMediaTypeDialog() { + + if (scope.queue.length === 0) { + // if queue has no items so there is nothing to choose a type for + return false; + } + + if (scope.acceptedMediatypes.length === 1) { + // if only one accepted type, then we wont ask to choose. + return false; + } + + var uploadFileExtensions = scope.queue.map(file => mediaHelper.getFileExtension(file.name)); + + var filteredMediaTypes = mediaTypeHelper.getTypeAcceptingFileExtensions(scope.acceptedMediatypes, uploadFileExtensions); + + var mediaTypesNotFile = filteredMediaTypes.filter(mediaType => mediaType.alias !== "File"); + + if (mediaTypesNotFile.length <= 1) { + // if only one or less accepted types when we have filtered type 'file' out, then we wont ask to choose. + return false; + } + + + localizationService.localizeMany(["defaultdialogs_selectMediaType", "mediaType_autoPickMediaType"]).then(function (translations) { + + filteredMediaTypes.push({ + alias: "umbracoAutoSelect", + name: translations[1], + icon: "icon-wand" + }); + + const dialog = { + view: "itempicker", + filter: filteredMediaTypes.length > 8, + availableItems: filteredMediaTypes, + submit: function (model) { + scope.contentTypeAlias = model.selectedItem.alias; + _processQueueItems(); + + overlayService.close(); + }, + close: function () { + + scope.queue.map(function (file) { + file.messages.push({ message: "No files uploaded, no mediatype selected", type: "Error" }); + }); + scope.queue = []; + + overlayService.close(); + } + }; + + dialog.title = translations[0]; + overlayService.open(dialog); + }); + + return true; // yes, we did open the choose-media dialog, therefore we return true. + } + + scope.dismissMessages = function (file) { + file.messages = []; + _processQueueItems(); + } + + scope.dismissAllMessages = function () { + Utilities.forEach(scope.processed, file => { + file.messages = []; + }); + _processQueueItems(); + } + + scope.handleFiles = function (files, event, invalidFiles) { + const allFiles = [...files, ...invalidFiles]; + + // add unique key for each files to use in ng-repeats + Utilities.forEach(allFiles, file => { + file.key = String.CreateGuid(); + }); + + if (scope.filesQueued) { + scope.filesQueued(allFiles, event); + } + _filesQueued(allFiles, event); + }; + } + }; + }); diff --git a/src/Umbraco.Web.UI.Client/src/views/components/upload/umb-file-dropzone.html b/src/Umbraco.Web.UI.Client/src/views/components/upload/umb-file-dropzone.html index 6581fca14d..c90f3139a8 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/upload/umb-file-dropzone.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/upload/umb-file-dropzone.html @@ -1,91 +1,92 @@
- - -
+ + +
-
-

- Drag and drop your file(s) into the area -

+
+

+ Drag and drop your file(s) into the area +

- - + + - - -
+ + +
+
+ + +
    + + +
  • +
    + +
    +
  • - -
      +
    • +
      +
      + {{ file.name }} + + {{message.header}}: {{message.message}} + + "{{maxFileSize}}" + +
      -
    • -
      - - -
      -
    • + + + + -
    • + + +
+ -
-
- {{ file.name }} - - {{message.header}}: {{message.message}} - - "{{maxFileSize}}" - -
+ +
  • +
    {{file.name}} {{file.uploadProgress + '%'}}
    +
    + +
    +
  • - - - - +
  • +
    {{ file.name }}
    +
  • - - -
    - - -
  • -
    {{currentFile.name}} {{currentFile.uploadProgress + '%'}}
    -
    - -
    -
  • - -
  • -
    {{ file.name }}
    -
  • - - -
    + +