Yet again I did not find a good example on the internet for .NET 8 and pure JS solution which combined the two (streaming + chunking upload). So here is my implementation using .NET 8 and pure JS to upload larger than 4GB files chunk by chunk.
FRONTEND:
const chunkSize = 2097152; // 2MB
const filename = file.name;
let totalChunks = (file.size / chunkSize);
if (file.size % chunkSize != 0) {
totalChunks++;
}
let idx = 0;
for (let start = 0; start < file.size; start += chunkSize) {
const chunk = file.slice(start, start + chunkSize)
const formData = new FormData();
formData.set("FileUpload.FormFile", chunk);
await fetchAPI(`/file/{uuid}/chunk/${idx}`, {
method: "post",
body: formData
});
idx ++;
}
const fileExtension = filename.split(".").pop();
await fetchAPI(`/file/{uuid}/complete/${fileExtension}/${file.type.replace("/", "-")}`, {
method: "post"
});
BACKEND
[HttpPost("chunk/{chunkIdx}")]
[Authorize]
[DisableFormValueModelBinding]
[DisableRequestSizeLimit]
public async Task<IActionResult> MultiUpload([FromRoute] Guid uuid, [FromRoute] int chunkIdx, [FromRoute] int chunkSize, CancellationToken cancellationToken)
{
var request = HttpContext.Request;
var reader = GetReader(request, mediaTypeHeader);
var section = await reader.ReadNextSectionAsync(cancellationToken);
while (section != null)
{
var hasContentDispositionHeader = HasContentDispositionHeader(section, out ContentDispositionHeaderValue? contentDisposition);
if (IsSectionsDoneAndValid(contentDisposition, hasContentDispositionHeader))
{
var fileName = $"part_{chunkIdx}";
var fileSizeLimit = GetFileSizeLimit(configuration);
await SaveFile(fileName, fileSizeLimit, section, contentDisposition!, uuid.ToString());
return Ok(new
{
UploadSuccess = true
});
}
section = await reader.ReadNextSectionAsync(cancellationToken);
}
// If the code runs to this location, it means that no files have been saved
throw new ValidationException("File data not present in request.");
}
[HttpPost("complete/{fileExt}/{fileType}")] // file ext and type can be moved in body params... I'm just gonna leave it like this at this moment.
[Authorize]
public async Task<IActionResult> UploadComplete([FromRoute] Guid uuid, [FromRoute] string fileExt, [FromRoute] string fileType, CancellationToken cancellationToken)
{
var targetpath = GetTargetPath(configuration);
var path = Path.Combine(targetpath, uuid.ToString());
var newpath = Path.Combine(path, uuid.ToString());
var newFileNameWithPath = newpath + $".{fileExt}";
if (System.IO.File.Exists(newFileNameWithPath)) // Lets remove previous file if exists so the old file wouldnt get appended which would break the file.
{
System.IO.File.Delete(newFileNameWithPath);
}
var filePaths = Directory.GetFiles(path)
.Where(path => path.Contains("part")) // Only include files which contain "part" name in it which indicates the chunk files.
.OrderBy(path => path.Length).ThenBy(path => path); // Lets ensure the files are in correct order.
foreach (var item in filePaths)
{
MergeFiles(newpath, item);
}
var newFileName = uuid.ToString() + $".{fileExt}";
System.IO.File.Move(newpath, newFileNameWithPath); // Lets add file extension to merged file.
// TODO save data to database or check for viruses with amsi dll here for example...
return Ok(new
{
UploadSuccess = true
});
}
private static void MergeFiles(string file1, string file2)
{
FileStream? fs1 = null;
FileStream? fs2 = null;
try
{
fs1 = System.IO.File.Open(file1, FileMode.Append);
fs2 = System.IO.File.Open(file2, FileMode.Open);
byte[] fs2Content = new byte[fs2.Length];
fs2.Read(fs2Content, 0, (int)fs2.Length);
fs1.Write(fs2Content, 0, (int)fs2.Length);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message + " : " + ex.StackTrace);
}
finally
{
fs1?.Close();
fs2?.Close();
System.IO.File.Delete(file2);
}
}
private static MultipartReader GetReader(HttpRequest request, MediaTypeHeaderValue? mediaTypeHeader)
{
var boundary = HeaderUtilities.RemoveQuotes(mediaTypeHeader?.Boundary.Value).Value ?? throw new ValidationException("Boundary is null.");
var reader = new MultipartReader(boundary, request.Body);
return reader;
}
private static bool HasContentDispositionHeader(MultipartSection section, out ContentDispositionHeaderValue? contentDisposition)
{
return ContentDispositionHeaderValue.TryParse(section.ContentDisposition,
out contentDisposition);
}
private static bool IsSectionsDoneAndValid(ContentDispositionHeaderValue? contentDisposition, bool hasContentDispositionHeader)
{
return hasContentDispositionHeader && contentDisposition is not null && contentDisposition.DispositionType.Equals("form-data") &&
!string.IsNullOrEmpty(contentDisposition.FileName.Value);
}
private async Task<FileSaveResult> SaveFile(string fileName, long fileSizeLimit, MultipartSection section, ContentDispositionHeaderValue contentDisposition, string subDir = "")
{
var targetFilePath = GetTargetPathFromConfig(configuration);
var newDir = Path.Combine(targetFilePath, subDir);
if (subDir != string.Empty && !Directory.Exists(newDir))
{
Directory.CreateDirectory(newDir);
}
var fileExtension = Path.GetExtension(contentDisposition.FileName).ToString();
var saveToPath = Path.Combine(targetFilePath, subDir, fileName + fileExtension);
long fileSize = 0;
using (var targetStream = System.IO.File.Create(saveToPath))
{
fileSize = targetStream.Length;
if (fileSizeLimit < fileSize)
{
throw new ValidationException("File size too large!");
}
await section.Body.CopyToAsync(targetStream);
}
return new FileSaveResult {
SavePath = saveToPath,
FileSize = fileSize
};
}
Hope this helps someone. If you have any questions then feel free to comment.