.NET 8 File upload streaming with chunks for larger files than 4GB!

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.

Leave a Comment