diff --git a/Oqtane.Client/Modules/Controls/FileManager.razor b/Oqtane.Client/Modules/Controls/FileManager.razor index 509cef2d..71c64dc0 100644 --- a/Oqtane.Client/Modules/Controls/FileManager.razor +++ b/Oqtane.Client/Modules/Controls/FileManager.razor @@ -157,6 +157,9 @@ [Parameter] public bool UploadMultiple { get; set; } = false; // optional - enable multiple file uploads - default false + [Parameter] + public int ChunkSize { get; set; } = 1; // optional - size of file chunks to upload in MB + [Parameter] public EventCallback OnUpload { get; set; } // optional - executes a method in the calling component when a file is uploaded @@ -383,51 +386,8 @@ StateHasChanged(); } - await interop.UploadFiles(posturl, folder, _guid, SiteState.AntiForgeryToken, jwt); - - // uploading is asynchronous so we need to poll to determine if uploads are completed - var success = true; - int upload = 0; - while (upload < uploads.Length && success) - { - success = false; - var filename = uploads[upload].Split(':')[0]; - - var size = Int64.Parse(uploads[upload].Split(':')[1]); // bytes - var megabits = (size / 1048576.0) * 8; // binary conversion - var uploadspeed = (PageState.Alias.Name.Contains("localhost")) ? 100 : 3; // 3 Mbps is FCC minimum for broadband upload - var uploadtime = (megabits / uploadspeed); // seconds - var maxattempts = 5; // polling (minimum timeout duration will be 5 seconds) - var sleep = (int)Math.Ceiling(uploadtime / maxattempts) * 1000; // milliseconds - - int attempts = 0; - while (attempts < maxattempts && !success) - { - attempts += 1; - Thread.Sleep(sleep); - - if (Folder == Constants.PackagesFolder) - { - var files = await FileService.GetFilesAsync(folder); - if (files != null && files.Any(item => item.Name == filename)) - { - success = true; - } - } - else - { - var file = await FileService.GetFileAsync(int.Parse(folder), filename); - if (file != null) - { - success = true; - } - } - } - if (success) - { - upload++; - } - } + // upload files + var success = await interop.UploadFiles(posturl, folder, _guid, SiteState.AntiForgeryToken, jwt, ChunkSize); // reset progress indicators if (ShowProgress) diff --git a/Oqtane.Client/UI/Interop.cs b/Oqtane.Client/UI/Interop.cs index 8183aff5..bda75505 100644 --- a/Oqtane.Client/UI/Interop.cs +++ b/Oqtane.Client/UI/Interop.cs @@ -209,17 +209,22 @@ namespace Oqtane.UI } public Task UploadFiles(string posturl, string folder, string id, string antiforgerytoken, string jwt) + { + UploadFiles(posturl, folder, id, antiforgerytoken, jwt, 1); + return Task.CompletedTask; + } + + public ValueTask UploadFiles(string posturl, string folder, string id, string antiforgerytoken, string jwt, int chunksize) { try { - _jsRuntime.InvokeVoidAsync( + return _jsRuntime.InvokeAsync( "Oqtane.Interop.uploadFiles", - posturl, folder, id, antiforgerytoken, jwt); - return Task.CompletedTask; + posturl, folder, id, antiforgerytoken, jwt, chunksize); } catch { - return Task.CompletedTask; + return new ValueTask(Task.FromResult(false)); } } diff --git a/Oqtane.Server/Controllers/FileController.cs b/Oqtane.Server/Controllers/FileController.cs index 847cdfa8..c754b689 100644 --- a/Oqtane.Server/Controllers/FileController.cs +++ b/Oqtane.Server/Controllers/FileController.cs @@ -21,6 +21,7 @@ using System.Net.Http; using Microsoft.AspNetCore.Cors; using System.IO.Compression; using Oqtane.Services; +using Microsoft.Extensions.Primitives; // ReSharper disable StringIndexOfIsCultureSpecific.1 @@ -427,7 +428,7 @@ namespace Oqtane.Controllers // POST api//upload [EnableCors(Constants.MauiCorsPolicy)] [HttpPost("upload")] - public async Task UploadFile(string folder, IFormFile formfile) + public async Task UploadFile([FromForm] string folder, IFormFile formfile) { if (formfile == null || formfile.Length <= 0) { @@ -435,13 +436,20 @@ namespace Oqtane.Controllers } // ensure filename is valid - string token = ".part_"; - if (!formfile.FileName.IsPathOrFileValid() || !formfile.FileName.Contains(token) || !HasValidFileExtension(formfile.FileName.Substring(0, formfile.FileName.IndexOf(token)))) + if (!formfile.FileName.IsPathOrFileValid() || !HasValidFileExtension(formfile.FileName)) { _logger.Log(LogLevel.Error, this, LogFunction.Security, "File Name Is Invalid Or Contains Invalid Extension {File}", formfile.FileName); return NoContent(); } + // ensure headers exist + if (!Request.Headers.TryGetValue("PartCount", out StringValues partCount) || !Request.Headers.TryGetValue("TotalParts", out StringValues totalParts)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "File Upload Request Is Missing Required Headers"); + return NoContent(); + } + + string fileName = formfile.FileName + ".part_" + int.Parse(partCount).ToString("000") + "_" + int.Parse(totalParts).ToString("000"); string folderPath = ""; int FolderId; @@ -465,12 +473,12 @@ namespace Oqtane.Controllers if (!string.IsNullOrEmpty(folderPath)) { CreateDirectory(folderPath); - using (var stream = new FileStream(Path.Combine(folderPath, formfile.FileName), FileMode.Create)) + using (var stream = new FileStream(Path.Combine(folderPath, fileName), FileMode.Create)) { await formfile.CopyToAsync(stream); } - string upload = await MergeFile(folderPath, formfile.FileName); + string upload = await MergeFile(folderPath, fileName); if (upload != "" && FolderId != -1) { var file = CreateFile(upload, FolderId, Path.Combine(folderPath, upload)); diff --git a/Oqtane.Server/wwwroot/js/interop.js b/Oqtane.Server/wwwroot/js/interop.js index ee81109c..130ee74b 100644 --- a/Oqtane.Server/wwwroot/js/interop.js +++ b/Oqtane.Server/wwwroot/js/interop.js @@ -308,97 +308,202 @@ Oqtane.Interop = { } return files; }, - uploadFiles: function (posturl, folder, id, antiforgerytoken, jwt) { + uploadFiles: async function (posturl, folder, id, antiforgerytoken, jwt, chunksize) { + var success = true; var fileinput = document.getElementById('FileInput_' + id); var progressinfo = document.getElementById('ProgressInfo_' + id); var progressbar = document.getElementById('ProgressBar_' + id); + var totalSize = 0; + for (var i = 0; i < fileinput.files.length; i++) { + totalSize += fileinput.files[i].size; + } + let uploadSize = 0; + + if (!chunksize) { + chunksize = 1; // 1 MB default + } + if (progressinfo !== null && progressbar !== null) { + progressinfo.setAttribute('style', 'display: inline;'); + if (fileinput.files.length > 1) { + progressinfo.innerHTML = fileinput.files[0].name + ', ...'; + } + else { + progressinfo.innerHTML = fileinput.files[0].name; + } + progressbar.setAttribute('style', 'width: 100%; display: inline;'); + progressbar.value = 0; + } + + const uploadFiles = Array.from(fileinput.files).map(file => { + const uploadFile = () => { + const chunkSize = chunksize * (1024 * 1024); + const totalParts = Math.ceil(file.size / chunkSize); + let partCount = 0; + + const uploadPart = () => { + const start = partCount * chunkSize; + const end = Math.min(start + chunkSize, file.size); + const chunk = file.slice(start, end); + + return new Promise((resolve, reject) => { + let formdata = new FormData(); + formdata.append('__RequestVerificationToken', antiforgerytoken); + formdata.append('folder', folder); + formdata.append('formfile', chunk, file.name); + + var credentials = 'same-origin'; + var headers = new Headers(); + headers.append('PartCount', partCount + 1); + headers.append('TotalParts', totalParts); + if (jwt !== "") { + headers.append('Authorization', 'Bearer ' + jwt); + credentials = 'include'; + } + + return fetch(posturl, { + method: 'POST', + headers: headers, + credentials: credentials, + body: formdata + }) + .then(response => { + if (!response.ok) { + if (progressinfo !== null) { + progressinfo.innerHTML = 'Error: ' + response.statusText; + } + throw new Error('Failed'); + } + return; + }) + .then(data => { + partCount++; + if (progressbar !== null) { + uploadSize += chunk.size; + var percent = Math.ceil((uploadSize / totalSize) * 100); + progressbar.value = (percent / 100); + } + if (partCount < totalParts) { + uploadPart().then(resolve).catch(reject); + } + else { + resolve(data); + } + }) + .catch(error => { + reject(error); + }); + }); + }; + + return uploadPart(); + }; + + return uploadFile(); + }); + + try { + await Promise.all(uploadFiles); + } catch (error) { + success = false; + } + + fileinput.value = ''; + return success; + }, + uploadFile: function (posturl, folder, id, antiforgerytoken, jwt, chunksize, totalsize, file) { + var fileinput = document.getElementById('FileInput_' + id); + var progressinfo = document.getElementById('ProgressInfo_' + id); + var progressbar = document.getElementById('ProgressBar_' + id); + + if (file === null && fileinput !== null) { + file = fileinput.files[0]; + totalsize = file.size; + } + + if (progressinfo !== null && progressbar !== null && fileinput.files.length === 1) { progressinfo.setAttribute("style", "display: inline;"); - progressinfo.innerHTML = ''; + progressinfo.innerHTML = file.name; progressbar.setAttribute("style", "width: 100%; display: inline;"); progressbar.value = 0; } - var files = fileinput.files; - var totalSize = 0; - for (var i = 0; i < files.length; i++) { - totalSize = totalSize + files[i].size; + if (!chunksize) { + chunksize = 1; // 1 MB default } + const chunkSize = chunksize * (1024 * 1024); + let uploadSize = 0; + const totalParts = Math.ceil(file.size / chunkSize); + let partCount = 0; + const maxThreads = 1; + let threadCount = 1; - var maxChunkSizeMB = 1; - var bufferChunkSize = maxChunkSizeMB * (1024 * 1024); - var uploadedSize = 0; + const uploadPart = () => { + const start = partCount * chunkSize; + const end = Math.min(start + chunkSize, file.size); + const chunk = file.slice(start, end); - for (var i = 0; i < files.length; i++) { - var fileChunk = []; - var file = files[i]; - var fileStreamPos = 0; - var endPos = bufferChunkSize; - - while (fileStreamPos < file.size) { - fileChunk.push(file.slice(fileStreamPos, endPos)); - fileStreamPos = endPos; - endPos = fileStreamPos + bufferChunkSize; + while (threadCount > maxThreads) { + // wait for thread to become available } + threadCount++; - var totalParts = fileChunk.length; - var partCount = 0; + return new Promise((resolve, reject) => { + let formdata = new FormData(); + formdata.append('__RequestVerificationToken', antiforgerytoken); + formdata.append('folder', folder); + formdata.append('formfile', chunk, file.name); - while (chunk = fileChunk.shift()) { - partCount++; - var fileName = file.name + ".part_" + partCount.toString().padStart(3, '0') + "_" + totalParts.toString().padStart(3, '0'); - - var data = new FormData(); - data.append('__RequestVerificationToken', antiforgerytoken); - data.append('folder', folder); - data.append('formfile', chunk, fileName); - var request = new XMLHttpRequest(); - request.open('POST', posturl, true); + var credentials = 'same-origin'; + var headers = new Headers(); + headers.append('PartCount', partCount + 1); + headers.append('TotalParts', totalParts); if (jwt !== "") { - request.setRequestHeader('Authorization', 'Bearer ' + jwt); - request.withCredentials = true; + headers.append('Authorization', 'Bearer ' + jwt); + credentials = 'include'; } - request.upload.onloadstart = function (e) { - if (progressinfo !== null && progressbar !== null && progressinfo.innerHTML === '') { - if (files.length === 1) { - progressinfo.innerHTML = file.name; - } - else { - progressinfo.innerHTML = file.name + ", ..."; - } - } - }; - request.upload.onprogress = function (e) { - if (progressinfo !== null && progressbar !== null) { - var percent = Math.ceil(((uploadedSize + e.loaded) / totalSize) * 100); - progressbar.value = (percent / 100); - } - }; - request.upload.onloadend = function (e) { - if (progressinfo !== null && progressbar !== null) { - uploadedSize = uploadedSize + e.total; - var percent = Math.ceil((uploadedSize / totalSize) * 100); - progressbar.value = (percent / 100); - } - }; - request.upload.onerror = function() { - if (progressinfo !== null && progressbar !== null) { - if (files.length === 1) { - progressinfo.innerHTML = file.name + ' Error: ' + request.statusText; - } - else { - progressinfo.innerHTML = ' Error: ' + request.statusText; - } - } - }; - request.send(data); - } - if (i === files.length - 1) { - fileinput.value = ''; - } - } + return fetch(posturl, { + method: 'POST', + headers: headers, + credentials: credentials, + body: formdata + }) + .then(response => { + if (!response.ok) { + if (progressinfo !== null) { + progressinfo.innerHTML = ' Error: ' + response.statusText; + } + throw new Error('Failed'); + } + if (progressbar !== null) { + progressbar.value = 1; + } + return; + }) + .then(data => { + partCount++; + if (progressbar !== null) { + uploadSize += chunk.size; + var percent = Math.ceil((uploadSize / totalsize) * 100); + progressbar.value = (percent / 100); + } + threadCount--; + if (partCount < totalParts) { + uploadPart().then(resolve).catch(reject); + } + else { + resolve(data); + } + }) + .catch(error => { + reject(error); + }); + }); + }; + + return uploadPart(); }, refreshBrowser: function (verify, wait) { async function attemptReload (verify) {